From ce2c834cb7424f682e5a61a757aa17cd5ba479ff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Gori=C4=8Dar?= Date: Mon, 23 Sep 2024 00:39:39 +0200 Subject: [PATCH] chore: finish all essential endpoints under the new schema (but much refactoring is still needed) --- Cargo.lock | 459 +--------------- Cargo.toml | 10 +- _typos.toml | 2 + kolomoni/src/api/errors.rs | 169 ++++-- kolomoni/src/api/macros.rs | 91 +++- kolomoni/src/api/mod.rs | 7 +- kolomoni/src/api/traits.rs | 12 + kolomoni/src/api/v1/dictionary.rs | 72 --- kolomoni/src/api/v1/dictionary/categories.rs | 344 ++++++++---- .../src/api/v1/dictionary/english/meaning.rs | 355 ++++++++++++ kolomoni/src/api/v1/dictionary/english/mod.rs | 16 + .../{english_word.rs => english/word.rs} | 478 ++++++++-------- kolomoni/src/api/v1/dictionary/mod.rs | 48 ++ kolomoni/src/api/v1/dictionary/search.rs | 10 +- .../src/api/v1/dictionary/slovene/meaning.rs | 367 +++++++++++++ kolomoni/src/api/v1/dictionary/slovene/mod.rs | 20 + .../{slovene_word.rs => slovene/word.rs} | 395 +++++++++----- kolomoni/src/api/v1/dictionary/suggestions.rs | 28 +- .../src/api/v1/dictionary/translations.rs | 161 +++--- kolomoni/src/api/v1/login.rs | 156 ++---- kolomoni/src/api/v1/mod.rs | 2 + kolomoni/src/api/v1/users.rs | 215 -------- kolomoni/src/api/v1/users/all.rs | 67 +-- kolomoni/src/api/v1/users/current.rs | 150 ++--- kolomoni/src/api/v1/users/mod.rs | 80 +++ kolomoni/src/api/v1/users/registration.rs | 113 ++-- kolomoni/src/api/v1/users/specific.rs | 338 ++++++------ kolomoni/src/authentication.rs | 84 ++- kolomoni/src/cli.rs | 7 + kolomoni/src/lib.rs | 153 ++++-- kolomoni/src/state.rs | 8 +- kolomoni/src/testing.rs | 10 +- kolomoni_auth/Cargo.toml | 4 + kolomoni_auth/src/permissions.rs | 157 +++--- kolomoni_auth/src/roles.rs | 12 +- kolomoni_auth/src/token.rs | 83 ++- kolomoni_configuration/src/lib.rs | 2 + .../src/structure/database.rs | 4 +- kolomoni_core/Cargo.toml | 9 + kolomoni_core/src/api_models/dictionary.rs | 19 + kolomoni_core/src/api_models/mod.rs | 4 + kolomoni_core/src/api_models/users.rs | 291 ++++++++++ kolomoni_core/src/id.rs | 143 +++-- kolomoni_core/src/lib.rs | 1 + kolomoni_database/Cargo.toml | 1 + .../src/entities/category/mod.rs | 5 + .../src/entities/category/model.rs | 26 +- .../src/entities/category/mutation.rs | 149 +++++ .../src/entities/category/query.rs | 110 ++++ kolomoni_database/src/entities/edit/mod.rs | 1 + kolomoni_database/src/entities/edit/model.rs | 24 +- kolomoni_database/src/entities/mod.rs | 42 +- .../src/entities/permission/model.rs | 11 +- kolomoni_database/src/entities/role/mod.rs | 1 + kolomoni_database/src/entities/role/model.rs | 8 +- kolomoni_database/src/entities/user/mod.rs | 47 ++ kolomoni_database/src/entities/user/model.rs | 45 +- .../src/entities/user/mutation.rs | 92 ++++ kolomoni_database/src/entities/user/query.rs | 67 +-- .../src/entities/user_role/mod.rs | 2 + .../src/entities/user_role/model.rs | 8 +- .../src/entities/user_role/mutation.rs | 132 +++++ .../src/entities/user_role/query.rs | 12 +- kolomoni_database/src/entities/word/mod.rs | 1 + kolomoni_database/src/entities/word/model.rs | 26 +- .../src/entities/word_english/mod.rs | 3 + .../src/entities/word_english/model.rs | 98 +++- .../src/entities/word_english/mutation.rs | 123 +++++ .../src/entities/word_english/query.rs | 514 +++++++++++++++++- .../src/entities/word_english_meaning/mod.rs | 5 + .../entities/word_english_meaning/model.rs | 242 ++++++++- .../entities/word_english_meaning/mutation.rs | 203 +++++++ .../entities/word_english_meaning/query.rs | 214 ++++++++ .../src/entities/word_meaning/mod.rs | 1 + .../src/entities/word_meaning/model.rs | 18 +- .../entities/word_meaning_translation/mod.rs | 5 + .../word_meaning_translation/model.rs | 22 +- .../word_meaning_translation/mutation.rs | 63 +++ .../word_meaning_translation/query.rs | 30 + .../src/entities/word_slovene/mod.rs | 4 +- .../src/entities/word_slovene/model.rs | 95 +++- .../src/entities/word_slovene/mutation.rs | 132 +++++ .../src/entities/word_slovene/query.rs | 482 +++++++++++++++- .../src/entities/word_slovene_meaning/mod.rs | 5 + .../entities/word_slovene_meaning/model.rs | 225 +++++++- .../entities/word_slovene_meaning/mutation.rs | 168 ++++++ .../entities/word_slovene_meaning/query.rs | 222 ++++++++ kolomoni_database/src/lib.rs | 57 +- kolomoni_migrations/src/lib.rs | 5 + kolomoni_migrations/src/main.rs | 11 +- kolomoni_migrations/src/migrations.rs | 3 + kolomoni_migrations_core/src/lib.rs | 4 +- kolomoni_migrations_core/src/migrations.rs | 9 + kolomoni_migrations_macros/src/lib.rs | 44 +- kolomoni_openapi/src/main.rs | 4 +- kolomoni_test/tests/end_to_end/words.rs | 38 +- kolomoni_test_util/src/sample_words.rs | 10 +- 97 files changed, 6612 insertions(+), 2413 deletions(-) create mode 100644 _typos.toml create mode 100644 kolomoni/src/api/traits.rs delete mode 100644 kolomoni/src/api/v1/dictionary.rs create mode 100644 kolomoni/src/api/v1/dictionary/english/meaning.rs create mode 100644 kolomoni/src/api/v1/dictionary/english/mod.rs rename kolomoni/src/api/v1/dictionary/{english_word.rs => english/word.rs} (60%) create mode 100644 kolomoni/src/api/v1/dictionary/mod.rs create mode 100644 kolomoni/src/api/v1/dictionary/slovene/meaning.rs create mode 100644 kolomoni/src/api/v1/dictionary/slovene/mod.rs rename kolomoni/src/api/v1/dictionary/{slovene_word.rs => slovene/word.rs} (62%) delete mode 100644 kolomoni/src/api/v1/users.rs create mode 100644 kolomoni/src/api/v1/users/mod.rs create mode 100644 kolomoni_core/src/api_models/dictionary.rs create mode 100644 kolomoni_core/src/api_models/mod.rs create mode 100644 kolomoni_core/src/api_models/users.rs create mode 100644 kolomoni_database/src/entities/category/mutation.rs create mode 100644 kolomoni_database/src/entities/category/query.rs create mode 100644 kolomoni_database/src/entities/user/mutation.rs create mode 100644 kolomoni_database/src/entities/user_role/mutation.rs create mode 100644 kolomoni_database/src/entities/word_english/mutation.rs create mode 100644 kolomoni_database/src/entities/word_english_meaning/mutation.rs create mode 100644 kolomoni_database/src/entities/word_english_meaning/query.rs create mode 100644 kolomoni_database/src/entities/word_meaning_translation/mutation.rs create mode 100644 kolomoni_database/src/entities/word_meaning_translation/query.rs create mode 100644 kolomoni_database/src/entities/word_slovene/mutation.rs create mode 100644 kolomoni_database/src/entities/word_slovene_meaning/mutation.rs create mode 100644 kolomoni_database/src/entities/word_slovene_meaning/query.rs create mode 100644 kolomoni_migrations/src/lib.rs create mode 100644 kolomoni_migrations/src/migrations.rs diff --git a/Cargo.lock b/Cargo.lock index 3cd0cd7..d0369a2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -321,12 +321,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "arc-swap" -version = "1.7.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" - [[package]] name = "argon2" version = "0.5.3" @@ -339,17 +333,6 @@ dependencies = [ "password-hash", ] -[[package]] -name = "async-trait" -version = "0.1.80" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6fa2087f2753a7da8cc1c0dbfcf89579dd57458e36769de5ac750b4671737ca" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.77", -] - [[package]] name = "atoi" version = "2.0.0" @@ -428,15 +411,6 @@ dependencies = [ "serde", ] -[[package]] -name = "bitpacking" -version = "0.9.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c1d3e2bfd8d06048a179f7b17afc3188effa10385e7b00dc65af6aae732ea92" -dependencies = [ - "crunchy", -] - [[package]] name = "blake2" version = "0.10.6" @@ -514,12 +488,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "census" -version = "0.4.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f4c707c6a209cbe82d10abd08e1ea8995e9ea937d2550646e02798948992be0" - [[package]] name = "cfg-if" version = "1.0.0" @@ -677,25 +645,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-deque" -version = "0.8.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "613f8cc01fe9cf1a3eb3d7f488fd2fa8388403e97039e2f73692932e291a770d" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-queue" version = "0.3.11" @@ -711,12 +660,6 @@ version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "248e3bacc7dc6baa3b21e405ee045c3047101a49145e7e9eca583ab4c2ca5345" -[[package]] -name = "crunchy" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a81dae078cea95a014a339291cec439d2f232ebe854a9d672b796c6afafa9b7" - [[package]] name = "crypto-common" version = "0.1.6" @@ -814,12 +757,6 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "downcast-rs" -version = "1.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" - [[package]] name = "dunce" version = "1.0.5" @@ -882,12 +819,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "fastdivide" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59668941c55e5c186b8b58c391629af56774ec768f73c08bbcd56f09348eb00b" - [[package]] name = "fastrand" version = "2.0.2" @@ -961,16 +892,6 @@ dependencies = [ "thiserror", ] -[[package]] -name = "fs4" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "21dabded2e32cd57ded879041205c60a4a4c4bab47bd0fd2fa8b01f30849f02b" -dependencies = [ - "rustix", - "windows-sys 0.52.0", -] - [[package]] name = "futures-channel" version = "0.3.30" @@ -1055,19 +976,6 @@ dependencies = [ "slab", ] -[[package]] -name = "generator" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cc16584ff22b460a382b7feec54b23d2908d858152e5739a120b949293bd74e" -dependencies = [ - "cc", - "libc", - "log", - "rustversion", - "windows", -] - [[package]] name = "generic-array" version = "0.14.7" @@ -1205,12 +1113,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "htmlescape" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9025058dae765dee5070ec375f591e2ba14638c63feff74f13805a72e523163" - [[package]] name = "http" version = "0.2.12" @@ -1408,18 +1310,6 @@ dependencies = [ "serde", ] -[[package]] -name = "instant" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a5bbe824c507c5da5956355e86a746d82e0e1464f65d862cc5e71da70e94b2c" -dependencies = [ - "cfg-if", - "js-sys", - "wasm-bindgen", - "web-sys", -] - [[package]] name = "ipnet" version = "2.9.0" @@ -1505,15 +1395,16 @@ dependencies = [ "itertools 0.13.0", "kolomoni_auth", "kolomoni_configuration", + "kolomoni_core", "kolomoni_database", "kolomoni_migrations", - "kolomoni_search", "miette", "mime", "paste", "serde", "serde_json", "serde_with", + "sqlx", "thiserror", "tokio", "tracing", @@ -1521,6 +1412,7 @@ dependencies = [ "tracing-appender", "tracing-subscriber", "utoipa", + "uuid", ] [[package]] @@ -1532,12 +1424,14 @@ dependencies = [ "argon2", "chrono", "jsonwebtoken", + "kolomoni_core", "miette", "serde", "serde_with", "thiserror", "tokio", "tracing", + "uuid", ] [[package]] @@ -1559,6 +1453,7 @@ dependencies = [ "serde", "serde_json", "serde_with", + "utoipa", "uuid", ] @@ -1577,6 +1472,7 @@ dependencies = [ "serde_json", "sqlx", "thiserror", + "tracing", "uuid", ] @@ -1652,21 +1548,6 @@ dependencies = [ "utoipa-rapidoc", ] -[[package]] -name = "kolomoni_search" -version = "0.1.0" -dependencies = [ - "chrono", - "kolomoni_configuration", - "kolomoni_database", - "miette", - "slotmap", - "tantivy", - "tokio", - "tracing", - "uuid", -] - [[package]] name = "kolomoni_test" version = "0.1.0" @@ -1715,12 +1596,6 @@ dependencies = [ "spin 0.5.2", ] -[[package]] -name = "levenshtein_automata" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c2cdeb66e45e9f36bfad5bbdb4d2384e70936afbee843c6f6543f0c551ebb25" - [[package]] name = "libc" version = "0.2.153" @@ -1783,35 +1658,6 @@ version = "0.4.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "90ed8c1e510134f979dbc4f070f87d4313098b704861a105fe34231c70a3901c" -[[package]] -name = "loom" -version = "0.5.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff50ecb28bb86013e935fb6683ab1f6d3a20016f123c76fd4c27470076ac30f5" -dependencies = [ - "cfg-if", - "generator", - "pin-utils", - "scoped-tls", - "tracing", - "tracing-subscriber", -] - -[[package]] -name = "lru" -version = "0.12.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3262e75e648fce39813cb56ac41f3c3e3f65217ebf3844d818d1f9398cfb0dc" -dependencies = [ - "hashbrown 0.14.5", -] - -[[package]] -name = "lz4_flex" -version = "0.11.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75761162ae2b0e580d7e7c390558127e5f01b4194debd6221fd8c207fc80e3f5" - [[package]] name = "matchers" version = "0.1.0" @@ -1831,31 +1677,12 @@ dependencies = [ "digest", ] -[[package]] -name = "measure_time" -version = "0.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "56220900f1a0923789ecd6bf25fbae8af3b2f1ff3e9e297fc9b6b8674dd4d852" -dependencies = [ - "instant", - "log", -] - [[package]] name = "memchr" version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c8640c5d730cb13ebd907d8d04b52f55ac9a2eec55b440c8892f40d56c76c1d" -[[package]] -name = "memmap2" -version = "0.9.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe751422e4a8caa417e13c3ea66452215d7d63e19e604f4980461212f3ae1322" -dependencies = [ - "libc", -] - [[package]] name = "miette" version = "7.2.0" @@ -1932,12 +1759,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "murmurhash32" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2195bf6aa996a481483b29d62a7663eed3fe39600c460e323f8ff41e90bdd89b" - [[package]] name = "mutually_exclusive_features" version = "0.0.3" @@ -2046,16 +1867,6 @@ dependencies = [ "libm", ] -[[package]] -name = "num_cpus" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" -dependencies = [ - "hermit-abi", - "libc", -] - [[package]] name = "object" version = "0.32.2" @@ -2071,15 +1882,6 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" -[[package]] -name = "oneshot" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f6640c6bda7731b1fdbab747981a0f896dd1fedaf9f4a53fa237a04a84431f4" -dependencies = [ - "loom", -] - [[package]] name = "openssl" version = "0.10.64" @@ -2130,15 +1932,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" -[[package]] -name = "ownedbytes" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3a059efb063b8f425b948e042e6b9bd85edfe60e913630ed727b23e2dfcc558" -dependencies = [ - "stable_deref_trait", -] - [[package]] name = "owo-colors" version = "4.0.0" @@ -2387,36 +2180,6 @@ dependencies = [ "getrandom", ] -[[package]] -name = "rand_distr" -version = "0.4.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32cb0b9bc82b0a0876c2dd994a7e7a2683d3e7390ca40e6886785ef0c7e3ee31" -dependencies = [ - "num-traits", - "rand", -] - -[[package]] -name = "rayon" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b418a60154510ca1a002a752ca9714984e21e4241e804d32555251faf8b78ffa" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1465873a3dfdaa8ae7cb14b4383657caab0b3e8a0aa9ae8e04b044854c8dfce2" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "redox_syscall" version = "0.4.1" @@ -2554,28 +2317,12 @@ dependencies = [ "zeroize", ] -[[package]] -name = "rust-stemmers" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e46a2036019fdb888131db7a4c847a1063a7493f971ed94ea82c67eada63ca54" -dependencies = [ - "serde", - "serde_derive", -] - [[package]] name = "rustc-demangle" version = "0.1.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" -[[package]] -name = "rustc-hash" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2" - [[package]] name = "rustc_version" version = "0.4.0" @@ -2638,12 +2385,6 @@ dependencies = [ "untrusted", ] -[[package]] -name = "rustversion" -version = "1.0.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "80af6f9131f277a45a3fba6ce8e2258037bb0477a67e610d3c1fe046ab31de47" - [[package]] name = "ryu" version = "1.0.17" @@ -2659,12 +2400,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "scoped-tls" -version = "1.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" - [[package]] name = "scopeguard" version = "1.2.0" @@ -2851,15 +2586,6 @@ dependencies = [ "time", ] -[[package]] -name = "sketches-ddsketch" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85636c14b73d81f541e525f585c0a2109e6744e1565b5c1668e31c70c10ed65c" -dependencies = [ - "serde", -] - [[package]] name = "slab" version = "0.4.9" @@ -2869,15 +2595,6 @@ dependencies = [ "autocfg", ] -[[package]] -name = "slotmap" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbff4acf519f630b3a3ddcfaea6c06b42174d9a44bc70c620e9ed1649d58b82a" -dependencies = [ - "version_check", -] - [[package]] name = "smallvec" version = "1.13.2" @@ -3142,12 +2859,6 @@ dependencies = [ "uuid", ] -[[package]] -name = "stable_deref_trait" -version = "1.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" - [[package]] name = "stringprep" version = "0.1.4" @@ -3250,147 +2961,6 @@ dependencies = [ "libc", ] -[[package]] -name = "tantivy" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8d0582f186c0a6d55655d24543f15e43607299425c5ad8352c242b914b31856" -dependencies = [ - "aho-corasick", - "arc-swap", - "base64 0.22.1", - "bitpacking", - "byteorder", - "census", - "crc32fast", - "crossbeam-channel", - "downcast-rs", - "fastdivide", - "fnv", - "fs4", - "htmlescape", - "itertools 0.12.1", - "levenshtein_automata", - "log", - "lru", - "lz4_flex", - "measure_time", - "memmap2", - "num_cpus", - "once_cell", - "oneshot", - "rayon", - "regex", - "rust-stemmers", - "rustc-hash", - "serde", - "serde_json", - "sketches-ddsketch", - "smallvec", - "tantivy-bitpacker", - "tantivy-columnar", - "tantivy-common", - "tantivy-fst", - "tantivy-query-grammar", - "tantivy-stacker", - "tantivy-tokenizer-api", - "tempfile", - "thiserror", - "time", - "uuid", - "winapi", -] - -[[package]] -name = "tantivy-bitpacker" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "284899c2325d6832203ac6ff5891b297fc5239c3dc754c5bc1977855b23c10df" -dependencies = [ - "bitpacking", -] - -[[package]] -name = "tantivy-columnar" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12722224ffbe346c7fec3275c699e508fd0d4710e629e933d5736ec524a1f44e" -dependencies = [ - "downcast-rs", - "fastdivide", - "itertools 0.12.1", - "serde", - "tantivy-bitpacker", - "tantivy-common", - "tantivy-sstable", - "tantivy-stacker", -] - -[[package]] -name = "tantivy-common" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8019e3cabcfd20a1380b491e13ff42f57bb38bf97c3d5fa5c07e50816e0621f4" -dependencies = [ - "async-trait", - "byteorder", - "ownedbytes", - "serde", - "time", -] - -[[package]] -name = "tantivy-fst" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d60769b80ad7953d8a7b2c70cdfe722bbcdcac6bccc8ac934c40c034d866fc18" -dependencies = [ - "byteorder", - "regex-syntax 0.8.3", - "utf8-ranges", -] - -[[package]] -name = "tantivy-query-grammar" -version = "0.22.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "847434d4af57b32e309f4ab1b4f1707a6c566656264caa427ff4285c4d9d0b82" -dependencies = [ - "nom", -] - -[[package]] -name = "tantivy-sstable" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c69578242e8e9fc989119f522ba5b49a38ac20f576fc778035b96cc94f41f98e" -dependencies = [ - "tantivy-bitpacker", - "tantivy-common", - "tantivy-fst", - "zstd", -] - -[[package]] -name = "tantivy-stacker" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56d6ff5591fc332739b3ce7035b57995a3ce29a93ffd6012660e0949c956ea8" -dependencies = [ - "murmurhash32", - "rand_distr", - "tantivy-common", -] - -[[package]] -name = "tantivy-tokenizer-api" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a0dcade25819a89cfe6f17d932c9cedff11989936bf6dd4f336d50392053b04" -dependencies = [ - "serde", -] - [[package]] name = "tempfile" version = "3.10.1" @@ -3791,12 +3361,6 @@ dependencies = [ "percent-encoding", ] -[[package]] -name = "utf8-ranges" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7fcfc827f90e53a02eaef5e535ee14266c1d569214c6aa70133a624d8a3164ba" - [[package]] name = "utf8parse" version = "0.2.1" @@ -3994,15 +3558,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" -[[package]] -name = "windows" -version = "0.48.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e686886bc078bc1b0b600cac0147aadb815089b6e4da64016cbd754b6342700f" -dependencies = [ - "windows-targets 0.48.5", -] - [[package]] name = "windows-core" version = "0.52.0" diff --git a/Cargo.toml b/Cargo.toml index 4d6a67c..5827e55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ members = [ "kolomoni_migrations_core", "kolomoni_migrations_macros", "kolomoni_openapi", - "kolomoni_search", + # "kolomoni_search", "kolomoni_test", "kolomoni_test_util" ] @@ -102,6 +102,7 @@ features = [ "postgres", "uuid", "chrono", + "json" ] @@ -136,11 +137,12 @@ path = "./kolomoni/src/main.rs" [dependencies] +kolomoni_core = { path = "./kolomoni_core" } kolomoni_migrations = { path = "./kolomoni_migrations" } kolomoni_configuration = { path = "./kolomoni_configuration" } kolomoni_auth = { path = "./kolomoni_auth" } kolomoni_database = { path = "./kolomoni_database" } -kolomoni_search = { path = "./kolomoni_search" } +# kolomoni_search = { path = "./kolomoni_search" } clap = { workspace = true } @@ -164,10 +166,13 @@ actix-cors = { workspace = true } utoipa = { workspace = true } +sqlx = { workspace = true } + # sea-orm = { workspace = true } # sea-orm-migration = { workspace = true } dunce = { workspace = true } +uuid = { workspace = true } chrono = { workspace = true } itertools = { workspace = true } @@ -178,6 +183,7 @@ paste = { workspace = true } +# TODO rename [features] with_test_facilities = [] diff --git a/_typos.toml b/_typos.toml new file mode 100644 index 0000000..919f52e --- /dev/null +++ b/_typos.toml @@ -0,0 +1,2 @@ +[default.extend-words] +lik = "lik" diff --git a/kolomoni/src/api/errors.rs b/kolomoni/src/api/errors.rs index f1ec744..61f69ce 100644 --- a/kolomoni/src/api/errors.rs +++ b/kolomoni/src/api/errors.rs @@ -2,19 +2,24 @@ //! and ways to have those errors automatically turned into correct //! HTTP error responses when returned as `Err(error)` from those functions. +use std::borrow::Cow; use std::fmt::{Display, Formatter}; use actix_web::body::BoxBody; use actix_web::http::StatusCode; use actix_web::{HttpResponse, ResponseError}; use itertools::Itertools; -use kolomoni_auth::Permission; -use sea_orm::DbErr; +use kolomoni_auth::{JWTCreationError, Permission}; +use kolomoni_database::entities::UserQueryError; +use kolomoni_database::QueryError; use serde::Serialize; use thiserror::Error; use tracing::error; use utoipa::ToSchema; +use super::macros::{KolomoniResponseBuilderJSONError, KolomoniResponseBuilderLMAError}; +use crate::authentication::AuthenticatedUserError; + /// Simple JSON-encodable response containing a single field: a `reason`. /// @@ -212,25 +217,33 @@ pub enum APIError { /// Bad client request with a reason; will produce a `400 Bad Request`. /// The `reason` will also be sent along in the response. - OtherClientError { reason: String }, + OtherClientError { reason: Cow<'static, str> }, /// Internal error with a string reason. - /// Triggers a `500 Internal Server Error` (*doesn't leak the error through the API*). - InternalReason(String), - - /// Internal error, constructed from an [`miette::Error`]. - /// Triggers a `500 Internal Server Error` (*doesn't leak the error through the API*). - InternalError(miette::Error), + /// Triggers a `500 Internal Server Error` (**reason doesn't leak through the API**). + InternalErrorWithReason { reason: Cow<'static, str> }, + + /// Internal error, constructed from a boxed [`Error`]. + /// Triggers a `500 Internal Server Error` (**error doesn't leak through the API**). + InternalGenericError { + #[from] + #[source] + error: Box, + }, - /// Internal error, constructed from an [`sea_orm::error::DbErr`]. + /// Internal error, constructed from a [`sqlx::Error`]. /// Triggers a `500 Internal Server Error` (*doesn't leak the error through the API*). - InternalDatabaseError(DbErr), + InternalDatabaseError { + #[from] + #[source] + error: sqlx::Error, + }, } impl APIError { /// Initialize a new not found API error without a specific reason. #[inline] - pub fn not_found() -> Self { + pub const fn not_found() -> Self { Self::NotFound { reason_response: None, } @@ -249,7 +262,7 @@ impl APIError { /// a permission (or multiple permissions), but without clarification as to which those are. #[allow(dead_code)] #[inline] - pub fn missing_permission() -> Self { + pub const fn missing_permission() -> Self { Self::NotEnoughPermissions { missing_permission: None, } @@ -257,10 +270,10 @@ impl APIError { pub fn client_error(reason: S) -> Self where - S: Into, + S: Into>, { Self::OtherClientError { - reason: reason.into(), + reason: Cow::from(reason.into()), } } @@ -277,21 +290,36 @@ impl APIError { /// some set of permissions. #[inline] #[allow(dead_code)] - pub fn missing_specific_permissions(permissions: Vec) -> Self { + pub const fn missing_specific_permissions(permissions: Vec) -> Self { Self::NotEnoughPermissions { missing_permission: Some(permissions), } } + pub fn internal_error(error: E) -> Self + where + E: std::error::Error + 'static, + { + Self::InternalGenericError { + error: Box::new(error), + } + } + + pub fn internal_database_error(error: sqlx::Error) -> Self { + Self::InternalDatabaseError { error } + } + /// Initialize a new internal API error using an internal reason string. /// When constructing an HTTP response using this error variant, the **reason /// is not leaked through the API.** #[inline] - pub fn internal_reason(reason: S) -> Self + pub fn internal_error_with_reason(reason: S) -> Self where - S: Into, + S: Into>, { - Self::InternalReason(reason.into()) + Self::InternalErrorWithReason { + reason: reason.into(), + } } } @@ -330,9 +358,19 @@ impl Display for APIError { } }, APIError::OtherClientError { reason } => write!(f, "Client error: {}", reason), - APIError::InternalReason(reason) => write!(f, "Internal error: {reason}."), - APIError::InternalError(error) => write!(f, "Internal error: {error}."), - APIError::InternalDatabaseError(error) => write!(f, "Internal database error: {error}."), + APIError::InternalErrorWithReason { reason } => write!( + f, + "Internal server error (with reason): {reason}." + ), + APIError::InternalGenericError { error } => { + write!(f, "Internal server error (generic): {error:?}") + } + APIError::InternalDatabaseError { error } => { + write!( + f, + "Internal server error (database error): {error}." + ) + } } } } @@ -344,9 +382,9 @@ impl ResponseError for APIError { APIError::NotEnoughPermissions { .. } => StatusCode::FORBIDDEN, APIError::NotFound { .. } => StatusCode::NOT_FOUND, APIError::OtherClientError { .. } => StatusCode::BAD_REQUEST, - APIError::InternalReason(_) => StatusCode::INTERNAL_SERVER_ERROR, - APIError::InternalError(_) => StatusCode::INTERNAL_SERVER_ERROR, - APIError::InternalDatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR, + APIError::InternalErrorWithReason { .. } => StatusCode::INTERNAL_SERVER_ERROR, + APIError::InternalGenericError { .. } => StatusCode::INTERNAL_SERVER_ERROR, + APIError::InternalDatabaseError { .. } => StatusCode::INTERNAL_SERVER_ERROR, } } @@ -370,23 +408,23 @@ impl ResponseError for APIError { reason: reason.to_string(), }) } - APIError::InternalReason(error) => { - error!(error = error, "Internal error."); + APIError::InternalErrorWithReason { reason } => { + error!(error = %reason, "Internal database error (custom reason)."); HttpResponse::InternalServerError().finish() } - APIError::InternalError(error) => { + APIError::InternalGenericError { error } => { error!( - error = error.to_string(), - "Internal server error." + error = ?error, + "Internal server error (generic)." ); HttpResponse::InternalServerError().finish() } - APIError::InternalDatabaseError(error) => { + APIError::InternalDatabaseError { error } => { error!( - error = error.to_string(), - "Internal database error.", + error = ?error, + "Internal server error (database error).", ); HttpResponse::InternalServerError().finish() @@ -396,6 +434,71 @@ impl ResponseError for APIError { } +impl From for APIError { + fn from(value: QueryError) -> Self { + match value { + QueryError::SqlxError { error } => Self::InternalDatabaseError { error }, + QueryError::ModelError { reason } => Self::InternalErrorWithReason { reason }, + QueryError::DatabaseInconsistencyError { problem: reason } => { + Self::InternalErrorWithReason { reason } + } + } + } +} + +impl From for APIError { + fn from(value: UserQueryError) -> Self { + match value { + UserQueryError::SqlxError { error } => Self::InternalDatabaseError { error }, + UserQueryError::ModelError { reason } => Self::InternalErrorWithReason { reason }, + UserQueryError::HasherError { error } => Self::InternalGenericError { + error: Box::new(error), + }, + } + } +} + +impl From for APIError { + fn from(value: AuthenticatedUserError) -> Self { + match value { + AuthenticatedUserError::QueryError { error } => Self::from(error), + } + } +} + +impl From for APIError { + fn from(value: JWTCreationError) -> Self { + match value { + JWTCreationError::JWTError { error } => Self::InternalGenericError { + error: Box::new(error), + }, + } + } +} + +impl From for APIError { + fn from(value: KolomoniResponseBuilderJSONError) -> Self { + match value { + KolomoniResponseBuilderJSONError::JsonError { error } => Self::InternalGenericError { + error: Box::new(error), + }, + } + } +} + +impl From for APIError { + fn from(value: KolomoniResponseBuilderLMAError) -> Self { + match value { + KolomoniResponseBuilderLMAError::JsonError { error } => Self::InternalGenericError { + error: Box::new(error), + }, + } + } +} + + + + /// Short for [`Result`]`<`[`HttpResponse`]`, `[`APIError`]`>`, intended to be used in most /// places in handlers of the Stari Kolomoni API. /// diff --git a/kolomoni/src/api/macros.rs b/kolomoni/src/api/macros.rs index d217d39..ecfcfd0 100644 --- a/kolomoni/src/api/macros.rs +++ b/kolomoni/src/api/macros.rs @@ -5,8 +5,8 @@ use actix_web::http::header::{self, HeaderValue, InvalidHeaderValue}; use actix_web::http::StatusCode; use actix_web::{http, HttpResponse, ResponseError}; use chrono::{DateTime, Utc}; -use miette::{Context, IntoDiagnostic, Result}; use serde::Serialize; +use thiserror::Error; use super::errors::APIError; @@ -25,6 +25,42 @@ pub fn construct_last_modified_header_value( } +pub fn construct_not_modified_response( + last_modified_at: &DateTime, +) -> Result { + let mut not_modified_response = HttpResponse::new(StatusCode::NOT_MODIFIED); + + not_modified_response.headers_mut().append( + header::LAST_MODIFIED, + construct_last_modified_header_value(last_modified_at).map_err(|_| { + APIError::internal_error_with_reason("unable to construct Last-Modified header") + })?, + ); + + Ok(not_modified_response) +} + + +#[derive(Debug, Error)] +pub enum KolomoniResponseBuilderJSONError { + #[error("failed to encode value as JSON")] + JsonError { + #[from] + #[source] + error: serde_json::Error, + }, +} + +#[derive(Debug, Error)] +pub enum KolomoniResponseBuilderLMAError { + #[error("failed to encode Last-Modified-At header")] + JsonError { + #[from] + #[source] + error: InvalidHeaderValue, + }, +} + /// A builder struct for a HTTP response with a JSON body. /// @@ -52,13 +88,11 @@ impl KolomoniResponseBuilder { /// /// The value will be serialized as JSON and prepared to be included /// in the body of the HTTP response. - pub fn new_json(value: S) -> Result + pub fn new_json(value: S) -> Result where S: Serialize, { - let body = serde_json::to_string(&value) - .into_diagnostic() - .wrap_err("Failed to serialize JSON body.")?; + let body = serde_json::to_string(&value)?; let mut additional_headers = http::header::HeaderMap::with_capacity(1); additional_headers.append( @@ -84,13 +118,14 @@ impl KolomoniResponseBuilder { /// Set the `Last-Modified` HTTP response header to some date and time. /// This has no default --- the header will not be included in the response /// if this is not called. - pub fn last_modified_at(mut self, last_modified_at: DateTime) -> Result { + pub fn last_modified_at( + mut self, + last_modified_at: DateTime, + ) -> Result { // See self.additional_headers.append( http::header::LAST_MODIFIED, - construct_last_modified_header_value(&last_modified_at) - .into_diagnostic() - .map_err(APIError::InternalError)?, + construct_last_modified_header_value(&last_modified_at)?, ); Ok(self) @@ -175,12 +210,20 @@ where response } - Err(_) => APIError::InternalReason("Failed to serialize value to JSON.".to_string()) + Err(_) => APIError::internal_error_with_reason("Failed to serialize value to JSON.") .error_response(), } } +#[macro_export] +macro_rules! obtain_database_connection { + ($state:expr) => { + $state.database.acquire().await? + }; +} + + /// A macro that, given some struct type, implements the following two traits on it: /// - [`ContextlessResponder`], allowing you to make a struct instance and call @@ -281,8 +324,7 @@ macro_rules! impl_json_response_builder { self, ) -> Result<$crate::api::macros::KolomoniResponseBuilder, $crate::api::errors::APIError> { - $crate::api::macros::KolomoniResponseBuilder::new_json(self) - .map_err($crate::api::errors::APIError::InternalError) + Ok($crate::api::macros::KolomoniResponseBuilder::new_json(self)?) } } }; @@ -325,7 +367,7 @@ macro_rules! impl_json_response_builder { /// } /// ``` #[macro_export] -macro_rules! error_response_with_reason { +macro_rules! json_error_response_with_reason { ($status_code:expr, $reason:expr) => { actix_web::HttpResponseBuilder::new($status_code) .json($crate::api::errors::ErrorReasonResponse::custom_reason($reason)) @@ -392,13 +434,12 @@ macro_rules! require_authentication { #[macro_export] macro_rules! require_permission_with_optional_authentication { - ($application_state:expr, $user_auth_extractor:expr, $permission:expr) => { + ($database_connection:expr, $user_auth_extractor:expr, $permission:expr) => { match $user_auth_extractor.authenticated_user() { Some(_authenticated_user) => { if !_authenticated_user - .has_permission(&$application_state.database, $permission) - .await - .map_err($crate::api::errors::APIError::InternalError)? + .transitively_has_permission($database_connection, $permission) + .await? { return Err( $crate::api::errors::APIError::missing_specific_permission($permission), @@ -431,11 +472,12 @@ macro_rules! require_permission_with_optional_authentication { /// /// # Arguments and examples /// ## Variant 1 (three arguments, most common) -/// - The first argument must be the [`ApplicationState`][crate::state::ApplicationState]. +/// - The first argument must be somethings that derefs to [`PgConnection`][sqlx::PgConnection] +/// (e.g. [`PoolConnection`][sqlx::PoolConnection]). /// - The second argument must be an -/// [`AuthenticatedUser`][crate::authentication::AuthenticatedUser] instance. -/// - The third argument must be the [`Permission`][kolomoni_auth::Permission] -/// you wish to check for. +/// [`AuthenticatedUser`][crate::authentication::AuthenticatedUser] instance, which is usually obtained +/// by calling the [`require_authentication!`][crate::api::macros::require_authentication] macro. +/// - The third argument must be the [`Permission`][kolomoni_auth::Permission] you want to ensure the user has. /// /// ``` /// use actix_web::get; @@ -523,11 +565,10 @@ macro_rules! require_permission { } }; - ($state:expr, $authenticated_user:expr, $required_permission:expr) => { + ($database_connection:expr, $authenticated_user:expr, $required_permission:expr) => { if !$authenticated_user - .has_permission(&$state.database, $required_permission) - .await - .map_err($crate::api::errors::APIError::InternalError)? + .transitively_has_permission($database_connection, $required_permission) + .await? { return Err( $crate::api::errors::APIError::missing_specific_permission($required_permission), diff --git a/kolomoni/src/api/mod.rs b/kolomoni/src/api/mod.rs index 9779f10..372783c 100644 --- a/kolomoni/src/api/mod.rs +++ b/kolomoni/src/api/mod.rs @@ -9,9 +9,11 @@ use self::v1::v1_api_router; pub mod errors; pub mod macros; pub mod openapi; +pub mod traits; pub mod v1; +// TODO document #[derive(Clone, PartialEq, Eq, Debug)] pub enum OptionalIfModifiedSince { Unspecified, @@ -30,7 +32,10 @@ impl OptionalIfModifiedSince { } #[inline] - pub fn has_not_changed_since(&self, real_last_modification_time: &DateTime) -> bool { + pub fn enabled_and_has_not_changed_since( + &self, + real_last_modification_time: &DateTime, + ) -> bool { match self { OptionalIfModifiedSince::Unspecified => false, OptionalIfModifiedSince::Specified(user_provided_conditional_time) => { diff --git a/kolomoni/src/api/traits.rs b/kolomoni/src/api/traits.rs new file mode 100644 index 0000000..64b0a3e --- /dev/null +++ b/kolomoni/src/api/traits.rs @@ -0,0 +1,12 @@ +pub trait IntoApiModel { + type ApiModel; + + fn into_api_model(self) -> Self::ApiModel; +} + +pub trait TryIntoApiModel { + type Error; + type ApiModel; + + fn try_into_api_model(self) -> Result; +} diff --git a/kolomoni/src/api/v1/dictionary.rs b/kolomoni/src/api/v1/dictionary.rs deleted file mode 100644 index be9289b..0000000 --- a/kolomoni/src/api/v1/dictionary.rs +++ /dev/null @@ -1,72 +0,0 @@ -use std::str::FromStr; - -use actix_web::{web, Scope}; -use chrono::{DateTime, Utc}; -use kolomoni_database::entities; -use miette::IntoDiagnostic; -use sea_orm::prelude::Uuid; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -use self::{ - categories::categories_router, - english_word::english_dictionary_router, - search::search_router, - slovene_word::slovene_dictionary_router, - suggestions::suggested_translations_router, - translations::translations_router, -}; -use crate::api::errors::APIError; - -pub mod categories; -pub mod english_word; -pub mod search; -pub mod slovene_word; -pub mod suggestions; -pub mod translations; - - -#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] -pub struct Category { - pub id: i32, - - pub slovene_name: String, - pub english_name: String, - - pub created_at: DateTime, - pub last_modified_at: DateTime, -} - -impl Category { - pub fn from_database_model(model: entities::category::Model) -> Self { - Self { - id: model.id, - slovene_name: model.slovene_name, - english_name: model.english_name, - created_at: model.created_at.to_utc(), - last_modified_at: model.last_modified_at.to_utc(), - } - } -} - - - -pub fn parse_string_into_uuid(potential_uuid: &str) -> Result { - let target_word_uuid = Uuid::from_str(potential_uuid) - .into_diagnostic() - .map_err(|_| APIError::client_error("invalid UUID"))?; - - Ok(target_word_uuid) -} - - -#[rustfmt::skip] -pub fn dictionary_router() -> Scope { - web::scope("/dictionary") - .service(slovene_dictionary_router()) - .service(english_dictionary_router()) - .service(suggested_translations_router()) - .service(translations_router()) - .service(categories_router()) - .service(search_router()) -} diff --git a/kolomoni/src/api/v1/dictionary/categories.rs b/kolomoni/src/api/v1/dictionary/categories.rs index 3ec1546..68bbf94 100644 --- a/kolomoni/src/api/v1/dictionary/categories.rs +++ b/kolomoni/src/api/v1/dictionary/categories.rs @@ -1,26 +1,29 @@ use actix_http::StatusCode; use actix_web::{delete, get, patch, post, web, HttpResponse, Scope}; +use futures_util::StreamExt; use kolomoni_auth::Permission; -use kolomoni_database::{ - mutation::{CategoryMutation, NewCategory, UpdatedCategory, WordCategoryMutation}, - query::{CategoriesQueryOptions, CategoryQuery, WordCategoryQuery, WordQuery}, - shared::WordLanguage, -}; +use kolomoni_core::id::CategoryId; +use kolomoni_database::entities::{self, CategoryValuesToUpdate, NewCategory}; use serde::{Deserialize, Serialize}; +use sqlx::Acquire; use utoipa::ToSchema; +use uuid::Uuid; use crate::{ api::{ errors::{APIError, EndpointResult}, macros::ContextlessResponder, openapi, - v1::dictionary::{parse_string_into_uuid, Category}, + traits::IntoApiModel, + v1::dictionary::Category, }, authentication::UserAuthenticationExtractor, - error_response_with_reason, impl_json_response_builder, + json_error_response_with_reason, + obtain_database_connection, require_authentication, require_permission, + require_permission_with_optional_authentication, state::ApplicationState, }; @@ -36,6 +39,7 @@ use crate::{ }) )] pub struct CategoryCreationRequest { + pub parent_category_id: Option, pub slovene_name: String, pub english_name: String, } @@ -61,6 +65,21 @@ pub struct CategoryCreationResponse { impl_json_response_builder!(CategoryCreationResponse); +impl IntoApiModel for entities::CategoryModel { + type ApiModel = Category; + + fn into_api_model(self) -> Self::ApiModel { + Category { + id: self.id, + english_name: self.english_name, + slovene_name: self.slovene_name, + created_at: self.created_at, + last_modified_at: self.last_modified_at, + } + } +} + + /// Create a new category /// @@ -99,9 +118,12 @@ pub async fn create_category( authentication: UserAuthenticationExtractor, request_body: web::Json, ) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + let authenticated_user = require_authentication!(authentication); require_permission!( - state, + &mut transaction, authenticated_user, Permission::CategoryCreate ); @@ -110,42 +132,54 @@ pub async fn create_category( let request_body = request_body.into_inner(); - let exact_category_already_exists = CategoryQuery::exists_by_both_names( - &state.database, - request_body.slovene_name.clone(), - request_body.english_name.clone(), + let category_exists_by_slovene_name = entities::CategoryQuery::exists_by_slovene_name( + &mut transaction, + &request_body.slovene_name, ) - .await - .map_err(APIError::InternalError)?; + .await?; + + if category_exists_by_slovene_name { + return Ok(json_error_response_with_reason!( + StatusCode::CONFLICT, + "Category with given slovene name already exists." + )); + } - if exact_category_already_exists { - return Ok(error_response_with_reason!( + let category_exists_by_english_name = entities::CategoryQuery::exists_by_english_name( + &mut transaction, + &request_body.english_name, + ) + .await?; + + if category_exists_by_english_name { + return Ok(json_error_response_with_reason!( StatusCode::CONFLICT, - "Category already exists." + "Category with given english name already exists." )); } - let new_category = CategoryMutation::create( - &state.database, + + let newly_created_category = entities::CategoryMutation::create( + &mut transaction, NewCategory { - english_name: request_body.english_name, + parent_category_id: request_body.parent_category_id.map(CategoryId::new), slovene_name: request_body.slovene_name, + english_name: request_body.english_name, }, ) - .await - .map_err(APIError::InternalError)?; - + .await?; + /* TODO pending rewrite of cache layer state .search .signal_category_created_or_updated(new_category.id) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; */ Ok(CategoryCreationResponse { - category: Category::from_database_model(new_category), + category: newly_created_category.into_api_model(), } .into_response()) } @@ -183,21 +217,31 @@ impl_json_response_builder!(CategoriesResponse); ) )] #[get("")] -pub async fn get_all_categories(state: ApplicationState) -> EndpointResult { - let category_models = CategoryQuery::all(&state.database, CategoriesQueryOptions::default()) - .await - .map_err(APIError::InternalError)?; +pub async fn get_all_categories( + state: ApplicationState, + authentication: UserAuthenticationExtractor, +) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + + require_permission_with_optional_authentication!( + &mut database_connection, + authentication, + Permission::CategoryRead + ); - let categories_as_api_models = category_models - .into_iter() - .map(Category::from_database_model) - .collect(); - Ok(CategoriesResponse { - categories: categories_as_api_models, + let mut category_stream = + entities::CategoryQuery::get_all_categories(&mut database_connection).await; + + + let mut categories = Vec::new(); + while let Some(internal_category) = category_stream.next().await { + categories.push(internal_category?.into_api_model()); } - .into_response()) + + + Ok(CategoriesResponse { categories }.into_response()) } @@ -254,22 +298,31 @@ impl_json_response_builder!(CategoryResponse); #[get("/{category_id}")] pub async fn get_specific_category( state: ApplicationState, - parameters: web::Path<(i32,)>, + authentication: UserAuthenticationExtractor, + parameters: web::Path<(Uuid,)>, ) -> EndpointResult { - let target_category_id = parameters.into_inner().0; + let mut database_connection = obtain_database_connection!(state); + + require_permission_with_optional_authentication!( + &mut database_connection, + authentication, + Permission::CategoryRead + ); + + + let target_category_id = CategoryId::new(parameters.into_inner().0); - let category_model = CategoryQuery::get_by_id(&state.database, target_category_id) - .await - .map_err(APIError::InternalError)?; + let category = + entities::CategoryQuery::get_by_id(&mut database_connection, target_category_id).await?; - let Some(category_model) = category_model else { + let Some(category) = category else { return Err(APIError::not_found()); }; Ok(CategoryResponse { - category: Category::from_database_model(category_model), + category: category.into_api_model(), } .into_response()) } @@ -285,8 +338,20 @@ pub async fn get_specific_category( }) )] pub struct CategoryUpdateRequest { - pub slovene_name: Option, - pub english_name: Option, + /// # Interpreting the double option + /// To distinguish from an unset and a null JSON value, this field is a + /// double option. `None` indicates the field was not present + /// (i.e. that the parent category should not change as part of this update), + /// while `Some(None)` indicates it was set to `null` + /// (i.e. that the parent category should be cleared). + /// + /// See also: [`serde_with::rust::double_option`]. + #[serde(default, with = "::serde_with::rust::double_option")] + pub new_parent_category_id: Option>, + + pub new_slovene_name: Option, + + pub new_english_name: Option, } @@ -333,77 +398,124 @@ pub struct CategoryUpdateRequest { #[patch("/{category_id}")] pub async fn update_specific_category( state: ApplicationState, - parameters: web::Path<(i32,)>, + parameters: web::Path<(Uuid,)>, authentication: UserAuthenticationExtractor, request_body: web::Json, ) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + + let authenticated_user = require_authentication!(authentication); require_permission!( - state, + &mut transaction, authenticated_user, Permission::CategoryUpdate ); + let target_category_id = CategoryId::new(parameters.into_inner().0); let request_body = request_body.into_inner(); - let target_category_id = parameters.into_inner().0; - let target_category_before_update = - CategoryQuery::get_by_id(&state.database, target_category_id) - .await - .map_err(APIError::InternalError)?; + let has_no_fields_to_update = request_body.new_parent_category_id.is_none() + && request_body.new_slovene_name.is_none() + && request_body.new_english_name.is_none(); + + if has_no_fields_to_update { + return Ok(json_error_response_with_reason!( + StatusCode::BAD_REQUEST, + "Client should provide at least one category field to update; \ + providing none on this endpoint is invalid." + )); + } + + + let target_category_exists = + entities::CategoryQuery::exists_by_id(&mut transaction, target_category_id).await?; - let Some(target_category_before_update) = target_category_before_update else { + if !target_category_exists { return Err(APIError::not_found()); }; - let updated_category_would_conflict = CategoryQuery::exists_by_both_names( - &state.database, - if let Some(updated_slovene_name) = &request_body.slovene_name { - updated_slovene_name.to_owned() - } else { - target_category_before_update.slovene_name - }, - if let Some(updated_english_name) = &request_body.english_name { - updated_english_name.to_owned() - } else { - target_category_before_update.english_name - }, - ) - .await - .map_err(APIError::InternalError)?; - if updated_category_would_conflict { - return Ok(error_response_with_reason!( + let would_conflict_by_slovene_name = if let Some(new_slovene_name) = + request_body.new_slovene_name.as_ref() + { + entities::CategoryQuery::exists_by_slovene_name(&mut transaction, &new_slovene_name).await? + } else { + false + }; + + if would_conflict_by_slovene_name { + return Ok(json_error_response_with_reason!( StatusCode::CONFLICT, - "Updated category would conflict with an existing category." + "Updated category would conflict with an existing category by its slovene name." )); } - let updated_category = CategoryMutation::update( - &state.database, + + let would_conflict_by_english_name = if let Some(new_english_name) = + request_body.new_english_name.as_ref() + { + entities::CategoryQuery::exists_by_english_name(&mut transaction, &new_english_name).await? + } else { + false + }; + + if would_conflict_by_english_name { + return Ok(json_error_response_with_reason!( + StatusCode::CONFLICT, + "Updated category would conflict with an existing category by its english name." + )); + } + + + + let successfully_updated = entities::CategoryMutation::update( + &mut transaction, target_category_id, - UpdatedCategory { - english_name: request_body.english_name, - slovene_name: request_body.slovene_name, + CategoryValuesToUpdate { + parent_category_id: request_body + .new_parent_category_id + .map(|optional_id| optional_id.map(CategoryId::new)), + slovene_name: request_body.new_slovene_name, + english_name: request_body.new_english_name, }, ) - .await - .map_err(APIError::InternalError)?; + .await?; + + if !successfully_updated { + return Err(APIError::internal_error_with_reason( + "database inconsistency: failed to update a category \ + that existed in a previous call inside the same transaction", + )); + } + + + let target_category_after_update = + entities::CategoryQuery::get_by_id(&mut transaction, target_category_id).await?; + + let Some(target_category_after_update) = target_category_after_update else { + return Err(APIError::internal_error_with_reason( + "database inconsistency: failed to fetch a category \ + that was just updated in a previous call inside the same transaction", + )); + }; + /* TODO pending rewrite of cache layer state .search .signal_category_created_or_updated(updated_category.id) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; */ Ok(CategoryResponse { - category: Category::from_database_model(updated_category), + category: target_category_after_update.into_api_model(), } .into_response()) } @@ -447,38 +559,49 @@ pub async fn update_specific_category( #[delete("/{category_id}")] pub async fn delete_specific_category( state: ApplicationState, - parameters: web::Path<(i32,)>, + parameters: web::Path<(Uuid,)>, authentication: UserAuthenticationExtractor, ) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + let authenticated_user = require_authentication!(authentication); require_permission!( - state, + &mut transaction, authenticated_user, Permission::CategoryDelete ); - let target_category_id = parameters.into_inner().0; + let target_category_id = CategoryId::new(parameters.into_inner().0); - let target_category_exists = CategoryQuery::exists_by_id(&state.database, target_category_id) - .await - .map_err(APIError::InternalError)?; + + + let target_category_exists = + entities::CategoryQuery::exists_by_id(&mut transaction, target_category_id).await?; if !target_category_exists { return Err(APIError::not_found()); } - CategoryMutation::delete(&state.database, target_category_id) - .await - .map_err(APIError::InternalError)?; + let successfully_deleted = + entities::CategoryMutation::delete(&mut transaction, target_category_id).await?; + if !successfully_deleted { + return Err(APIError::internal_error_with_reason( + "database inconsistency: failed to delete a category that \ + previously existed in the same transaction", + )); + } + + /* TODO pending rewrite of cache layer state .search .signal_category_removed(target_category_id) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; */ Ok(HttpResponse::Ok().finish()) @@ -486,6 +609,7 @@ pub async fn delete_specific_category( +/* TODO needs to be restructured/rewritten /// Link category to a word /// @@ -552,7 +676,7 @@ pub async fn link_word_to_category( let target_category_exists = CategoryQuery::exists_by_id(&state.database, target_category_id) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; if !target_category_exists { return Err(APIError::not_found_with_reason( "category does not exist.", @@ -562,7 +686,7 @@ pub async fn link_word_to_category( let potential_base_target_word = WordQuery::get_by_uuid(&state.database, target_word_uuid) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; let Some(base_target_word) = potential_base_target_word else { return Err(APIError::not_found_with_reason( @@ -577,9 +701,9 @@ pub async fn link_word_to_category( target_category_id, ) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; if already_has_category { - return Ok(error_response_with_reason!( + return Ok(json_error_response_with_reason!( StatusCode::CONFLICT, "This category is already linked to the word." )); @@ -592,24 +716,24 @@ pub async fn link_word_to_category( target_category_id, ) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; // Signals to the background search indexer that the word has changed. match base_target_word .language() - .map_err(APIError::InternalError)? + .map_err(APIError::InternalGenericError)? { WordLanguage::Slovene => state .search .signal_slovene_word_created_or_updated(base_target_word.id) .await - .map_err(APIError::InternalError)?, + .map_err(APIError::InternalGenericError)?, WordLanguage::English => state .search .signal_english_word_created_or_updated(base_target_word.id) .await - .map_err(APIError::InternalError)?, + .map_err(APIError::InternalGenericError)?, }; @@ -680,7 +804,7 @@ pub async fn unlink_word_from_category( let target_category_exists = CategoryQuery::exists_by_id(&state.database, target_category_id) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; if !target_category_exists { return Err(APIError::not_found_with_reason( "category does not exist.", @@ -690,7 +814,7 @@ pub async fn unlink_word_from_category( let target_word_exists = WordQuery::exists_by_uuid(&state.database, target_word_uuid) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; if !target_word_exists { return Err(APIError::not_found_with_reason( "word does not exist.", @@ -704,7 +828,7 @@ pub async fn unlink_word_from_category( target_category_id, ) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; if !category_link_exists { return Err(APIError::not_found_with_reason( "the word isn't linked to this category.", @@ -718,7 +842,7 @@ pub async fn unlink_word_from_category( target_category_id, ) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; @@ -726,27 +850,27 @@ pub async fn unlink_word_from_category( let base_target_word = WordQuery::get_by_uuid(&state.database, target_word_uuid) .await - .map_err(APIError::InternalError)? + .map_err(APIError::InternalGenericError)? .ok_or_else(|| { - APIError::internal_reason( + APIError::internal_error_with_reason( "BUG: Word dissapeared between category removal and index update.", ) })?; match base_target_word .language() - .map_err(APIError::InternalError)? + .map_err(APIError::InternalGenericError)? { WordLanguage::Slovene => state .search .signal_slovene_word_created_or_updated(base_target_word.id) .await - .map_err(APIError::InternalError)?, + .map_err(APIError::InternalGenericError)?, WordLanguage::English => state .search .signal_english_word_created_or_updated(base_target_word.id) .await - .map_err(APIError::InternalError)?, + .map_err(APIError::InternalGenericError)?, }; @@ -754,7 +878,7 @@ pub async fn unlink_word_from_category( } - + */ #[rustfmt::skip] pub fn categories_router() -> Scope { @@ -764,6 +888,6 @@ pub fn categories_router() -> Scope { .service(get_specific_category) .service(update_specific_category) .service(delete_specific_category) - .service(link_word_to_category) - .service(unlink_word_from_category) + // .service(link_word_to_category) + // .service(unlink_word_from_category) } diff --git a/kolomoni/src/api/v1/dictionary/english/meaning.rs b/kolomoni/src/api/v1/dictionary/english/meaning.rs new file mode 100644 index 0000000..6786a5e --- /dev/null +++ b/kolomoni/src/api/v1/dictionary/english/meaning.rs @@ -0,0 +1,355 @@ +use actix_web::{delete, get, patch, post, web, HttpResponse, Scope}; +use chrono::{DateTime, Utc}; +use kolomoni_auth::Permission; +use kolomoni_core::id::{CategoryId, EnglishWordId, EnglishWordMeaningId}; +use kolomoni_database::entities::{self, EnglishWordMeaningUpdate, NewEnglishWordMeaning}; +use serde::{Deserialize, Serialize}; +use sqlx::{types::Uuid, Acquire}; +use utoipa::ToSchema; + +use crate::{ + api::{ + errors::{APIError, EndpointResult}, + macros::ContextlessResponder, + traits::IntoApiModel, + v1::dictionary::slovene::meaning::ShallowSloveneWordMeaning, + }, + authentication::UserAuthenticationExtractor, + impl_json_response_builder, + obtain_database_connection, + require_permission, + require_permission_with_optional_authentication, + state::ApplicationState, +}; + + +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] +pub struct ShallowEnglishWordMeaning { + pub meaning_id: EnglishWordMeaningId, + + pub disambiguation: Option, + + pub abbreviation: Option, + + pub description: Option, + + pub categories: Vec, + + pub created_at: DateTime, + + pub last_modified_at: DateTime, +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] +pub struct EnglishWordMeaning { + pub meaning_id: EnglishWordMeaningId, + + pub disambiguation: Option, + + pub abbreviation: Option, + + pub description: Option, + + pub created_at: DateTime, + + pub last_modified_at: DateTime, +} + +impl IntoApiModel for entities::EnglishWordMeaningModel { + type ApiModel = EnglishWordMeaning; + + fn into_api_model(self) -> Self::ApiModel { + Self::ApiModel { + meaning_id: self.id, + disambiguation: self.disambiguation, + abbreviation: self.abbreviation, + description: self.description, + created_at: self.created_at, + last_modified_at: self.last_modified_at, + } + } +} + + +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] +pub struct EnglishWordMeaningWithCategoriesAndTranslations { + pub meaning_id: EnglishWordMeaningId, + + pub disambiguation: Option, + + pub abbreviation: Option, + + pub description: Option, + + pub created_at: DateTime, + + pub last_modified_at: DateTime, + + pub categories: Vec, + + pub translates_into: Vec, +} + + +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] +pub struct EnglishWordMeaningsResponse { + pub meanings: Vec, +} + +impl_json_response_builder!(EnglishWordMeaningsResponse); + + + +#[get("")] +pub async fn get_all_english_word_meanings( + state: ApplicationState, + authentication: UserAuthenticationExtractor, + parameters: web::Path<(Uuid,)>, +) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + + require_permission_with_optional_authentication!( + &mut database_connection, + authentication, + Permission::WordRead + ); + + + let target_english_word_id = EnglishWordId::new(parameters.into_inner().0); + + + let english_word_meanings = entities::EnglishWordMeaningQuery::get_all_by_english_word_id( + &mut database_connection, + target_english_word_id, + ) + .await?; + + + Ok(EnglishWordMeaningsResponse { + meanings: english_word_meanings + .into_iter() + .map(|meaning| meaning.into_api_model()) + .collect(), + } + .into_response()) +} + + +// TODO could be nice to submit initial categories with this as well? +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] +pub struct NewEnglishWordMeaningRequest { + pub disambiguation: Option, + + pub abbreviation: Option, + + pub description: Option, +} + + +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] +pub struct NewEnglishWordMeaningCreatedResponse { + pub meaning: EnglishWordMeaning, +} + +impl_json_response_builder!(NewEnglishWordMeaningCreatedResponse); + + + +#[post("")] +pub async fn create_english_word_meaning( + state: ApplicationState, + authentication: UserAuthenticationExtractor, + parameters: web::Path<(Uuid,)>, + request_data: web::Json, +) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + + require_permission!( + &mut transaction, + authentication, + Permission::WordUpdate + ); + + + let target_english_word_id = EnglishWordId::new(parameters.into_inner().0); + let new_word_meaning_data = request_data.into_inner(); + + + let newly_created_meaning = entities::EnglishWordMeaningMutation::create( + &mut transaction, + target_english_word_id, + NewEnglishWordMeaning { + abbreviation: new_word_meaning_data.abbreviation, + description: new_word_meaning_data.description, + disambiguation: new_word_meaning_data.disambiguation, + }, + ) + .await?; + + transaction.commit().await?; + + + Ok(NewEnglishWordMeaningCreatedResponse { + meaning: newly_created_meaning.into_api_model(), + } + .into_response()) +} + + +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] +pub struct EnglishWordMeaningUpdateRequest { + #[serde(default, with = "::serde_with::rust::double_option")] + pub disambiguation: Option>, + + #[serde(default, with = "::serde_with::rust::double_option")] + pub abbreviation: Option>, + + #[serde(default, with = "::serde_with::rust::double_option")] + pub description: Option>, +} + + +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] +pub struct EnglishWordMeaningUpdatedResponse { + pub meaning: EnglishWordMeaningWithCategoriesAndTranslations, +} + +impl_json_response_builder!(EnglishWordMeaningUpdatedResponse); + + +#[patch("/{english_word_meaning_id}")] +pub async fn update_english_word_meaning( + state: ApplicationState, + authentication: UserAuthenticationExtractor, + parameters: web::Path<(Uuid, Uuid)>, + request_data: web::Json, +) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + + require_permission!( + &mut transaction, + authentication, + Permission::WordUpdate + ); + + + let (target_english_word_id, target_english_word_meaning_id) = { + let url_parameters = parameters.into_inner(); + + let target_english_word_id = EnglishWordId::new(url_parameters.0); + let target_english_word_meaning_id = EnglishWordMeaningId::new(url_parameters.1); + + ( + target_english_word_id, + target_english_word_meaning_id, + ) + }; + + let new_word_meaning_data = request_data.into_inner(); + + // TODO we don't verify english word ID validity here, is that okay? + + + let updated_meaning_successfully = entities::EnglishWordMeaningMutation::update( + &mut transaction, + target_english_word_meaning_id, + EnglishWordMeaningUpdate { + disambiguation: new_word_meaning_data.disambiguation, + abbreviation: new_word_meaning_data.abbreviation, + description: new_word_meaning_data.description, + }, + ) + .await?; + + + // When zero rows are affected by the query, that means there is no such english word meaning. + if !updated_meaning_successfully { + return Err(APIError::not_found()); + } + + + + let updated_meaning = entities::EnglishWordMeaningQuery::get( + &mut transaction, + target_english_word_id, + target_english_word_meaning_id, + ) + .await?; + + let Some(updated_meaning) = updated_meaning else { + return Err(APIError::internal_error_with_reason( + "database inconsistency: after updating an english word meaning \ + we could not fetch the exact same meaning", + )); + }; + + + transaction.commit().await?; + + + Ok(EnglishWordMeaningUpdatedResponse { + meaning: updated_meaning.into_api_model(), + } + .into_response()) +} + + + +#[delete("/{english_word_meaning_id}")] +pub async fn delete_english_word_meaning( + state: ApplicationState, + authentication: UserAuthenticationExtractor, + parameters: web::Path<(Uuid, Uuid)>, +) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + + require_permission!( + &mut transaction, + authentication, + Permission::WordUpdate + ); + + + let (target_english_word_id, target_english_word_meaning_id) = { + let url_parameters = parameters.into_inner(); + + let target_english_word_id = EnglishWordId::new(url_parameters.0); + let target_english_word_meaning_id = EnglishWordMeaningId::new(url_parameters.1); + + ( + target_english_word_id, + target_english_word_meaning_id, + ) + }; + + + let successfully_deleted_meaning = entities::EnglishWordMeaningMutation::delete( + &mut transaction, + target_english_word_meaning_id, + ) + .await?; + + + if !successfully_deleted_meaning { + return Err(APIError::not_found()); + } + + + transaction.commit().await?; + + + Ok(HttpResponse::Ok().finish()) +} + + + +pub fn english_word_meaning_router() -> Scope { + web::scope("/{english_word_id}/meaning") + .service(get_all_english_word_meanings) + .service(create_english_word_meaning) + .service(update_english_word_meaning) + .service(delete_english_word_meaning) +} diff --git a/kolomoni/src/api/v1/dictionary/english/mod.rs b/kolomoni/src/api/v1/dictionary/english/mod.rs new file mode 100644 index 0000000..01bc668 --- /dev/null +++ b/kolomoni/src/api/v1/dictionary/english/mod.rs @@ -0,0 +1,16 @@ +use actix_web::{web, Scope}; +use meaning::english_word_meaning_router; +use word::english_word_router; + +pub mod meaning; +pub mod word; + +// TODO Links. + + +#[rustfmt::skip] +pub fn english_dictionary_router() -> Scope { + web::scope("/english") + .service(english_word_router()) + .service(english_word_meaning_router()) +} diff --git a/kolomoni/src/api/v1/dictionary/english_word.rs b/kolomoni/src/api/v1/dictionary/english/word.rs similarity index 60% rename from kolomoni/src/api/v1/dictionary/english_word.rs rename to kolomoni/src/api/v1/dictionary/english/word.rs index 1d2e7ad..0693a9b 100644 --- a/kolomoni/src/api/v1/dictionary/english_word.rs +++ b/kolomoni/src/api/v1/dictionary/english/word.rs @@ -1,34 +1,37 @@ use actix_http::StatusCode; use actix_web::{delete, get, patch, post, web, HttpResponse, Scope}; use chrono::{DateTime, Utc}; +use futures_util::StreamExt; use kolomoni_auth::Permission; -use kolomoni_database::{ - entities, - mutation::{EnglishWordMutation, NewEnglishWord, UpdatedEnglishWord, WordMutation}, - query::{ - self, - EnglishWordQuery, - EnglishWordsQueryOptions, - ExpandedEnglishWordInfo, - RelatedEnglishWordInfo, - }, +use kolomoni_core::id::EnglishWordId; +use kolomoni_database::entities::{ + self, + EnglishWordFieldsToUpdate, + EnglishWordMeaningModelWithCategoriesAndTranslations, + EnglishWordWithMeaningsModel, + EnglishWordsQueryOptions, + NewEnglishWord, + TranslatesIntoSloveneWordModel, }; use miette::Result; use serde::{Deserialize, Serialize}; +use sqlx::Acquire; use tracing::info; use utoipa::ToSchema; -use super::{slovene_word::SloveneWord, Category}; +use super::meaning::EnglishWordMeaningWithCategoriesAndTranslations; use crate::{ api::{ errors::{APIError, EndpointResult}, macros::ContextlessResponder, openapi, - v1::dictionary::parse_string_into_uuid, + traits::IntoApiModel, + v1::dictionary::{parse_string_into_uuid, slovene::meaning::ShallowSloveneWordMeaning}, }, authentication::UserAuthenticationExtractor, - error_response_with_reason, impl_json_response_builder, + json_error_response_with_reason, + obtain_database_connection, require_authentication, require_permission, require_permission_with_optional_authentication, @@ -37,6 +40,7 @@ use crate::{ +// TODO needs updated example #[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] #[schema( example = json!({ @@ -59,23 +63,13 @@ use crate::{ ] }) )] -pub struct EnglishWord { - /// Internal UUID of the word. - pub id: String, +pub struct EnglishWordWithMeanings { + /// Word UUID. + pub id: EnglishWordId, /// An abstract or base form of the word. pub lemma: String, - /// If there are multiple similar words, the disambiguation - /// helps distinguish the word from other words at a glance. - pub disambiguation: Option, - - /// A short description of the word. Supports Markdown. - /// - /// TODO Will need special Markdown support for linking to other dictionary words - /// and possibly autocomplete in the frontend editor. - pub description: Option, - /// When the word was created. pub created_at: DateTime, @@ -84,106 +78,14 @@ pub struct EnglishWord { /// suggestion or translation linked to this word. pub last_modified_at: DateTime, - /// A list of categories this word belongs in. - pub categories: Vec, - - /// Suggested slovene translations of this word. - pub suggested_translations: Vec, - - /// Slovene translations of this word. - pub translations: Vec, -} - -impl EnglishWord { - pub fn new_without_expanded_info(english_model: entities::word_english::Model) -> Self { - Self { - id: english_model.word_id.to_string(), - lemma: english_model.lemma, - disambiguation: english_model.disambiguation, - description: english_model.description, - created_at: english_model.created_at.to_utc(), - last_modified_at: english_model.last_modified_at.to_utc(), - categories: Vec::new(), - suggested_translations: Vec::new(), - translations: Vec::new(), - } - } - - pub fn from_word_and_related_info( - word_model: entities::word_english::Model, - related_english_word_info: RelatedEnglishWordInfo, - ) -> Self { - let categories = related_english_word_info - .categories - .into_iter() - .map(Category::from_database_model) - .collect(); - - let suggested_translations = related_english_word_info - .suggested_translations - .into_iter() - .map(SloveneWord::from_expanded_word_info) - .collect(); - - let translations = related_english_word_info - .translations - .into_iter() - .map(SloveneWord::from_expanded_word_info) - .collect(); - - - Self { - id: word_model.word_id.to_string(), - lemma: word_model.lemma, - disambiguation: word_model.disambiguation, - description: word_model.description, - created_at: word_model.created_at.to_utc(), - last_modified_at: word_model.last_modified_at.to_utc(), - categories, - suggested_translations, - translations, - } - } - - pub fn from_expanded_word_info(expanded_english_word_info: ExpandedEnglishWordInfo) -> Self { - let categories = expanded_english_word_info - .categories - .into_iter() - .map(Category::from_database_model) - .collect(); - - let suggested_translations = expanded_english_word_info - .suggested_translations - .into_iter() - .map(SloveneWord::from_expanded_word_info) - .collect(); - - let translations = expanded_english_word_info - .translations - .into_iter() - .map(SloveneWord::from_expanded_word_info) - .collect(); - - - Self { - id: expanded_english_word_info.word.word_id.to_string(), - lemma: expanded_english_word_info.word.lemma, - disambiguation: expanded_english_word_info.word.disambiguation, - description: expanded_english_word_info.word.description, - created_at: expanded_english_word_info.word.created_at.to_utc(), - last_modified_at: expanded_english_word_info.word.last_modified_at.to_utc(), - categories, - suggested_translations, - translations, - } - } + pub meanings: Vec, } #[derive(Serialize, PartialEq, Eq, Debug, ToSchema)] #[cfg_attr(feature = "with_test_facilities", derive(Deserialize))] pub struct EnglishWordsResponse { - pub english_words: Vec, + pub english_words: Vec, } impl_json_response_builder!(EnglishWordsResponse); @@ -203,6 +105,65 @@ pub struct EnglishWordsListRequest { pub filters: Option, } +impl IntoApiModel for EnglishWordMeaningModelWithCategoriesAndTranslations { + type ApiModel = EnglishWordMeaningWithCategoriesAndTranslations; + + fn into_api_model(self) -> Self::ApiModel { + Self::ApiModel { + meaning_id: self.id, + disambiguation: self.disambiguation, + abbreviation: self.abbreviation, + description: self.description, + categories: self.categories, + created_at: self.created_at, + last_modified_at: self.last_modified_at, + translates_into: self + .translates_into + .into_iter() + .map(|internal_model| internal_model.into_api_model()) + .collect(), + } + } +} + +impl IntoApiModel for TranslatesIntoSloveneWordModel { + type ApiModel = ShallowSloveneWordMeaning; + + fn into_api_model(self) -> Self::ApiModel { + Self::ApiModel { + meaning_id: self.word_meaning_id, + disambiguation: self.disambiguation, + abbreviation: self.abbreviation, + description: self.description, + categories: self.categories, + created_at: self.created_at, + last_modified_at: self.last_modified_at, + } + } +} + + +impl IntoApiModel for EnglishWordWithMeaningsModel { + type ApiModel = EnglishWordWithMeanings; + + fn into_api_model(self) -> Self::ApiModel { + let meanings = self + .meanings + .into_iter() + .map(|meaning| meaning.into_api_model()) + .collect(); + + Self::ApiModel { + id: self.word_id, + lemma: self.lemma, + created_at: self.created_at, + last_modified_at: self.last_modified_at, + meanings, + } + } +} + + /// List all english words /// @@ -234,39 +195,44 @@ pub async fn get_all_english_words( authentication: UserAuthenticationExtractor, request_body: Option>, ) -> EndpointResult { - require_permission_with_optional_authentication!(state, authentication, Permission::WordRead); + let mut database_connection = obtain_database_connection!(state); + require_permission_with_optional_authentication!( + &mut database_connection, + authentication, + Permission::WordRead + ); - let word_query_options = match request_body { - Some(body) => { - let body = body.into_inner(); - match body.filters { - Some(filters) => EnglishWordsQueryOptions { - only_words_modified_after: filters.last_modified_after, - }, - None => EnglishWordsQueryOptions::default(), - } - } - None => EnglishWordsQueryOptions::default(), - }; + let word_query_options = request_body + .map(|options| { + options + .into_inner() + .filters + .map(|filter_options| EnglishWordsQueryOptions { + only_words_modified_after: filter_options.last_modified_after, + }) + }) + .flatten() + .unwrap_or_default(); - let words_with_additional_info = - query::EnglishWordQuery::all_words_expanded(&state.database, word_query_options) - .await - .map_err(APIError::InternalError)?; + let mut words_with_meanings_stream = + entities::EnglishWordQuery::get_all_english_words_with_meanings( + &mut database_connection, + word_query_options, + ) + .await; - let words_as_api_structures = words_with_additional_info - .into_iter() - .map(EnglishWord::from_expanded_word_info) - .collect(); + let mut english_words = Vec::new(); - Ok(EnglishWordsResponse { - english_words: words_as_api_structures, + while let Some(word_result) = words_with_meanings_stream.next().await { + english_words.push(word_result?.into_api_model()); } - .into_response()) + + + Ok(EnglishWordsResponse { english_words }.into_response()) } @@ -275,15 +241,11 @@ pub async fn get_all_english_words( #[cfg_attr(feature = "with_test_facilities", derive(Serialize))] #[schema( example = json!({ - "lemma": "adventurer", - "disambiguation": "character", - "description": "Playable or non-playable character.", + "lemma": "adventurer" }) )] pub struct EnglishWordCreationRequest { pub lemma: String, - pub disambiguation: Option, - pub description: Option, } @@ -294,15 +256,27 @@ pub struct EnglishWordCreationRequest { "word": { "id": "018dbe00-266e-7398-abd2-0906df0aa345", "lemma": "adventurer", - "disambiguation": "character", - "description": "Playable or non-playable character.", "added_at": "2023-06-27T20:34:27.217273Z", "last_edited_at": "2023-06-27T20:34:27.217273Z" } }) )] pub struct EnglishWordCreationResponse { - pub word: EnglishWord, + pub word: EnglishWordWithMeanings, +} + +impl IntoApiModel for entities::EnglishWordModel { + type ApiModel = EnglishWordWithMeanings; + + fn into_api_model(self) -> Self::ApiModel { + Self::ApiModel { + id: self.word_id, + lemma: self.lemma, + created_at: self.created_at, + last_modified_at: self.last_modified_at, + meanings: vec![], + } + } } impl_json_response_builder!(EnglishWordCreationResponse); @@ -347,55 +321,60 @@ pub async fn create_english_word( authentication: UserAuthenticationExtractor, creation_request: web::Json, ) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + let authenticated_user = require_authentication!(authentication); - require_permission!(state, authenticated_user, Permission::WordCreate); + require_permission!( + &mut database_connection, + authenticated_user, + Permission::WordCreate + ); let creation_request = creation_request.into_inner(); - let lemma_already_exists = - EnglishWordQuery::word_exists_by_lemma(&state.database, creation_request.lemma.clone()) - .await - .map_err(APIError::InternalError)?; + let word_lemma_already_exists = entities::EnglishWordQuery::exists_by_exact_lemma( + &mut database_connection, + &creation_request.lemma, + ) + .await?; - if lemma_already_exists { - return Ok(error_response_with_reason!( + if word_lemma_already_exists { + return Ok(json_error_response_with_reason!( StatusCode::CONFLICT, "An english word with the given lemma already exists." )); } - let newly_created_word = EnglishWordMutation::create( - &state.database, + let newly_created_word = entities::EnglishWordMutation::create( + &mut database_connection, NewEnglishWord { lemma: creation_request.lemma, - disambiguation: creation_request.disambiguation, - description: creation_request.description, }, ) - .await - .map_err(APIError::InternalError)?; + .await?; info!( - created_by_user = authenticated_user.user_id(), + created_by_user = %authenticated_user.user_id(), "Created new english word: {}", newly_created_word.lemma, ); + /* TODO needs cache layer rework // Signals to the the search indexer that the word has been created. state .search .signal_english_word_created_or_updated(newly_created_word.word_id) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; */ Ok(EnglishWordCreationResponse { - // A newly-created word can not have any suggestions or translations yet. - word: EnglishWord::new_without_expanded_info(newly_created_word), + // A newly-created word can not have any meanings yet. + word: newly_created_word.into_api_model(), } .into_response()) } @@ -406,7 +385,7 @@ pub async fn create_english_word( #[derive(Serialize, Clone, PartialEq, Eq, Debug, ToSchema)] #[cfg_attr(feature = "with_test_facilities", derive(Deserialize))] pub struct EnglishWordInfoResponse { - pub word: EnglishWord, + pub word: EnglishWordWithMeanings, } impl_json_response_builder!(EnglishWordInfoResponse); @@ -451,28 +430,36 @@ impl_json_response_builder!(EnglishWordInfoResponse); ) )] #[get("/{word_uuid}")] -pub async fn get_specific_english_word( +pub async fn get_english_word_by_id( state: ApplicationState, authentication: UserAuthenticationExtractor, parameters: web::Path<(String,)>, ) -> EndpointResult { - require_permission_with_optional_authentication!(state, authentication, Permission::WordRead); + let mut database_connection = obtain_database_connection!(state); + + require_permission_with_optional_authentication!( + &mut database_connection, + authentication, + Permission::WordRead + ); let target_word_uuid = parse_string_into_uuid(¶meters.into_inner().0)?; - let target_word = EnglishWordQuery::expanded_word_by_uuid(&state.database, target_word_uuid) - .await - .map_err(APIError::InternalError)?; + let potential_english_word = entities::EnglishWordQuery::get_by_id_with_meanings( + &mut database_connection, + EnglishWordId::new(target_word_uuid), + ) + .await?; - let Some(target_word) = target_word else { + let Some(english_word) = potential_english_word else { return Err(APIError::not_found()); }; Ok(EnglishWordInfoResponse { - word: EnglishWord::from_expanded_word_info(target_word), + word: english_word.into_api_model(), } .into_response()) } @@ -515,27 +502,36 @@ pub async fn get_specific_english_word( ) )] #[get("/by-lemma/{word_lemma}")] -pub async fn get_specific_english_word_by_lemma( +pub async fn get_english_word_by_lemma( state: ApplicationState, authentication: UserAuthenticationExtractor, parameters: web::Path<(String,)>, ) -> EndpointResult { - require_permission_with_optional_authentication!(state, authentication, Permission::WordRead); + let mut database_connection = obtain_database_connection!(state); + + require_permission_with_optional_authentication!( + &mut database_connection, + authentication, + Permission::WordRead + ); let target_word_lemma = parameters.into_inner().0; - let target_word = EnglishWordQuery::expanded_word_by_lemma(&state.database, target_word_lemma) - .await - .map_err(APIError::InternalError)?; - let Some(target_word) = target_word else { + let potential_english_word = entities::EnglishWordQuery::get_by_exact_lemma_with_meanings( + &mut database_connection, + &target_word_lemma, + ) + .await?; + + let Some(english_word) = potential_english_word else { return Err(APIError::not_found()); }; Ok(EnglishWordInfoResponse { - word: EnglishWord::from_expanded_word_info(target_word), + word: english_word.into_api_model(), } .into_response()) } @@ -544,8 +540,6 @@ pub async fn get_specific_english_word_by_lemma( #[derive(Deserialize, Serialize, Clone, PartialEq, Eq, Debug, ToSchema, Default)] pub struct EnglishWordUpdateRequest { pub lemma: Option, - pub disambiguation: Option, - pub description: Option, } impl_json_response_builder!(EnglishWordUpdateRequest); @@ -603,55 +597,75 @@ pub async fn update_specific_english_word( parameters: web::Path<(String,)>, request_data: web::Json, ) -> EndpointResult { - let authenticated_user = require_authentication!(authentication); - require_permission!(state, authenticated_user, Permission::WordUpdate); + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + let authenticated_user = require_authentication!(authentication); + require_permission!( + &mut transaction, + authenticated_user, + Permission::WordUpdate + ); - let target_word_uuid = parse_string_into_uuid(¶meters.into_inner().0)?; + let target_word_uuid = EnglishWordId::new(parse_string_into_uuid( + ¶meters.into_inner().0, + )?); let request_data = request_data.into_inner(); + let target_word_exists = - EnglishWordQuery::word_exists_by_uuid(&state.database, target_word_uuid) - .await - .map_err(APIError::InternalError)?; + entities::EnglishWordQuery::exists_by_id(&mut transaction, target_word_uuid).await?; if !target_word_exists { return Err(APIError::not_found()); } - let updated_model = EnglishWordMutation::update( - &state.database, + let updated_successfully = entities::EnglishWordMutation::update( + &mut transaction, target_word_uuid, - UpdatedEnglishWord { - lemma: request_data.lemma, - disambiguation: request_data.disambiguation, - description: request_data.description, + EnglishWordFieldsToUpdate { + new_lemma: request_data.lemma, }, ) - .await - .map_err(APIError::InternalError)?; + .await?; + + if !updated_successfully { + transaction.rollback().await?; + + return Err(APIError::internal_error_with_reason( + "Failed to update english word.", + )); + } + - let target_word_additional_info = - EnglishWordQuery::related_word_information_only(&state.database, target_word_uuid) - .await - .map_err(APIError::InternalError)?; + let updated_word = + entities::EnglishWordQuery::get_by_id_with_meanings(&mut transaction, target_word_uuid) + .await? + .ok_or_else(|| { + APIError::internal_error_with_reason( + "Database inconsistency: word did not exist after being updated.", + ) + })?; + transaction.commit().await?; + + /* TODO pending rewrite of cache layer // Signals to the the search indexer that the word has been updated. state .search .signal_english_word_created_or_updated(updated_model.word_id) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; */ Ok(EnglishWordInfoResponse { - word: EnglishWord::from_word_and_related_info(updated_model, target_word_additional_info), + word: updated_word.into_api_model(), } .into_response()) } @@ -698,56 +712,66 @@ pub async fn update_specific_english_word( ) )] #[delete("/{word_uuid}")] -pub async fn delete_specific_english_word( +pub async fn delete_english_word( state: ApplicationState, authentication: UserAuthenticationExtractor, parameters: web::Path<(String,)>, ) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + let authenticated_user = require_authentication!(authentication); - require_permission!(state, authenticated_user, Permission::WordDelete); + require_permission!( + &mut transaction, + authenticated_user, + Permission::WordDelete + ); - let target_word_uuid = parse_string_into_uuid(¶meters.into_inner().0)?; + let target_word_uuid = EnglishWordId::new(parse_string_into_uuid( + ¶meters.into_inner().0, + )?); + let target_word_exists = - EnglishWordQuery::word_exists_by_uuid(&state.database, target_word_uuid) - .await - .map_err(APIError::InternalError)?; + entities::EnglishWordQuery::exists_by_id(&mut transaction, target_word_uuid).await?; if !target_word_exists { return Err(APIError::not_found()); } - WordMutation::delete(&state.database, target_word_uuid) - .await - .map_err(APIError::InternalError)?; + let has_been_deleted = + entities::EnglishWordMutation::delete(&mut transaction, target_word_uuid).await?; + if !has_been_deleted { + return Err(APIError::internal_error_with_reason( + "database inconsistency: failed to delete english word that \ + just existed in the same transaction", + )); + } + /* TODO needs update when cache layer is rewritten // Signals to the the search indexer that the word has been removed. state .search .signal_english_word_removed(target_word_uuid) .await - .map_err(APIError::InternalError)?; - + .map_err(APIError::InternalGenericError)?; + */ Ok(HttpResponse::Ok().finish()) } - -// TODO Links. - - #[rustfmt::skip] -pub fn english_dictionary_router() -> Scope { - web::scope("/english") +pub fn english_word_router() -> Scope { + web::scope("") .service(get_all_english_words) .service(create_english_word) - .service(get_specific_english_word) - .service(get_specific_english_word_by_lemma) - .service(update_specific_english_word) - .service(delete_specific_english_word) + .service(get_english_word_by_id) + .service(get_english_word_by_lemma) + // .service(update_specific_english_word) + .service(delete_english_word) } diff --git a/kolomoni/src/api/v1/dictionary/mod.rs b/kolomoni/src/api/v1/dictionary/mod.rs new file mode 100644 index 0000000..49135b0 --- /dev/null +++ b/kolomoni/src/api/v1/dictionary/mod.rs @@ -0,0 +1,48 @@ +use std::str::FromStr; + +use actix_web::{web, Scope}; +use english::english_dictionary_router; +use kolomoni_core::api_models::Category; +use sqlx::types::Uuid; + +use self::{ + categories::categories_router, + // suggestions::suggested_translations_router, + translations::translations_router, +}; +use crate::api::errors::APIError; + +pub mod categories; +pub mod english; +// TODO +pub mod slovene; +// TODO +// pub mod search; +// DEPRECATED +// pub mod suggestions; +pub mod translations; + + + + +pub fn parse_string_into_uuid(potential_uuid: &str) -> Result { + let target_word_uuid = + Uuid::from_str(potential_uuid).map_err(|_| APIError::client_error("invalid UUID"))?; + + Ok(target_word_uuid) +} + + +#[rustfmt::skip] +pub fn dictionary_router() -> Scope { + web::scope("/dictionary") + // TODO + // .service(slovene_dictionary_router()) + .service(english_dictionary_router()) + // DEPRECATED + // .service(suggested_translations_router()) + .service(translations_router()) + .service(categories_router()) + // TODO + // .service(search_router()) +} diff --git a/kolomoni/src/api/v1/dictionary/search.rs b/kolomoni/src/api/v1/dictionary/search.rs index c8596fc..20c175f 100644 --- a/kolomoni/src/api/v1/dictionary/search.rs +++ b/kolomoni/src/api/v1/dictionary/search.rs @@ -3,7 +3,7 @@ use kolomoni_search::SearchResult; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; -use super::{english_word::EnglishWord, slovene_word::SloveneWord}; +use super::{english_word::EnglishWordWithMeanings, slovene_word::SloveneWord}; use crate::{ api::{ errors::{APIError, EndpointResult}, @@ -32,7 +32,7 @@ pub struct SearchRequest { #[derive(Serialize, Clone, PartialEq, Eq, ToSchema)] #[cfg_attr(feature = "with_test_facilities", derive(Deserialize))] pub struct SearchResults { - english_results: Vec, + english_results: Vec, slovene_results: Vec, } @@ -102,16 +102,16 @@ pub async fn perform_search( .search .search(&search_query) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; - let mut english_results: Vec = Vec::new(); + let mut english_results: Vec = Vec::new(); let mut slovene_results: Vec = Vec::new(); for search_result in search_results.words { match search_result { SearchResult::English(english_result) => { - english_results.push(EnglishWord::from_expanded_word_info( + english_results.push(EnglishWordWithMeanings::from_expanded_word_info( english_result, )); } diff --git a/kolomoni/src/api/v1/dictionary/slovene/meaning.rs b/kolomoni/src/api/v1/dictionary/slovene/meaning.rs new file mode 100644 index 0000000..ceab1b4 --- /dev/null +++ b/kolomoni/src/api/v1/dictionary/slovene/meaning.rs @@ -0,0 +1,367 @@ +use actix_web::{delete, get, patch, post, web, HttpResponse, Scope}; +use chrono::{DateTime, Utc}; +use kolomoni_auth::Permission; +use kolomoni_core::{ + api_models::Category, + id::{CategoryId, SloveneWordId, SloveneWordMeaningId}, +}; +use kolomoni_database::entities::{self, NewSloveneWordMeaning, SloveneWordMeaningUpdate}; +use serde::{Deserialize, Serialize}; +use sqlx::Acquire; +use utoipa::ToSchema; +use uuid::Uuid; + +use super::word::SloveneWordMeaningWithCategoriesAndTranslations; +use crate::{ + api::{ + errors::{APIError, EndpointResult}, + macros::ContextlessResponder, + traits::IntoApiModel, + v1::dictionary::english::meaning::ShallowEnglishWordMeaning, + }, + authentication::UserAuthenticationExtractor, + impl_json_response_builder, + obtain_database_connection, + require_permission, + require_permission_with_optional_authentication, + state::ApplicationState, +}; + + +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] +pub struct ShallowSloveneWordMeaning { + pub meaning_id: SloveneWordMeaningId, + + pub disambiguation: Option, + + pub abbreviation: Option, + + pub description: Option, + + pub categories: Vec, + + pub created_at: DateTime, + + pub last_modified_at: DateTime, +} + +// TODO refactor these names, this one is the same as ShallowSloveneWordMeaning, but without categories +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] +pub struct SloveneWordMeaning { + pub meaning_id: SloveneWordMeaningId, + + pub disambiguation: Option, + + pub abbreviation: Option, + + pub description: Option, + + pub created_at: DateTime, + + pub last_modified_at: DateTime, +} + +impl IntoApiModel for entities::SloveneWordMeaningModel { + type ApiModel = SloveneWordMeaning; + + fn into_api_model(self) -> Self::ApiModel { + Self::ApiModel { + meaning_id: self.id, + disambiguation: self.disambiguation, + abbreviation: self.abbreviation, + description: self.description, + created_at: self.created_at, + last_modified_at: self.last_modified_at, + } + } +} + + +/* +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] +pub struct SloveneWordMeaningWithCategoriesAndTranslations { + pub meaning_id: SloveneWordMeaningId, + + pub disambiguation: Option, + + pub abbreviation: Option, + + pub description: Option, + + pub created_at: DateTime, + + pub last_modified_at: DateTime, + + pub categories: Vec, + + pub translates_into: Vec, +} */ + + +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] +pub struct SloveneWordMeaningsResponse { + pub meanings: Vec, +} + +impl_json_response_builder!(SloveneWordMeaningsResponse); + + + +#[get("")] +pub async fn get_all_slovene_word_meanings( + state: ApplicationState, + authentication: UserAuthenticationExtractor, + parameters: web::Path<(Uuid,)>, +) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + + require_permission_with_optional_authentication!( + &mut database_connection, + authentication, + Permission::WordRead + ); + + + let target_slovene_word_id = SloveneWordId::new(parameters.into_inner().0); + + + let slovene_word_meanings = entities::SloveneWordMeaningQuery::get_all_by_slovene_word_id( + &mut database_connection, + target_slovene_word_id, + ) + .await?; + + Ok(SloveneWordMeaningsResponse { + meanings: slovene_word_meanings + .into_iter() + .map(|meaning| meaning.into_api_model()) + .collect(), + } + .into_response()) +} + + + +// TODO could be nice to submit initial categories with this as well? (see also english version of this) +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] +pub struct NewSloveneWordMeaningRequest { + pub disambiguation: Option, + + pub abbreviation: Option, + + pub description: Option, +} + + +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] +pub struct NewSloveneWordMeaningCreatedResponse { + pub meaning: SloveneWordMeaning, +} + +impl_json_response_builder!(NewSloveneWordMeaningCreatedResponse); + + + +#[post("")] +pub async fn create_slovene_word_meaning( + state: ApplicationState, + authentication: UserAuthenticationExtractor, + parameters: web::Path<(Uuid,)>, + request_data: web::Json, +) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + + require_permission!( + &mut transaction, + authentication, + Permission::WordUpdate + ); + + + let target_slovene_word_id = SloveneWordId::new(parameters.into_inner().0); + let new_word_meaning_data = request_data.into_inner(); + + + + let newly_created_meaning = entities::SloveneWordMeaningMutation::create( + &mut transaction, + target_slovene_word_id, + NewSloveneWordMeaning { + abbreviation: new_word_meaning_data.abbreviation, + description: new_word_meaning_data.description, + disambiguation: new_word_meaning_data.disambiguation, + }, + ) + .await?; + + + transaction.commit().await?; + + + Ok(NewSloveneWordMeaningCreatedResponse { + meaning: newly_created_meaning.into_api_model(), + } + .into_response()) +} + + +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] +pub struct SloveneWordMeaningUpdateRequest { + #[serde(default, with = "::serde_with::rust::double_option")] + pub disambiguation: Option>, + + #[serde(default, with = "::serde_with::rust::double_option")] + pub abbreviation: Option>, + + #[serde(default, with = "::serde_with::rust::double_option")] + pub description: Option>, +} + + +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] +pub struct SloveneWordMeaningUpdatedResponse { + pub meaning: SloveneWordMeaningWithCategoriesAndTranslations, +} + +impl_json_response_builder!(SloveneWordMeaningUpdatedResponse); + + + +#[patch("/{slovene_word_meaning_id}")] +pub async fn update_slovene_word_meaning( + state: ApplicationState, + authentication: UserAuthenticationExtractor, + parameters: web::Path<(Uuid, Uuid)>, + request_data: web::Json, +) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + + require_permission!( + &mut transaction, + authentication, + Permission::WordUpdate + ); + + + + let (target_slovene_word_id, target_slovene_word_meaning_id) = { + let url_parameters = parameters.into_inner(); + + let target_slovene_word_id = SloveneWordId::new(url_parameters.0); + let target_slovene_word_meaning_id = SloveneWordMeaningId::new(url_parameters.1); + + ( + target_slovene_word_id, + target_slovene_word_meaning_id, + ) + }; + + let new_word_meaning_data = request_data.into_inner(); + + // TODO we don't verify slovene word ID validity here, is that okay? + + + + let successfully_updated_meaning = entities::SloveneWordMeaningMutation::update( + &mut transaction, + target_slovene_word_meaning_id, + SloveneWordMeaningUpdate { + disambiguation: new_word_meaning_data.disambiguation, + abbreviation: new_word_meaning_data.abbreviation, + description: new_word_meaning_data.description, + }, + ) + .await?; + + + // When zero rows are affected by the query, that means there is no such slovene word meaning. + if !successfully_updated_meaning { + return Err(APIError::not_found()); + } + + + + let updated_meaning = entities::SloveneWordMeaningQuery::get( + &mut transaction, + target_slovene_word_id, + target_slovene_word_meaning_id, + ) + .await?; + + let Some(updated_meaning) = updated_meaning else { + return Err(APIError::internal_error_with_reason( + "database inconsistency: after updating a slovene word meaning \ + we could not fetch the exact same meaning", + )); + }; + + transaction.commit().await?; + + + Ok(SloveneWordMeaningUpdatedResponse { + meaning: updated_meaning.into_api_model(), + } + .into_response()) +} + + + +#[delete("/{slovene_word_meaning_id}")] +pub async fn delete_slovene_word_meaning( + state: ApplicationState, + authentication: UserAuthenticationExtractor, + parameters: web::Path<(Uuid, Uuid)>, +) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + + require_permission!( + &mut transaction, + authentication, + Permission::WordUpdate + ); + + + let (target_slovene_word_id, target_slovene_word_meaning_id) = { + let url_parameters = parameters.into_inner(); + + let target_slovene_word_id = SloveneWordId::new(url_parameters.0); + let target_slovene_word_meaning_id = SloveneWordMeaningId::new(url_parameters.1); + + ( + target_slovene_word_id, + target_slovene_word_meaning_id, + ) + }; + + + let successfully_deleted_meaning = entities::SloveneWordMeaningMutation::delete( + &mut transaction, + target_slovene_word_meaning_id, + ) + .await?; + + if !successfully_deleted_meaning { + return Err(APIError::not_found()); + } + + + transaction.commit().await?; + + + Ok(HttpResponse::Ok().finish()) +} + +// TODO next up: refactor names and structure, then look at aligning the utoipa docs with the actual endpoints again + + + +pub fn slovene_word_meaning_router() -> Scope { + web::scope("/{slovene_word_id}/meaning") + .service(get_all_slovene_word_meanings) + .service(create_slovene_word_meaning) + .service(update_slovene_word_meaning) + .service(delete_slovene_word_meaning) +} diff --git a/kolomoni/src/api/v1/dictionary/slovene/mod.rs b/kolomoni/src/api/v1/dictionary/slovene/mod.rs new file mode 100644 index 0000000..631af71 --- /dev/null +++ b/kolomoni/src/api/v1/dictionary/slovene/mod.rs @@ -0,0 +1,20 @@ +use actix_web::{web, Scope}; +use meaning::slovene_word_meaning_router; +use word::slovene_word_router; + + + +pub mod meaning; +pub mod word; + + + +// TODO Links. + + +#[rustfmt::skip] +pub fn slovene_dictionary_router() -> Scope { + web::scope("/slovene") + .service(slovene_word_router()) + .service(slovene_word_meaning_router()) +} diff --git a/kolomoni/src/api/v1/dictionary/slovene_word.rs b/kolomoni/src/api/v1/dictionary/slovene/word.rs similarity index 62% rename from kolomoni/src/api/v1/dictionary/slovene_word.rs rename to kolomoni/src/api/v1/dictionary/slovene/word.rs index 80bc8bb..7b1b87e 100644 --- a/kolomoni/src/api/v1/dictionary/slovene_word.rs +++ b/kolomoni/src/api/v1/dictionary/slovene/word.rs @@ -1,32 +1,33 @@ use actix_http::StatusCode; use actix_web::{delete, get, patch, post, web, HttpResponse, Scope}; use chrono::{DateTime, Utc}; +use futures_util::StreamExt; use kolomoni_auth::Permission; -use kolomoni_database::{ - entities, - mutation::{NewSloveneWord, SloveneWordMutation, UpdatedSloveneWord, WordMutation}, - query::{ - self, - ExpandedSloveneWordInfo, - RelatedSloveneWordInfo, - SloveneWordQuery, - SloveneWordsQueryOptions, - }, +use kolomoni_core::id::{CategoryId, SloveneWordId, SloveneWordMeaningId}; +use kolomoni_database::entities::{ + self, + NewSloveneWord, + SloveneWordFieldsToUpdate, + SloveneWordsQueryOptions, }; use serde::{Deserialize, Serialize}; +use sqlx::Acquire; +use tracing::info; use utoipa::ToSchema; +use uuid::Uuid; -use super::Category; use crate::{ api::{ errors::{APIError, EndpointResult}, macros::ContextlessResponder, openapi, - v1::dictionary::parse_string_into_uuid, + traits::IntoApiModel, + v1::dictionary::english::meaning::ShallowEnglishWordMeaning, }, authentication::UserAuthenticationExtractor, - error_response_with_reason, impl_json_response_builder, + json_error_response_with_reason, + obtain_database_connection, require_authentication, require_permission, require_permission_with_optional_authentication, @@ -46,20 +47,13 @@ use crate::{ "last_modified_at": "2023-06-27T20:34:27.217273Z" }) )] -pub struct SloveneWord { +pub struct SloveneWordWithMeanings { /// Internal UUID of the word. - pub id: String, + pub id: SloveneWordId, /// An abstract or base form of the word. pub lemma: String, - /// If there are multiple similar words, the disambiguation - /// helps distinguish the word from other words at a glance. - pub disambiguation: Option, - - /// A short description of the word. Supports Markdown. - pub description: Option, - /// When the word was created. pub created_at: DateTime, @@ -69,9 +63,70 @@ pub struct SloveneWord { /// of the linked suggestion and translation relationships. pub last_modified_at: DateTime, - pub categories: Vec, + pub meanings: Vec, +} + + +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] +pub struct SloveneWordMeaningWithCategoriesAndTranslations { + pub meaning_id: SloveneWordMeaningId, + + pub disambiguation: Option, + + pub abbreviation: Option, + + pub description: Option, + + pub created_at: DateTime, + + pub last_modified_at: DateTime, + + pub categories: Vec, + + pub translates_into: Vec, +} + + +impl IntoApiModel for entities::SloveneWordMeaningModelWithCategoriesAndTranslations { + type ApiModel = SloveneWordMeaningWithCategoriesAndTranslations; + + fn into_api_model(self) -> Self::ApiModel { + Self::ApiModel { + meaning_id: self.id, + disambiguation: self.disambiguation, + abbreviation: self.abbreviation, + description: self.description, + created_at: self.created_at, + last_modified_at: self.last_modified_at, + categories: self.categories, + translates_into: self + .translates_into + .into_iter() + .map(|translation| translation.into_api_model()) + .collect(), + } + } } + +impl IntoApiModel for entities::TranslatesIntoEnglishWordMeaningModel { + type ApiModel = ShallowEnglishWordMeaning; + + fn into_api_model(self) -> Self::ApiModel { + Self::ApiModel { + meaning_id: self.word_meaning_id, + disambiguation: self.disambiguation, + abbreviation: self.abbreviation, + description: self.description, + created_at: self.created_at, + last_modified_at: self.last_modified_at, + categories: self.categories, + } + } +} + + +/* impl SloveneWord { pub fn new_without_expanded_info(slovene_model: entities::word_slovene::Model) -> Self { Self { @@ -127,14 +182,14 @@ impl SloveneWord { categories, } } -} +} */ #[derive(Serialize, PartialEq, Eq, Debug, ToSchema)] #[cfg_attr(feature = "with_test_facilities", derive(Deserialize))] pub struct SloveneWordsResponse { - pub slovene_words: Vec, + pub slovene_words: Vec, } impl_json_response_builder!(SloveneWordsResponse); @@ -155,6 +210,25 @@ pub struct SloveneWordsListRequest { } +impl IntoApiModel for entities::SloveneWordWithMeaningsModel { + type ApiModel = SloveneWordWithMeanings; + + fn into_api_model(self) -> Self::ApiModel { + Self::ApiModel { + id: self.word_id, + lemma: self.lemma, + created_at: self.created_at, + last_modified_at: self.last_modified_at, + meanings: self + .meanings + .into_iter() + .map(|meaning| meaning.into_api_model()) + .collect(), + } + } +} + + /// List all slovene words /// @@ -186,39 +260,46 @@ pub async fn get_all_slovene_words( authentication: UserAuthenticationExtractor, request_body: Option>, ) -> EndpointResult { - require_permission_with_optional_authentication!(state, authentication, Permission::WordRead); + let mut database_connection = obtain_database_connection!(state); + require_permission_with_optional_authentication!( + &mut database_connection, + authentication, + Permission::WordRead + ); + // TODO continue from here - let word_query_options = match request_body { - Some(body) => { - let body = body.into_inner(); - match body.filters { - Some(filters) => SloveneWordsQueryOptions { - only_words_modified_after: filters.last_modified_after, - }, - None => SloveneWordsQueryOptions::default(), - } - } - None => SloveneWordsQueryOptions::default(), - }; + let word_query_options = request_body + .map(|options| { + options + .into_inner() + .filters + .map(|filter_options| SloveneWordsQueryOptions { + only_words_modified_after: filter_options.last_modified_after, + }) + }) + .flatten() + .unwrap_or_default(); - // Load words from the database. - let words = query::SloveneWordQuery::all_words_expanded(&state.database, word_query_options) - .await - .map_err(APIError::InternalError)?; + // Load words from the database. + let mut words_with_meanings_stream = + entities::SloveneWordQuery::get_all_slovene_words_with_meanings( + &mut database_connection, + word_query_options, + ) + .await; - let words_as_api_structures = words - .into_iter() - .map(SloveneWord::from_expanded_word_info) - .collect(); + let mut slovene_words = Vec::new(); - Ok(SloveneWordsResponse { - slovene_words: words_as_api_structures, + while let Some(word_result) = words_with_meanings_stream.next().await { + slovene_words.push(word_result?.into_api_model()); } - .into_response()) + + + Ok(SloveneWordsResponse { slovene_words }.into_response()) } @@ -226,15 +307,11 @@ pub async fn get_all_slovene_words( #[cfg_attr(feature = "with_test_facilities", derive(Serialize))] #[schema( example = json!({ - "lemma": "pustolovec", - "disambiguation": "lik", - "description": "Igrani ali neigrani liki, ki se odpravijo na pustolovščino." + "lemma": "pustolovec" }) )] pub struct SloveneWordCreationRequest { pub lemma: String, - pub disambiguation: Option, - pub description: Option, } #[derive(Serialize, Clone, PartialEq, Eq, Debug, ToSchema)] @@ -252,12 +329,27 @@ pub struct SloveneWordCreationRequest { }) )] pub struct SloveneWordCreationResponse { - pub word: SloveneWord, + pub word: SloveneWordWithMeanings, } impl_json_response_builder!(SloveneWordCreationResponse); +impl IntoApiModel for entities::SloveneWordModel { + type ApiModel = SloveneWordWithMeanings; + + fn into_api_model(self) -> Self::ApiModel { + Self::ApiModel { + id: self.word_id, + lemma: self.lemma, + created_at: self.created_at, + last_modified_at: self.last_modified_at, + meanings: vec![], + } + } +} + + /// Create a slovene word /// @@ -298,48 +390,58 @@ pub async fn create_slovene_word( authentication: UserAuthenticationExtractor, creation_request: web::Json, ) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + let authenticated_user = require_authentication!(authentication); - require_permission!(state, authenticated_user, Permission::WordCreate); + require_permission!( + &mut transaction, + authenticated_user, + Permission::WordCreate + ); + let creation_request = creation_request.into_inner(); - let lemma_already_exists = - SloveneWordQuery::word_exists_by_lemma(&state.database, creation_request.lemma.clone()) - .await - .map_err(APIError::InternalError)?; - if lemma_already_exists { - return Ok(error_response_with_reason!( + let word_lemma_already_exists = + entities::SloveneWordQuery::exists_by_exact_lemma(&mut transaction, &creation_request.lemma) + .await?; + + if word_lemma_already_exists { + return Ok(json_error_response_with_reason!( StatusCode::CONFLICT, "A slovene word with the given lemma already exists." )); } - let newly_created_word = SloveneWordMutation::create( - &state.database, + let newly_created_word = entities::SloveneWordMutation::create( + &mut transaction, NewSloveneWord { lemma: creation_request.lemma, - disambiguation: creation_request.disambiguation, - description: creation_request.description, }, ) - .await - .map_err(APIError::InternalError)?; + .await?; + info!( + created_by_user = %authenticated_user.user_id(), + "Created new slovene word: {}", newly_created_word.lemma, + ); + /* TODO pending rewrite of cache layer // Signals to the the search indexer that the word has been created. state .search .signal_slovene_word_created_or_updated(newly_created_word.word_id) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; */ Ok(SloveneWordCreationResponse { // Newly created words do not belong to any categories. - word: SloveneWord::new_without_expanded_info(newly_created_word), + word: newly_created_word.into_api_model(), } .into_response()) } @@ -350,7 +452,7 @@ pub async fn create_slovene_word( #[derive(Serialize, Clone, PartialEq, Eq, Debug, ToSchema)] #[cfg_attr(feature = "with_test_facilities", derive(Deserialize))] pub struct SloveneWordInfoResponse { - pub word: SloveneWord, + pub word: SloveneWordWithMeanings, } impl_json_response_builder!(SloveneWordInfoResponse); @@ -399,25 +501,34 @@ impl_json_response_builder!(SloveneWordInfoResponse); pub async fn get_specific_slovene_word( state: ApplicationState, authentication: UserAuthenticationExtractor, - parameters: web::Path<(String,)>, + parameters: web::Path<(Uuid,)>, ) -> EndpointResult { - require_permission_with_optional_authentication!(state, authentication, Permission::WordRead); + // TODO continue from here + let mut database_connection = obtain_database_connection!(state); + require_permission_with_optional_authentication!( + &mut database_connection, + authentication, + Permission::WordRead + ); - let target_word_uuid = parse_string_into_uuid(¶meters.into_inner().0)?; + let target_word_uuid = SloveneWordId::new(parameters.into_inner().0); - let target_word = SloveneWordQuery::expanded_word_by_uuid(&state.database, target_word_uuid) - .await - .map_err(APIError::InternalError)?; - let Some(target_word) = target_word else { + let potential_slovene_word = entities::SloveneWordQuery::get_by_id_with_meanings( + &mut database_connection, + target_word_uuid, + ) + .await?; + + let Some(slovene_word_with_meanings) = potential_slovene_word else { return Err(APIError::not_found()); }; Ok(SloveneWordInfoResponse { - word: SloveneWord::from_expanded_word_info(target_word), + word: slovene_word_with_meanings.into_api_model(), } .into_response()) } @@ -465,23 +576,31 @@ pub async fn get_specific_slovene_word_by_lemma( authentication: UserAuthenticationExtractor, parameters: web::Path<(String,)>, ) -> EndpointResult { - require_permission_with_optional_authentication!(state, authentication, Permission::WordRead); + let mut database_connection = obtain_database_connection!(state); + require_permission_with_optional_authentication!( + &mut database_connection, + authentication, + Permission::WordRead + ); - let target_word_lemma = parameters.into_inner().0; + let target_word_lemma = ¶meters.into_inner().0; - let target_word = SloveneWordQuery::expanded_word_by_lemma(&state.database, target_word_lemma) - .await - .map_err(APIError::InternalError)?; - let Some(target_word) = target_word else { + let potential_slovene_word = entities::SloveneWordQuery::get_by_exact_lemma_with_meanings( + &mut database_connection, + &target_word_lemma, + ) + .await?; + + let Some(slovene_word_with_meanings) = potential_slovene_word else { return Err(APIError::not_found()); }; Ok(SloveneWordInfoResponse { - word: SloveneWord::from_expanded_word_info(target_word), + word: slovene_word_with_meanings.into_api_model(), } .into_response()) } @@ -492,8 +611,6 @@ pub async fn get_specific_slovene_word_by_lemma( #[derive(Deserialize, Serialize, Clone, PartialEq, Eq, Debug, ToSchema, Default)] pub struct SloveneWordUpdateRequest { pub lemma: Option, - pub disambiguation: Option, - pub description: Option, } impl_json_response_builder!(SloveneWordUpdateRequest); @@ -548,58 +665,78 @@ impl_json_response_builder!(SloveneWordUpdateRequest); pub async fn update_specific_slovene_word( state: ApplicationState, authentication: UserAuthenticationExtractor, - parameters: web::Path<(String,)>, + parameters: web::Path<(Uuid,)>, request_data: web::Json, ) -> EndpointResult { - let authenticated_user = require_authentication!(authentication); - require_permission!(state, authenticated_user, Permission::WordUpdate); + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + let authenticated_user = require_authentication!(authentication); + require_permission!( + &mut transaction, + authenticated_user, + Permission::WordUpdate + ); - let target_word_uuid = parse_string_into_uuid(¶meters.into_inner().0)?; + let target_word_id = SloveneWordId::new(parameters.into_inner().0); let request_data = request_data.into_inner(); + let target_word_exists = - SloveneWordQuery::word_exists_by_uuid(&state.database, target_word_uuid) - .await - .map_err(APIError::InternalError)?; + entities::SloveneWordQuery::exists_by_id(&mut transaction, target_word_id).await?; if !target_word_exists { return Err(APIError::not_found()); } - let updated_word = SloveneWordMutation::update( - &state.database, - target_word_uuid, - UpdatedSloveneWord { - lemma: request_data.lemma, - disambiguation: request_data.disambiguation, - description: request_data.description, + let updated_successfully = entities::SloveneWordMutation::update( + &mut transaction, + target_word_id, + SloveneWordFieldsToUpdate { + new_lemma: request_data.lemma, }, ) - .await - .map_err(APIError::InternalError)?; + .await?; + + if !updated_successfully { + transaction.rollback().await?; + + return Err(APIError::internal_error_with_reason( + "database inconsistency: failed to update slovene word, even though it \ + previously existed inside the same transaction", + )); + } + + let updated_word = + entities::SloveneWordQuery::get_by_id_with_meanings(&mut transaction, target_word_id) + .await? + .ok_or_else(|| { + APIError::internal_error_with_reason( + "database inconsistency: word did not exist after updating it \ + inside the same transaction", + ) + })?; - let related_word_info = - SloveneWordQuery::related_word_information_only(&state.database, updated_word.word_id) - .await - .map_err(APIError::InternalError)?; + // TODO at the end, go over all endpoints and make sure that they all commit transactions if they use them + transaction.commit().await?; + /* TODO pending cache layer rewrite // Signals to the the search indexer that the word has been updated. state .search .signal_slovene_word_created_or_updated(updated_word.word_id) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; */ Ok(SloveneWordInfoResponse { - word: SloveneWord::from_word_and_related_info(updated_word, related_word_info), + word: updated_word.into_api_model(), } .into_response()) } @@ -649,48 +786,54 @@ pub async fn update_specific_slovene_word( pub async fn delete_specific_slovene_word( state: ApplicationState, authentication: UserAuthenticationExtractor, - parameters: web::Path<(String,)>, + parameters: web::Path<(Uuid,)>, ) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + let authenticated_user = require_authentication!(authentication); - require_permission!(state, authenticated_user, Permission::WordDelete); + require_permission!( + &mut transaction, + authenticated_user, + Permission::WordDelete + ); - let target_word_uuid = parse_string_into_uuid(¶meters.into_inner().0)?; + let target_word_id = SloveneWordId::new(parameters.into_inner().0); let target_word_exists = - SloveneWordQuery::word_exists_by_uuid(&state.database, target_word_uuid) - .await - .map_err(APIError::InternalError)?; + entities::SloveneWordQuery::exists_by_id(&mut transaction, target_word_id).await?; if !target_word_exists { return Err(APIError::not_found()); } - WordMutation::delete(&state.database, target_word_uuid) - .await - .map_err(APIError::InternalError)?; + let has_been_deleted = + entities::SloveneWordMutation::delete(&mut transaction, target_word_id).await?; + + if !has_been_deleted { + return Err(APIError::not_found()); + } + /* TODO pending cache layer rewrite // Signals to the the search indexer that the word has been removed. state .search - .signal_slovene_word_removed(target_word_uuid) + .signal_slovene_word_removed(target_word_id) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; */ Ok(HttpResponse::Ok().finish()) } -// TODO Links. - - #[rustfmt::skip] -pub fn slovene_dictionary_router() -> Scope { - web::scope("/slovene") +pub fn slovene_word_router() -> Scope { + web::scope("") .service(get_all_slovene_words) .service(create_slovene_word) .service(get_specific_slovene_word) diff --git a/kolomoni/src/api/v1/dictionary/suggestions.rs b/kolomoni/src/api/v1/dictionary/suggestions.rs index af10615..369e22a 100644 --- a/kolomoni/src/api/v1/dictionary/suggestions.rs +++ b/kolomoni/src/api/v1/dictionary/suggestions.rs @@ -19,7 +19,7 @@ use crate::{ v1::dictionary::parse_string_into_uuid, }, authentication::UserAuthenticationExtractor, - error_response_with_reason, + json_error_response_with_reason, require_authentication, require_permission, state::ApplicationState, @@ -96,7 +96,7 @@ pub async fn suggest_translation( let english_word_exists = EnglishWordQuery::word_exists_by_uuid(&state.database, english_word_uuid) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; if !english_word_exists { return Err(APIError::client_error( "The provided english word does not exist.", @@ -106,7 +106,7 @@ pub async fn suggest_translation( let slovene_word_exists = SloveneWordQuery::word_exists_by_uuid(&state.database, slovene_word_uuid) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; if !slovene_word_exists { return Err(APIError::client_error( "The provided slovene word does not exist.", @@ -120,10 +120,10 @@ pub async fn suggest_translation( slovene_word_uuid, ) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; if suggestion_already_exists { - return Ok(error_response_with_reason!( + return Ok(json_error_response_with_reason!( StatusCode::CONFLICT, "The translation suggestion already exists." )); @@ -138,7 +138,7 @@ pub async fn suggest_translation( }, ) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; @@ -147,12 +147,12 @@ pub async fn suggest_translation( .search .signal_english_word_created_or_updated(english_word_uuid) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; state .search .signal_slovene_word_created_or_updated(slovene_word_uuid) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; Ok(HttpResponse::Ok().finish()) @@ -230,7 +230,7 @@ pub async fn delete_suggestion( let english_word_exists = EnglishWordQuery::word_exists_by_uuid(&state.database, english_word_uuid) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; if !english_word_exists { return Err(APIError::client_error( "The provided english word does not exist.", @@ -240,7 +240,7 @@ pub async fn delete_suggestion( let slovene_word_exists = SloveneWordQuery::word_exists_by_uuid(&state.database, slovene_word_uuid) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; if !slovene_word_exists { return Err(APIError::client_error( "The provided slovene word does not exist.", @@ -255,7 +255,7 @@ pub async fn delete_suggestion( slovene_word_uuid, ) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; if !suggestion_exists { return Err(APIError::not_found()); @@ -270,7 +270,7 @@ pub async fn delete_suggestion( }, ) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; @@ -279,12 +279,12 @@ pub async fn delete_suggestion( .search .signal_english_word_created_or_updated(english_word_uuid) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; state .search .signal_slovene_word_created_or_updated(slovene_word_uuid) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; Ok(HttpResponse::Ok().finish()) diff --git a/kolomoni/src/api/v1/dictionary/translations.rs b/kolomoni/src/api/v1/dictionary/translations.rs index 9a0d513..14a440b 100644 --- a/kolomoni/src/api/v1/dictionary/translations.rs +++ b/kolomoni/src/api/v1/dictionary/translations.rs @@ -1,21 +1,21 @@ use actix_http::StatusCode; use actix_web::{delete, post, web, HttpResponse, Scope}; use kolomoni_auth::Permission; -use kolomoni_database::{ - mutation::{NewTranslation, TranslationMutation, TranslationToDelete}, - query::{EnglishWordQuery, SloveneWordQuery, TranslationQuery}, -}; +use kolomoni_core::id::{EnglishWordMeaningId, SloveneWordMeaningId}; +use kolomoni_database::entities; use serde::{Deserialize, Serialize}; +use sqlx::Acquire; use utoipa::ToSchema; +use uuid::Uuid; use crate::{ api::{ errors::{APIError, EndpointResult}, openapi, - v1::dictionary::parse_string_into_uuid, }, authentication::UserAuthenticationExtractor, - error_response_with_reason, + json_error_response_with_reason, + obtain_database_connection, require_authentication, require_permission, state::ApplicationState, @@ -23,9 +23,9 @@ use crate::{ #[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] -pub struct TranslationRequest { - pub english_word_id: String, - pub slovene_word_id: String, +pub struct TranslationCreationRequest { + pub english_word_meaning_id: Uuid, + pub slovene_word_meaning_id: Uuid, } @@ -73,11 +73,14 @@ pub struct TranslationRequest { pub async fn create_translation( state: ApplicationState, authentication: UserAuthenticationExtractor, - request_body: web::Json, + request_body: web::Json, ) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + let authenticated_user = require_authentication!(authentication); require_permission!( - state, + &mut transaction, authenticated_user, Permission::TranslationCreate ); @@ -85,68 +88,71 @@ pub async fn create_translation( let request_body = request_body.into_inner(); - let english_word_uuid = parse_string_into_uuid(&request_body.english_word_id)?; - let slovene_word_uuid = parse_string_into_uuid(&request_body.slovene_word_id)?; + let english_word_meaning_id = EnglishWordMeaningId::new(request_body.english_word_meaning_id); + let slovene_word_meaning_id = SloveneWordMeaningId::new(request_body.slovene_word_meaning_id); + let english_word_exists = - EnglishWordQuery::word_exists_by_uuid(&state.database, english_word_uuid) - .await - .map_err(APIError::InternalError)?; + entities::EnglishWordMeaningQuery::exists_by_id(&mut transaction, english_word_meaning_id) + .await?; + if !english_word_exists { return Err(APIError::client_error( - "The provided english word does not exist.", + "The provided english word meaning does not exist.", )); } + let slovene_word_exists = - SloveneWordQuery::word_exists_by_uuid(&state.database, slovene_word_uuid) - .await - .map_err(APIError::InternalError)?; + entities::SloveneWordMeaningQuery::exists_by_id(&mut transaction, slovene_word_meaning_id) + .await?; + if !slovene_word_exists { return Err(APIError::client_error( "The provided slovene word does not exist.", )); } - let translation_already_exists = TranslationQuery::exists( - &state.database, - english_word_uuid, - slovene_word_uuid, + + + let translation_already_exists = entities::WordMeaningTranslationQuery::exists( + &mut transaction, + english_word_meaning_id, + slovene_word_meaning_id, ) - .await - .map_err(APIError::InternalError)?; + .await?; if translation_already_exists { - return Ok(error_response_with_reason!( + return Ok(json_error_response_with_reason!( StatusCode::CONFLICT, "The translation already exists." )); } - TranslationMutation::create( - &state.database, - NewTranslation { - english_word_id: english_word_uuid, - slovene_word_id: slovene_word_uuid, - }, + + let _ = entities::WordMeaningTranslationMutation::create( + &mut transaction, + english_word_meaning_id, + slovene_word_meaning_id, + Some(authenticated_user.user_id()), ) - .await - .map_err(APIError::InternalError)?; + .await?; + /* TODO pending cache layer rewrite // Signals to the search engine that both words have been updated. state .search .signal_english_word_created_or_updated(english_word_uuid) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; state .search .signal_slovene_word_created_or_updated(slovene_word_uuid) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; */ Ok(HttpResponse::Ok().finish()) @@ -157,8 +163,8 @@ pub async fn create_translation( #[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] pub struct TranslationDeletionRequest { - pub english_word_id: String, - pub slovene_word_id: String, + pub english_word_meaning_id: Uuid, + pub slovene_word_meaning_id: Uuid, } @@ -207,9 +213,12 @@ pub async fn delete_translation( authentication: UserAuthenticationExtractor, request_body: web::Json, ) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + let authenticated_user = require_authentication!(authentication); require_permission!( - state, + &mut transaction, authenticated_user, Permission::TranslationDelete ); @@ -217,66 +226,74 @@ pub async fn delete_translation( let request_body = request_body.into_inner(); - let english_word_uuid = parse_string_into_uuid(&request_body.english_word_id)?; - let slovene_word_uuid = parse_string_into_uuid(&request_body.slovene_word_id)?; + let english_word_meaning_id = EnglishWordMeaningId::new(request_body.english_word_meaning_id); + let slovene_word_meaning_id = SloveneWordMeaningId::new(request_body.slovene_word_meaning_id); - let english_word_exists = - EnglishWordQuery::word_exists_by_uuid(&state.database, english_word_uuid) - .await - .map_err(APIError::InternalError)?; - if !english_word_exists { + let english_word_meaning_exists = + entities::EnglishWordMeaningQuery::exists_by_id(&mut transaction, english_word_meaning_id) + .await?; + + if !english_word_meaning_exists { return Err(APIError::client_error( - "The provided english word does not exist.", + "The given english word meaning does not exist.", )); } - let slovene_word_exists = - SloveneWordQuery::word_exists_by_uuid(&state.database, slovene_word_uuid) - .await - .map_err(APIError::InternalError)?; - if !slovene_word_exists { + + let slovene_word_meaning_exists = + entities::SloveneWordMeaningQuery::exists_by_id(&mut transaction, slovene_word_meaning_id) + .await?; + + if !slovene_word_meaning_exists { return Err(APIError::client_error( - "The provided slovene word does not exist.", + "The given slovene word meaning does not exist.", )); } - let suggestion_exists = TranslationQuery::exists( - &state.database, - english_word_uuid, - slovene_word_uuid, + + let translation_relationship_exists = entities::WordMeaningTranslationQuery::exists( + &mut transaction, + english_word_meaning_id, + slovene_word_meaning_id, ) - .await - .map_err(APIError::InternalError)?; + .await?; - if !suggestion_exists { + if !translation_relationship_exists { return Err(APIError::not_found()); } - TranslationMutation::delete( - &state.database, - TranslationToDelete { - english_word_id: english_word_uuid, - slovene_word_id: slovene_word_uuid, - }, - ) - .await - .map_err(APIError::InternalError)?; + let deleted_translation_relationship_successfully = + entities::WordMeaningTranslationMutation::delete( + &mut transaction, + english_word_meaning_id, + slovene_word_meaning_id, + ) + .await?; + + + if !deleted_translation_relationship_successfully { + return Err(APIError::internal_error_with_reason( + "database inconsistency: failed to delete a translation relationship \ + even though it previously existed inside the same transaction", + )); + } + /* TODO pending cache layer rewrite // Signals to the search engine that both words have been updated. state .search .signal_english_word_created_or_updated(english_word_uuid) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; state .search .signal_slovene_word_created_or_updated(slovene_word_uuid) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; */ Ok(HttpResponse::Ok().finish()) diff --git a/kolomoni/src/api/v1/login.rs b/kolomoni/src/api/v1/login.rs index 040682a..0863f0f 100644 --- a/kolomoni/src/api/v1/login.rs +++ b/kolomoni/src/api/v1/login.rs @@ -1,69 +1,25 @@ use actix_web::{post, web, HttpResponse, Scope}; use chrono::{Duration, Utc}; use kolomoni_auth::{JWTClaims, JWTTokenType, JWTValidationError}; -use kolomoni_database::query; -use miette::Context; -use serde::{Deserialize, Serialize}; +use kolomoni_core::api_models::{ + UserLoginRefreshRequest, + UserLoginRefreshResponse, + UserLoginRequest, + UserLoginResponse, +}; +use kolomoni_database::entities; use tracing::{debug, warn}; -use utoipa::ToSchema; -use crate::api::errors::{APIError, EndpointResult, ErrorReasonResponse}; +use crate::api::errors::{EndpointResult, ErrorReasonResponse}; use crate::api::macros::ContextlessResponder; use crate::api::openapi; -use crate::impl_json_response_builder; use crate::state::ApplicationState; +use crate::{impl_json_response_builder, obtain_database_connection}; -/// User login information. -#[derive(Deserialize, PartialEq, Eq, Debug, ToSchema)] -#[cfg_attr(feature = "with_test_facilities", derive(Serialize))] -#[schema( - example = json!({ - "username": "sample_user", - "password": "verysecurepassword" - }) -)] -pub struct UserLoginRequest { - /// Username to log in as. - pub username: String, - - /// Password. - pub password: String, -} - -/// Response on successful user login. -/// -/// Contains two tokens: -/// - the `access_token` that should be appended to future requests and -/// - the `refresh_token` that can be used on `POST /api/v1/users/login/refresh` to -/// receive a new (fresh) request token. -/// -/// This works because the `refresh_token` has a longer expiration time. -#[derive(Serialize, PartialEq, Eq, Debug, ToSchema)] -#[cfg_attr(feature = "with_test_facilities", derive(Deserialize))] -#[schema( - example = json!({ - "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdGFyaSBLb2xvbW9uaSIsInN1Y\ - iI6IkFQSSB0b2tlbiIsImlhdCI6MTY4Nzk3MTMyMiwiZXhwIjoxNjg4MDU3NzIyLCJ1c2VybmF\ - tZSI6InRlc3QiLCJ0b2tlbl90eXBlIjoiYWNjZXNzIn0.ZnuhEVacQD_pYzkW9h6aX3eoRNOAs\ - 2-y3EngGBglxkk", - "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdGFyaSBLb2xvbW9uaSIsInN1\ - YiI6IkFQSSB0b2tlbiIsImlhdCI6MTY4Nzk3MTMyMiwiZXhwIjoxNjg4NTc2MTIyLCJ1c2Vyb\ - mFtZSI6InRlc3QiLCJ0b2tlbl90eXBlIjoicmVmcmVzaCJ9.Ze6DI5EZ-swXRQrMW3NIppYej\ - clGbyI9D6zmYBWJMLk" - }) -)] -pub struct UserLoginResponse { - /// JWT access token. - /// Provide in subsequent requests in the `Authorization` header as `Bearer your_token_here`. - pub access_token: String, - - /// JWT refresh token. - pub refresh_token: String, -} - impl_json_response_builder!(UserLoginResponse); +impl_json_response_builder!(UserLoginRefreshResponse); @@ -104,17 +60,19 @@ pub async fn login( state: ApplicationState, login_info: web::Json, ) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + + // Validate user login credentials. - let login_result_details = query::UserQuery::validate_user_credentials( - &state.database, + let login_result = entities::UserQuery::validate_credentials( + &mut database_connection, &state.hasher, &login_info.username, &login_info.password, ) - .await - .map_err(APIError::InternalError)?; + .await?; - let Some(logged_in_user) = login_result_details else { + let Some(logged_in_user) = login_result else { return Ok( HttpResponse::Forbidden().json(ErrorReasonResponse::custom_reason( "Invalid login credentials.", @@ -124,34 +82,25 @@ pub async fn login( // Generate access and refresh token. + let logged_in_at = Utc::now(); + let access_token_claims = JWTClaims::create( logged_in_user.id, - Utc::now(), - // PANIC SAFETY: 1 is a valid number of days. - Duration::try_days(1).unwrap(), + logged_in_at, + Duration::hours(2), JWTTokenType::Access, ); let refresh_token_claims = JWTClaims::create( logged_in_user.id, - Utc::now(), - // PANIC SAFETY: 7 is a valid number of days. - Duration::try_days(7).unwrap(), + logged_in_at, + Duration::days(7), JWTTokenType::Refresh, ); - let access_token = state - .jwt_manager - .create_token(access_token_claims) - .wrap_err("Errored while creating JWT access token.") - .map_err(APIError::InternalError)?; - - let refresh_token = state - .jwt_manager - .create_token(refresh_token_claims) - .wrap_err("Errored while creating JWT refresh token.") - .map_err(APIError::InternalError)?; + let access_token = state.jwt_manager.create_token(access_token_claims)?; + let refresh_token = state.jwt_manager.create_token(refresh_token_claims)?; debug!( @@ -170,42 +119,6 @@ pub async fn login( -/// Information with which to refresh a user's login, generating a new access token. -#[derive(Deserialize, ToSchema)] -#[schema( - example = json!({ - "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdGFyaSBLb2xvbW9uaSIsInN\ - 1YiI6IkFQSSB0b2tlbiIsImlhdCI6MTY4Nzk3MTMyMiwiZXhwIjoxNjg4NTc2MTIyLCJ1c2V\ - ybmFtZSI6InRlc3QiLCJ0b2tlbl90eXBlIjoicmVmcmVzaCJ9.Ze6DI5EZ-swXRQrMW3NIpp\ - YejclGbyI9D6zmYBWJMLk" - }) -)] -pub struct UserLoginRefreshRequest { - /// Refresh token to use to generate an access token. - /// - /// Token must not have expired to work. - pub refresh_token: String, -} - -/// Response on successful login refresh. -#[derive(Serialize, Debug, ToSchema)] -#[schema( - example = json!({ - "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdGFyaSBLb2xvbW9uaSIsInN1\ - YiI6IkFQSSB0b2tlbiIsImlhdCI6MTY4Nzk3MTMyMiwiZXhwIjoxNjg4MDU3NzIyLCJ1c2Vyb\ - mFtZSI6InRlc3QiLCJ0b2tlbl90eXBlIjoiYWNjZXNzIn0.ZnuhEVacQD_pYzkW9h6aX3eoRN\ - OAs2-y3EngGBglxkk" - }) -)] -pub struct UserLoginRefreshResponse { - /// Newly-generated access token to use in future requests. - pub access_token: String, -} - -impl_json_response_builder!(UserLoginRefreshResponse); - - - /// Refresh a login /// /// The user must provide a refresh token given to them on an initial call to `/users/login`. @@ -261,9 +174,9 @@ pub async fn refresh_login( Ok(token_claims) => token_claims, Err(error) => { return match error { - JWTValidationError::Expired(token_claims) => { + JWTValidationError::Expired { expired_token } => { debug!( - user_id = token_claims.user_id, + user_id = %expired_token.user_id, "Refusing to refresh expired token.", ); @@ -273,8 +186,8 @@ pub async fn refresh_login( )), ) } - JWTValidationError::InvalidToken(error) => { - warn!(error = error, "Failed to parse refresh token."); + JWTValidationError::InvalidToken { reason } => { + warn!(error = %reason, "Failed to parse refresh token."); Ok( HttpResponse::BadRequest().json(ErrorReasonResponse::custom_reason( @@ -298,18 +211,15 @@ pub async fn refresh_login( let access_token_claims = JWTClaims::create( refresh_token_claims.user_id, Utc::now(), - // PANIC SAFETY: 1 is a valid number of days. - Duration::try_days(1).unwrap(), + Duration::days(1), JWTTokenType::Access, ); - let access_token = state - .jwt_manager - .create_token(access_token_claims) - .map_err(APIError::InternalError)?; + + let access_token = state.jwt_manager.create_token(access_token_claims)?; debug!( - user_id = refresh_token_claims.user_id, + user_id = %refresh_token_claims.user_id, "User has successfully refreshed access token." ); diff --git a/kolomoni/src/api/v1/mod.rs b/kolomoni/src/api/v1/mod.rs index 77d8206..a5eb7fb 100644 --- a/kolomoni/src/api/v1/mod.rs +++ b/kolomoni/src/api/v1/mod.rs @@ -17,6 +17,8 @@ use actix_web::{web, Scope}; use self::{dictionary::dictionary_router, login::login_router, users::users_router}; +// TODO refactor the API out of the v1 directory, since we currently have only one version (but keep the HTTP path /v1/ prefix!) + /// Router for the entire V1 API. /// Lives under the `/api/v1` path. pub fn v1_api_router() -> Scope { diff --git a/kolomoni/src/api/v1/users.rs b/kolomoni/src/api/v1/users.rs deleted file mode 100644 index 7d87aa1..0000000 --- a/kolomoni/src/api/v1/users.rs +++ /dev/null @@ -1,215 +0,0 @@ -use actix_web::{web, Scope}; -use chrono::{DateTime, Utc}; -use kolomoni_database::entities; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -use self::all::get_all_registered_users; -use self::current::{ - get_current_user_effective_permissions, - get_current_user_info, - get_current_user_roles, - update_current_user_display_name, -}; -use self::registration::register_user; -use self::specific::{ - // add_permissions_to_specific_user, - add_roles_to_specific_user, - get_specific_user_effective_permissions, - get_specific_user_info, - get_specific_user_roles, - remove_roles_from_specific_user, - update_specific_user_display_name, -}; -use crate::impl_json_response_builder; - -pub mod all; -pub mod current; -pub mod registration; -pub mod specific; - - - -/// Information about a single user. -/// -/// This struct is used as part of a response in the public API. -#[derive(Serialize, PartialEq, Eq, Clone, Debug, ToSchema)] -#[cfg_attr(feature = "with_test_facilities", derive(Deserialize))] -#[schema(example = json!({ - "id": 1, - "username": "janeznovak", - "display_name": "Janez Novak", - "joined_at": "2023-06-27T20:33:53.078789Z", - "last_modified_at": "2023-06-27T20:34:27.217273Z", - "last_active_at": "2023-06-27T20:34:27.253746Z" -}))] -pub struct UserInformation { - /// Internal user ID. - pub id: i32, - - /// Unique username for login. - pub username: String, - - /// Unique display name. - pub display_name: String, - - /// Registration date and time. - pub joined_at: DateTime, - - /// Last modification date and time. - pub last_modified_at: DateTime, - - /// Last activity date and time. - pub last_active_at: DateTime, -} - -impl UserInformation { - /// Convert a user database model into a [`UserInformation`] - /// that can be safely exposed through the API. - #[inline] - pub fn from_user_model(model: entities::user::Model) -> Self { - Self { - id: model.id, - username: model.username, - display_name: model.display_name, - joined_at: model.joined_at.with_timezone(&Utc), - last_modified_at: model.last_modified_at.with_timezone(&Utc), - last_active_at: model.last_active_at.with_timezone(&Utc), - } - } -} - - - -/// Information about one user in particular. -/// -/// This struct is used as a response in the public API. -#[derive(Serialize, PartialEq, Eq, Debug, ToSchema)] -#[cfg_attr(feature = "with_test_facilities", derive(Deserialize))] -#[schema(example = json!({ - "user": { - "id": 1, - "username": "janeznovak", - "display_name": "Janez Novak", - "joined_at": "2023-06-27T20:33:53.078789Z", - "last_modified_at": "2023-06-27T20:34:27.217273Z", - "last_active_at": "2023-06-27T20:34:27.253746Z" - } -}))] -pub struct UserInfoResponse { - pub user: UserInformation, -} - -impl UserInfoResponse { - pub fn new(model: entities::user::Model) -> Self { - Self { - user: UserInformation::from_user_model(model), - } - } -} - -impl_json_response_builder!(UserInfoResponse); - - - -/// User (API caller) request to change a user's display name. -/// -/// This struct is used as a request in the public API. -#[derive(Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] -#[cfg_attr(feature = "with_test_facilities", derive(Serialize))] -#[schema( - example = json!({ - "new_display_name": "Janez Novak Veliki" - }) -)] -pub struct UserDisplayNameChangeRequest { - /// Display name to change to. - pub new_display_name: String, -} - - - -/// Response indicating successful change of a display name. -/// Contains the updated user information. -/// -/// This struct is used as a response in the public API. -#[derive(Serialize, PartialEq, Eq, Clone, Debug, ToSchema)] -#[cfg_attr(feature = "with_test_facilities", derive(Deserialize))] -pub struct UserDisplayNameChangeResponse { - pub user: UserInformation, -} - -impl_json_response_builder!(UserDisplayNameChangeResponse); - - - - -#[derive(Serialize, PartialEq, Eq, Debug, ToSchema)] -#[cfg_attr(feature = "with_test_facilities", derive(Deserialize))] -#[schema( - example = json!({ - "role_names": [ - "user", - "administrator" - ] - }) -)] -pub struct UserRolesResponse { - pub role_names: Vec, -} - -impl_json_response_builder!(UserRolesResponse); - - - -/// Response containing a list of active permissions. -/// -/// This struct is used as a response in the public API. -#[derive(Serialize, PartialEq, Eq, Debug, ToSchema)] -#[cfg_attr(feature = "with_test_facilities", derive(Deserialize))] -#[schema( - example = json!({ - "permissions": [ - "user.self:read", - "user.self:write", - "user.any:read" - ] - }) -)] -pub struct UserPermissionsResponse { - pub permissions: Vec, -} - -impl UserPermissionsResponse { - pub fn from_permission_names(permission_names: Vec) -> Self { - Self { - permissions: permission_names, - } - } -} - -impl_json_response_builder!(UserPermissionsResponse); - - -/// Router for all user-related operations. -/// Lives under `/api/v1/users`. -#[rustfmt::skip] -pub fn users_router() -> Scope { - web::scope("users") - // all.rs - .service(get_all_registered_users) - // registration.rs - .service(register_user) - // current.ts - .service(get_current_user_info) - .service(get_current_user_roles) - .service(get_current_user_effective_permissions) - .service(update_current_user_display_name) - // specific.rs - .service(get_specific_user_info) - .service(get_specific_user_effective_permissions) - .service(get_specific_user_roles) - .service(add_roles_to_specific_user) - .service(remove_roles_from_specific_user) - .service(update_specific_user_display_name) -} diff --git a/kolomoni/src/api/v1/users/all.rs b/kolomoni/src/api/v1/users/all.rs index 2bbcf39..8164ae9 100644 --- a/kolomoni/src/api/v1/users/all.rs +++ b/kolomoni/src/api/v1/users/all.rs @@ -1,46 +1,24 @@ use actix_web::get; +use futures_util::StreamExt; use kolomoni_auth::Permission; -use kolomoni_database::query; -use serde::Serialize; -use utoipa::ToSchema; +use kolomoni_core::api_models::RegisteredUsersListResponse; +use kolomoni_database::entities; -use super::UserInformation; use crate::{ - api::{ - errors::{APIError, EndpointResult}, - macros::ContextlessResponder, - openapi, - }, + api::{errors::EndpointResult, macros::ContextlessResponder, openapi, traits::IntoApiModel}, authentication::UserAuthenticationExtractor, impl_json_response_builder, + obtain_database_connection, require_authentication, require_permission, state::ApplicationState, }; -/// List of registered users. -#[derive(Serialize, PartialEq, Eq, Debug, ToSchema)] -#[cfg_attr(feature = "with_test_facilities", derive(serde::Deserialize))] -#[schema(title = "RegisteredUsersListResponse")] -#[schema(example = json!({ - "users": [ - { - "id": 1, - "username": "janeznovak", - "display_name": "Janez Novak", - "joined_at": "2023-06-27T20:33:53.078789Z", - "last_modified_at": "2023-06-27T20:34:27.217273Z", - "last_active_at": "2023-06-27T20:34:27.253746Z" - }, - ] -}))] -pub struct RegisteredUsersListResponse { - pub users: Vec, -} impl_json_response_builder!(RegisteredUsersListResponse); + /// List all registered users. /// /// This endpoint returns a list of all registered users. @@ -70,25 +48,36 @@ pub async fn get_all_registered_users( state: ApplicationState, authentication: UserAuthenticationExtractor, ) -> EndpointResult { - // User MUST provide their authentication token AND - // have the `user.any:read` permission to access this endpoint. + let mut database_connection = obtain_database_connection!(state); + + + // To access this endpoint, the user: + // - MUST provide their authentication token, and + // - MUST have the `user.any:read` permission. let authenticated_user = require_authentication!(authentication); - require_permission!(state, authenticated_user, Permission::UserAnyRead); + require_permission!( + &mut database_connection, + authenticated_user, + Permission::UserAnyRead + ); + // Load all users from the database and parse them info `UserInformation` instances. - let all_users = query::UserQuery::get_all_users(&state.database) - .await - .map_err(APIError::InternalError)?; + let mut all_users_stream = entities::UserQuery::get_all_users(&mut database_connection); - let all_users_as_public_struct: Vec = all_users - .into_iter() - .map(UserInformation::from_user_model) - .collect(); + + let mut parsed_users = Vec::new(); + + while let Some(next_user_result) = all_users_stream.next().await { + let next_user_as_api_model = next_user_result?.into_api_model(); + + parsed_users.push(next_user_as_api_model); + } Ok(RegisteredUsersListResponse { - users: all_users_as_public_struct, + users: parsed_users, } .into_response()) } diff --git a/kolomoni/src/api/v1/users/current.rs b/kolomoni/src/api/v1/users/current.rs index 8a52d5a..c55543a 100644 --- a/kolomoni/src/api/v1/users/current.rs +++ b/kolomoni/src/api/v1/users/current.rs @@ -1,45 +1,38 @@ -use actix_web::{ - get, - http::{header, StatusCode}, - patch, - web, - HttpResponse, -}; +use actix_web::{get, http::StatusCode, patch, web}; use kolomoni_auth::Permission; -use kolomoni_database::{ - begin_transaction, - mutation, - query::{self, UserQuery, UserRoleQuery}, -}; -use miette::IntoDiagnostic; +use kolomoni_core::api_models::UserDisplayNameChangeRequest; +use kolomoni_database::entities; +use sqlx::Acquire; use tracing::info; use crate::{ api::{ errors::{APIError, EndpointResult}, macros::{ - construct_last_modified_header_value, + construct_not_modified_response, ContextlessResponder, IntoKolomoniResponseBuilder, }, openapi, + traits::IntoApiModel, v1::users::{ - UserDisplayNameChangeRequest, UserDisplayNameChangeResponse, UserInfoResponse, - UserInformation, UserPermissionsResponse, UserRolesResponse, }, OptionalIfModifiedSince, }, authentication::UserAuthenticationExtractor, - error_response_with_reason, + json_error_response_with_reason, + obtain_database_connection, require_authentication, require_permission, state::ApplicationState, }; +// TODO introduce transactions here and elsewhere (even in read-only operations, for consistency) + /// Get your user information /// @@ -85,43 +78,43 @@ use crate::{ pub async fn get_current_user_info( state: ApplicationState, authentication_extractor: UserAuthenticationExtractor, - if_modified_since: OptionalIfModifiedSince, + if_modified_since_header: OptionalIfModifiedSince, ) -> EndpointResult { - // User must provide an authentication token and - // have the `user.self:read` permission to access this endpoint. + let mut database_connection = obtain_database_connection!(state); + + + // To access this endpoint, the user: + // - MUST provide an authentication token, and + // - MUST have the `user.self:read` permission. let authenticated_user = require_authentication!(authentication_extractor); - let authenticated_user_id = authenticated_user.user_id(); require_permission!( - state, + &mut database_connection, authenticated_user, Permission::UserSelfRead ); - // Load user from database. - let user = query::UserQuery::get_user_by_id(&state.database, authenticated_user_id) - .await - .map_err(APIError::InternalError)? - .ok_or_else(APIError::not_found)?; + let authenticated_user_id = authenticated_user.user_id(); - let last_modification_time = user.last_modified_at.to_utc(); - if if_modified_since.has_not_changed_since(&last_modification_time) { - let mut unchanged_response = HttpResponse::new(StatusCode::NOT_MODIFIED); + // Load user from database. + let current_user = + entities::UserQuery::get_user_by_id(&mut database_connection, authenticated_user_id) + .await? + .ok_or_else(APIError::not_found)?; - unchanged_response.headers_mut().append( - header::LAST_MODIFIED, - construct_last_modified_header_value(&last_modification_time) - .into_diagnostic() - .map_err(APIError::InternalError)?, - ); - Ok(unchanged_response) + if if_modified_since_header.enabled_and_has_not_changed_since(¤t_user.last_modified_at) { + construct_not_modified_response(¤t_user.last_modified_at) } else { - Ok(UserInfoResponse::new(user) - .into_response_builder()? - .last_modified_at(last_modification_time)? - .build()) + let last_modified_at = current_user.last_modified_at; + + Ok(UserInfoResponse { + user: current_user.into_api_model(), + } + .into_response_builder()? + .last_modified_at(last_modified_at)? + .build()) } } @@ -161,28 +154,37 @@ pub async fn get_current_user_roles( state: ApplicationState, authentication: UserAuthenticationExtractor, ) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + + let authenticated_user = require_authentication!(authentication); require_permission!( - state, + &mut database_connection, authenticated_user, Permission::UserSelfRead ); - let user_exists = - UserQuery::user_exists_by_user_id(&state.database, authenticated_user.user_id()) - .await - .map_err(APIError::InternalError)?; + + let user_exists = entities::UserQuery::exists_by_id( + &mut database_connection, + authenticated_user.user_id(), + ) + .await?; + if !user_exists { return Err(APIError::not_found()); } - let user_roles = UserRoleQuery::user_roles(&state.database, authenticated_user.user_id()) - .await - .map_err(APIError::InternalError)?; + let user_roles = entities::UserRoleQuery::roles_for_user( + &mut database_connection, + authenticated_user.user_id(), + ) + .await?; let user_role_names = user_roles.role_names(); + Ok(UserRolesResponse { role_names: user_role_names, } @@ -227,13 +229,15 @@ async fn get_current_user_effective_permissions( state: ApplicationState, authentication_extractor: UserAuthenticationExtractor, ) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + + // User must be authenticated and // have the `user.self:read` permission to access this endpoint. let authenticated_user = require_authentication!(authentication_extractor); let user_permissions = authenticated_user - .permissions(&state.database) - .await - .map_err(APIError::InternalError)?; + .fetch_transitive_permissions(&mut database_connection) + .await?; require_permission!(user_permissions, Permission::UserSelfRead); @@ -303,32 +307,32 @@ async fn update_current_user_display_name( authentication_extractor: UserAuthenticationExtractor, json_data: web::Json, ) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + + // User must be authenticated and have // the `user.self:write` permission to access this endpoint. let authenticated_user = require_authentication!(authentication_extractor); - let authenticated_user_id = authenticated_user.user_id(); require_permission!( - state, + &mut transaction, authenticated_user, Permission::UserSelfWrite ); + let authenticated_user_id = authenticated_user.user_id(); let json_data = json_data.into_inner(); - let database_transaction = - begin_transaction!(&state.database).map_err(APIError::InternalError)?; + // Ensure the display name is unique. - let display_name_already_exists = query::UserQuery::user_exists_by_display_name( - &database_transaction, - &json_data.new_display_name, - ) - .await - .map_err(APIError::InternalError)?; + let display_name_already_exists = + entities::UserQuery::exists_by_display_name(&mut *transaction, &json_data.new_display_name) + .await?; if display_name_already_exists { - return Ok(error_response_with_reason!( + return Ok(json_error_response_with_reason!( StatusCode::CONFLICT, "User with given display name already exists." )); @@ -336,28 +340,26 @@ async fn update_current_user_display_name( // Update user in the database. - let updated_user = mutation::UserMutation::update_display_name_by_user_id( - &database_transaction, + let updated_user = entities::UserMutation::change_display_name_by_user_id( + &mut *transaction, authenticated_user_id, - json_data.new_display_name.clone(), + &json_data.new_display_name, ) - .await - .map_err(APIError::InternalError)?; + .await?; + - database_transaction - .commit() - .await - .map_err(APIError::InternalDatabaseError)?; + transaction.commit().await?; info!( - user_id = authenticated_user_id, + user_id = %authenticated_user_id, new_display_name = json_data.new_display_name, "User has updated their display name." ); + Ok(UserDisplayNameChangeResponse { - user: UserInformation::from_user_model(updated_user), + user: updated_user.into_api_model(), } .into_response()) } diff --git a/kolomoni/src/api/v1/users/mod.rs b/kolomoni/src/api/v1/users/mod.rs new file mode 100644 index 0000000..0e173a0 --- /dev/null +++ b/kolomoni/src/api/v1/users/mod.rs @@ -0,0 +1,80 @@ +use actix_web::{web, Scope}; +use kolomoni_core::api_models::{ + UserDisplayNameChangeResponse, + UserInfo, + UserInfoResponse, + UserPermissionsResponse, + UserRolesResponse, +}; +use registration::register_user; + +use self::all::get_all_registered_users; +use self::current::{ + get_current_user_effective_permissions, + get_current_user_info, + get_current_user_roles, + update_current_user_display_name, +}; +use self::specific::{ + add_roles_to_specific_user, + get_specific_user_effective_permissions, + get_specific_user_info, + get_specific_user_roles, + remove_roles_from_specific_user, + update_specific_user_display_name, +}; +use crate::api::traits::IntoApiModel; +use crate::impl_json_response_builder; + +pub mod all; +pub mod current; +pub mod registration; +pub mod specific; + + +impl IntoApiModel for kolomoni_database::entities::UserModel { + type ApiModel = UserInfo; + + fn into_api_model(self) -> Self::ApiModel { + Self::ApiModel { + id: self.id, + username: self.username, + display_name: self.display_name, + joined_at: self.joined_at, + last_modified_at: self.last_modified_at, + last_active_at: self.last_active_at, + } + } +} + + +impl_json_response_builder!(UserInfoResponse); +impl_json_response_builder!(UserDisplayNameChangeResponse); +impl_json_response_builder!(UserRolesResponse); +impl_json_response_builder!(UserPermissionsResponse); + + + + +/// Router for all user-related operations. +/// Lives under `/api/v1/users`. +#[rustfmt::skip] +pub fn users_router() -> Scope { + web::scope("users") + // all.rs + .service(get_all_registered_users) + // registration.rs + .service(register_user) + // current.ts + .service(get_current_user_info) + .service(get_current_user_roles) + .service(get_current_user_effective_permissions) + .service(update_current_user_display_name) + // specific.rs + .service(get_specific_user_info) + .service(get_specific_user_effective_permissions) + .service(get_specific_user_roles) + .service(add_roles_to_specific_user) + .service(remove_roles_from_specific_user) + .service(update_specific_user_display_name) +} diff --git a/kolomoni/src/api/v1/users/registration.rs b/kolomoni/src/api/v1/users/registration.rs index 694dd75..9f59334 100644 --- a/kolomoni/src/api/v1/users/registration.rs +++ b/kolomoni/src/api/v1/users/registration.rs @@ -1,73 +1,17 @@ use actix_web::{http::StatusCode, post, web}; -use kolomoni_database::{ - mutation::{self, UserRegistrationInfo}, - query, -}; -use serde::{Deserialize, Serialize}; -use utoipa::ToSchema; +use kolomoni_core::api_models::{UserRegistrationRequest, UserRegistrationResponse}; +use kolomoni_database::entities::{self, UserRegistrationInfo}; +use sqlx::Acquire; -use super::UserInformation; use crate::{ - api::{ - errors::{APIError, EndpointResult}, - macros::ContextlessResponder, - openapi, - }, - error_response_with_reason, + api::{errors::EndpointResult, macros::ContextlessResponder, openapi, traits::IntoApiModel}, impl_json_response_builder, + json_error_response_with_reason, + obtain_database_connection, state::ApplicationState, }; -/// User registration request provided by an API caller. -#[derive(Deserialize, Clone, Debug, ToSchema)] -#[schema(example = json!({ - "username": "janeznovak", - "display_name": "Janez Novak", - "password": "perica_reže_raci_rep" -}))] -#[cfg_attr(feature = "with_test_facilities", derive(Serialize))] -pub struct UserRegistrationRequest { - /// Username to register as (not the same as the display name). - pub username: String, - - /// Name to display as in the UI. - pub display_name: String, - - /// Password for this user account. - pub password: String, -} -/// Conversion into the backend-specific struct for registration -/// (`database::mutation::users::UserRegistrationInfo`). -impl From for UserRegistrationInfo { - fn from(value: UserRegistrationRequest) -> Self { - Self { - username: value.username, - display_name: value.display_name, - password: value.password, - } - } -} - -/// API-serializable response upon successful user registration. -/// Contains the newly-created user's information. -#[derive(Serialize, PartialEq, Eq, Debug, ToSchema)] -#[cfg_attr(feature = "with_test_facilities", derive(Deserialize))] -#[schema( - example = json!({ - "user": { - "id": 1, - "username": "janeznovak", - "display_name": "Janez Novak", - "joined_at": "2023-06-27T20:33:53.078789Z", - "last_modified_at": "2023-06-27T20:34:27.217273Z", - "last_active_at": "2023-06-27T20:34:27.253746Z" - } - }) -)] -pub struct UserRegistrationResponse { - pub user: UserInformation, -} impl_json_response_builder!(UserRegistrationResponse); @@ -119,14 +63,22 @@ pub async fn register_user( state: ApplicationState, json_data: web::Json, ) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + + + let registration_request_data = json_data.into_inner(); + + // Ensure the provided username is unique. - let username_already_exists = - query::UserQuery::user_exists_by_username(&state.database, &json_data.username) - .await - .map_err(APIError::InternalError)?; + let username_already_exists = entities::UserQuery::exists_by_username( + &mut *transaction, + ®istration_request_data.username, + ) + .await?; if username_already_exists { - return Ok(error_response_with_reason!( + return Ok(json_error_response_with_reason!( StatusCode::CONFLICT, "User with provided username already exists." )); @@ -134,13 +86,14 @@ pub async fn register_user( // Ensure the provided display name is unique. - let display_name_already_exists = - query::UserQuery::user_exists_by_display_name(&state.database, &json_data.display_name) - .await - .map_err(APIError::InternalError)?; + let display_name_already_exists = entities::UserQuery::exists_by_display_name( + &mut *transaction, + ®istration_request_data.display_name, + ) + .await?; if display_name_already_exists { - return Ok(error_response_with_reason!( + return Ok(json_error_response_with_reason!( StatusCode::CONFLICT, "User with provided display name already exists." )); @@ -148,16 +101,20 @@ pub async fn register_user( // Create new user. - let new_user = mutation::UserMutation::create_user( - &state.database, + let newly_created_user = entities::UserMutation::create_user( + &mut *transaction, &state.hasher, - json_data.clone().into(), + UserRegistrationInfo { + username: registration_request_data.username, + display_name: registration_request_data.display_name, + password: registration_request_data.password, + }, ) - .await - .map_err(APIError::InternalError)?; + .await?; + Ok(UserRegistrationResponse { - user: UserInformation::from_user_model(new_user), + user: newly_created_user.into_api_model(), } .into_response()) } diff --git a/kolomoni/src/api/v1/users/specific.rs b/kolomoni/src/api/v1/users/specific.rs index 83c8253..e2b2ecf 100644 --- a/kolomoni/src/api/v1/users/specific.rs +++ b/kolomoni/src/api/v1/users/specific.rs @@ -1,11 +1,14 @@ +use std::collections::HashSet; + use actix_web::{delete, get, http::StatusCode, patch, post, web, HttpResponse}; -use kolomoni_auth::{Permission, Role}; -use kolomoni_database::{ - begin_transaction, - mutation, - query::{self, UserQuery, UserRoleQuery}, +use kolomoni_auth::{Permission, Role, RoleSet}; +use kolomoni_core::{ + api_models::{UserDisplayNameChangeRequest, UserDisplayNameChangeResponse}, + id::UserId, }; +use kolomoni_database::entities; use serde::Deserialize; +use sqlx::{types::Uuid, Acquire}; use tracing::info; use utoipa::ToSchema; @@ -14,17 +17,12 @@ use crate::{ errors::{APIError, EndpointResult}, macros::ContextlessResponder, openapi, - v1::users::{ - UserDisplayNameChangeRequest, - UserDisplayNameChangeResponse, - UserInfoResponse, - UserInformation, - UserPermissionsResponse, - UserRolesResponse, - }, + traits::IntoApiModel, + v1::users::{UserInfoResponse, UserPermissionsResponse, UserRolesResponse}, }, authentication::UserAuthenticationExtractor, - error_response_with_reason, + json_error_response_with_reason, + obtain_database_connection, require_authentication, require_permission, require_permission_with_optional_authentication, @@ -46,7 +44,11 @@ use crate::{ path = "/users/{user_id}", tag = "users", params( - ("user_id" = i32, Path, description = "ID of the user to get information about.") + ( + "user_id" = Uuid, + Path, + description = "ID of the user to get information about." + ) ), responses( ( @@ -76,15 +78,18 @@ use crate::{ async fn get_specific_user_info( state: ApplicationState, authentication_extractor: UserAuthenticationExtractor, - path_info: web::Path<(i32,)>, + path_info: web::Path<(Uuid,)>, ) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + + // Users don't need to authenticate due to a // blanket permission grant for `user.any:read`. // This will also work if we remove the blanket grant // in the future - it will fall back to requiring authentication // AND the `user.any:read` permission. require_permission_with_optional_authentication!( - state, + &mut database_connection, authentication_extractor, Permission::UserAnyRead ); @@ -93,16 +98,22 @@ async fn get_specific_user_info( // Return information about the requested user. let requested_user_id = path_info.into_inner().0; - let optional_requested_user = - query::UserQuery::get_user_by_id(&state.database, requested_user_id) - .await - .map_err(APIError::InternalError)?; - let Some(user) = optional_requested_user else { + let user_info_if_they_exist = entities::UserQuery::get_user_by_id( + &mut database_connection, + UserId::new(requested_user_id), + ) + .await?; + + + let Some(user_info) = user_info_if_they_exist else { return Ok(HttpResponse::NotFound().finish()); }; - Ok(UserInfoResponse::new(user).into_response()) + Ok(UserInfoResponse { + user: user_info.into_api_model(), + } + .into_response()) } @@ -118,7 +129,7 @@ async fn get_specific_user_info( tag = "users", params( ( - "user_id" = i32, + "user_id" = Uuid, Path, description = "ID of the user to query roles for." ) @@ -141,39 +152,39 @@ async fn get_specific_user_info( pub async fn get_specific_user_roles( state: ApplicationState, authentication_extractor: UserAuthenticationExtractor, - path_info: web::Path<(i32,)>, + path_info: web::Path<(Uuid,)>, ) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + + // Users don't need to authenticate due to a // blanket permission grant for `user.any:read`. // This will also work if we remove the blanket grant // in the future - it will fall back to requiring authentication // AND the `user.any:read` permission. require_permission_with_optional_authentication!( - state, + &mut database_connection, authentication_extractor, Permission::UserAnyRead ); - let target_user_id = path_info.into_inner().0; - let target_user_exists = UserQuery::user_exists_by_user_id(&state.database, target_user_id) - .await - .map_err(APIError::InternalError)?; + + let target_user_id = UserId::new(path_info.into_inner().0); + + + let target_user_exists = + entities::UserQuery::exists_by_id(&mut database_connection, target_user_id).await?; if !target_user_exists { return Err(APIError::not_found()); } - let target_user_roles = UserRoleQuery::user_roles(&state.database, target_user_id) - .await - .map_err(APIError::InternalError)?; + let target_user_role_set = + entities::UserRoleQuery::roles_for_user(&mut database_connection, target_user_id).await?; - let target_user_role_names = target_user_roles - .into_roles() - .into_iter() - .map(|role| role.name().to_string()) - .collect(); + let target_user_role_names = target_user_role_set.role_names(); Ok(UserRolesResponse { role_names: target_user_role_names, @@ -199,7 +210,7 @@ pub async fn get_specific_user_roles( tag = "users", params( ( - "user_id" = i32, + "user_id" = Uuid, Path, description = "ID of the user to get effective permissions for." ) @@ -225,37 +236,42 @@ pub async fn get_specific_user_roles( async fn get_specific_user_effective_permissions( state: ApplicationState, authentication_extractor: UserAuthenticationExtractor, - path_info: web::Path<(i32,)>, + path_info: web::Path<(Uuid,)>, ) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + + // Only authenticated users with the `user.any:read` permission can access this endpoint. let authenticated_user = require_authentication!(authentication_extractor); - require_permission!(state, authenticated_user, Permission::UserAnyRead); + require_permission!( + &mut database_connection, + authenticated_user, + Permission::UserAnyRead + ); // Get requested user's permissions. - let target_user_id = path_info.into_inner().0; + let target_user_id = UserId::new(path_info.into_inner().0); + let target_user_exists = - query::UserQuery::user_exists_by_user_id(&state.database, target_user_id) - .await - .map_err(APIError::InternalError)?; + entities::UserQuery::exists_by_id(&mut database_connection, target_user_id).await?; if !target_user_exists { return Ok(HttpResponse::NotFound().finish()); } - let target_user_permission_set = query::UserRoleQuery::effective_user_permissions_from_user_id( - &state.database, + let target_user_permission_set = entities::UserRoleQuery::transitive_permissions_for_user( + &mut database_connection, target_user_id, ) - .await - .map_err(APIError::InternalError)?; + .await?; let permission_names = target_user_permission_set - .into_permissions() + .permission_names() .into_iter() - .map(|permission| permission.name().to_string()) + .map(|name| name.to_string()) .collect(); @@ -265,6 +281,7 @@ async fn get_specific_user_effective_permissions( .into_response()) } +// TODO Continue from here. /// Update a user's display name @@ -283,7 +300,7 @@ async fn get_specific_user_effective_permissions( tag = "users", params( ( - "user_id" = i32, + "user_id" = Uuid, Path, description = "User ID." ) @@ -331,25 +348,30 @@ async fn get_specific_user_effective_permissions( async fn update_specific_user_display_name( state: ApplicationState, authentication_extractor: UserAuthenticationExtractor, - path_info: web::Path<(i32,)>, + path_info: web::Path<(Uuid,)>, json_data: web::Json, ) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + + // Only authenticated users with the `user.any:write` permission can modify // others' display names. Intended for moderation tooling. let authenticated_user = require_authentication!(authentication_extractor); - let authenticated_user_id = authenticated_user.user_id(); require_permission!( - state, + &mut transaction, authenticated_user, Permission::UserAnyWrite ); - // Disallow modifying your own account on these `/{user_id}/*` endpoints. - let target_user_id = path_info.into_inner().0; + let authenticated_user_id = authenticated_user.user_id(); + let target_user_id = UserId::new(path_info.into_inner().0); + + // Disallow modifying your own account on these `/{user_id}/*` endpoints. if authenticated_user_id == target_user_id { - return Ok(error_response_with_reason!( + return Ok(json_error_response_with_reason!( StatusCode::FORBIDDEN, "Can't modify your own account on this endpoint." )); @@ -357,30 +379,25 @@ async fn update_specific_user_display_name( let target_user_exists = - query::UserQuery::user_exists_by_user_id(&state.database, target_user_id) - .await - .map_err(APIError::InternalError)?; + entities::UserQuery::exists_by_id(&mut transaction, target_user_id).await?; if !target_user_exists { return Err(APIError::not_found_with_reason("no such user")); } - let json_data = json_data.into_inner(); - let database_transaction = - begin_transaction!(&state.database).map_err(APIError::InternalError)?; + let change_request_data = json_data.into_inner(); // Modify requested user's display name. - let display_name_already_exists = query::UserQuery::user_exists_by_display_name( - &database_transaction, - &json_data.new_display_name, + let display_name_already_exists = entities::UserQuery::exists_by_display_name( + &mut transaction, + &change_request_data.new_display_name, ) - .await - .map_err(APIError::InternalError)?; + .await?; if display_name_already_exists { - return Ok(error_response_with_reason!( + return Ok(json_error_response_with_reason!( StatusCode::CONFLICT, "User with given display name already exists." )); @@ -388,37 +405,26 @@ async fn update_specific_user_display_name( // Update requested user's display name. - mutation::UserMutation::update_display_name_by_user_id( - &database_transaction, - target_user_id, - json_data.new_display_name.clone(), - ) - .await - .map_err(APIError::InternalError)?; - - let updated_user = mutation::UserMutation::update_last_active_at_by_user_id( - &database_transaction, + let updated_user = entities::UserMutation::change_display_name_by_user_id( + &mut transaction, target_user_id, - None, + &change_request_data.new_display_name, ) - .await - .map_err(APIError::InternalError)?; + .await?; - database_transaction - .commit() - .await - .map_err(APIError::InternalDatabaseError)?; + transaction.commit().await?; info!( - operator_id = authenticated_user_id, - target_user_id = target_user_id, - new_display_name = json_data.new_display_name, + operator_id = %authenticated_user_id, + target_user_id = %target_user_id, + new_display_name = %change_request_data.new_display_name, "User has updated another user's display name." ); + Ok(UserDisplayNameChangeResponse { - user: UserInformation::from_user_model(updated_user), + user: updated_user.into_api_model(), } .into_response()) } @@ -455,7 +461,7 @@ pub struct UserRoleAddRequest { tag = "users", params( ( - "user_id" = i32, + "user_id" = Uuid, Path, description = "ID of the user to add roles to." ) @@ -508,64 +514,63 @@ pub struct UserRoleAddRequest { pub async fn add_roles_to_specific_user( state: ApplicationState, authentication: UserAuthenticationExtractor, - path_info: web::Path<(i32,)>, + path_info: web::Path<(Uuid,)>, json_data: web::Json, ) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + + // Only authenticated users with the `user.any:write` permission can add roles // to other users, but only if they also have that role. // Intended for moderation tooling. let authenticated_user = require_authentication!(authentication); - let authenticated_user_id = authenticated_user.user_id(); - let authenticated_user_roles = authenticated_user - .roles(&state.database) - .await - .map_err(APIError::InternalError)?; + let authenticated_user_roles = authenticated_user.fetch_roles(&mut transaction).await?; + let authenticated_user_permissions = authenticated_user_roles.granted_permission_set(); require_permission!( - state, - authenticated_user, + authenticated_user_permissions, Permission::UserAnyWrite ); - let target_user_id = path_info.into_inner().0; + let authenticated_user_id = authenticated_user.user_id(); + let target_user_id = UserId::new(path_info.into_inner().0); + let request_data = json_data.into_inner(); // Disallow modifying your own user account on this endpoint. if authenticated_user_id == target_user_id { - return Ok(error_response_with_reason!( + return Ok(json_error_response_with_reason!( StatusCode::FORBIDDEN, "Can't modify your own account on this endpoint." )); } - let parsed_roles_to_add_result = request_data - .roles_to_add - .into_iter() - .map(|role_name| { - Role::from_name(&role_name).ok_or_else(|| format!("No such role: \"{role_name}\".")) - }) - .collect::, _>>(); - - let roles_to_add = match parsed_roles_to_add_result { - Ok(roles) => roles, - Err(error_reason) => { - return Ok(error_response_with_reason!( + let mut roles_to_add_to_user = HashSet::with_capacity(request_data.roles_to_add.len()); + + for raw_role_name in request_data.roles_to_add { + let Some(role) = Role::from_name(&raw_role_name) else { + return Ok(json_error_response_with_reason!( StatusCode::BAD_REQUEST, - error_reason + format!("{} is an invalid role name", raw_role_name) )); - } - }; + }; + + roles_to_add_to_user.insert(role); + } + + let roles_to_add_to_user = RoleSet::from_role_hash_set(roles_to_add_to_user); // Validate that the authenticated user has all of the roles // they wish to assign to other users. Not checking for this would // be dangerous as it would essentially allow for privilege escalation. - for role in roles_to_add.iter() { + for role in roles_to_add_to_user.roles() { if !authenticated_user_roles.has_role(role) { - return Ok(error_response_with_reason!( + return Ok(json_error_response_with_reason!( StatusCode::FORBIDDEN, format!( "You cannot give out roles you do not have (missing role: {}).", @@ -576,9 +581,8 @@ pub async fn add_roles_to_specific_user( } - let user_exists = query::UserQuery::user_exists_by_user_id(&state.database, target_user_id) - .await - .map_err(APIError::InternalError)?; + + let user_exists = entities::UserQuery::exists_by_id(&mut transaction, target_user_id).await?; if !user_exists { return Err(APIError::not_found_with_reason( @@ -587,17 +591,19 @@ pub async fn add_roles_to_specific_user( } - mutation::UserRoleMutation::add_roles_to_user(&state.database, target_user_id, &roles_to_add) - .await - .map_err(APIError::InternalError)?; + let full_updated_user_role_set = entities::UserRoleMutation::add_roles_to_user( + &mut transaction, + target_user_id, + roles_to_add_to_user, + ) + .await?; + - let updated_role_set = query::UserRoleQuery::user_roles(&state.database, target_user_id) - .await - .map_err(APIError::InternalError)?; + transaction.commit().await?; Ok(UserRolesResponse { - role_names: updated_role_set.role_names(), + role_names: full_updated_user_role_set.role_names(), } .into_response()) } @@ -634,7 +640,7 @@ pub struct UserRoleRemoveRequest { tag = "users", params( ( - "user_id" = i32, + "user_id" = Uuid, Path, description = "ID of the user to remove roles from." ) @@ -687,64 +693,63 @@ pub struct UserRoleRemoveRequest { pub async fn remove_roles_from_specific_user( state: ApplicationState, authentication: UserAuthenticationExtractor, - path_info: web::Path<(i32,)>, + path_info: web::Path<(Uuid,)>, json_data: web::Json, ) -> EndpointResult { + let mut database_connection = obtain_database_connection!(state); + let mut transaction = database_connection.begin().await?; + + // Only authenticated users with the `user.any:write` permission can remove roles // from other users, but only if they also have that role. // Intended for moderation tooling. let authenticated_user = require_authentication!(authentication); - let authenticated_user_id = authenticated_user.user_id(); - let authenticated_user_roles = authenticated_user - .roles(&state.database) - .await - .map_err(APIError::InternalError)?; + let authenticated_user_roles = authenticated_user.fetch_roles(&mut transaction).await?; + let authenticated_user_permissions = authenticated_user_roles.granted_permission_set(); require_permission!( - state, - authenticated_user, + authenticated_user_permissions, Permission::UserAnyWrite ); - let target_user_id = path_info.into_inner().0; + let authenticated_user_id = authenticated_user.user_id(); + let target_user_id = UserId::new(path_info.into_inner().0); + let request_data = json_data.into_inner(); // Disallow modifying your own user account on this endpoint. if authenticated_user_id == target_user_id { - return Ok(error_response_with_reason!( + return Ok(json_error_response_with_reason!( StatusCode::FORBIDDEN, "Can't modify your own account on this endpoint." )); } - let parsed_roles_to_remove_result = request_data - .roles_to_remove - .into_iter() - .map(|role_name| { - Role::from_name(&role_name).ok_or_else(|| format!("No such role: \"{role_name}\".")) - }) - .collect::, _>>(); - - let roles_to_remove = match parsed_roles_to_remove_result { - Ok(roles) => roles, - Err(error_reason) => { - return Ok(error_response_with_reason!( + let mut roles_to_remove_from_user = HashSet::with_capacity(request_data.roles_to_remove.len()); + + for raw_role_name in request_data.roles_to_remove { + let Some(role) = Role::from_name(&raw_role_name) else { + return Ok(json_error_response_with_reason!( StatusCode::BAD_REQUEST, - error_reason + format!("{} is an invalid role name", raw_role_name) )); - } - }; + }; + + roles_to_remove_from_user.insert(role); + } + + let roles_to_remove_from_user = RoleSet::from_role_hash_set(roles_to_remove_from_user); // Validate that the authenticated user (caller) has all of the roles // they wish to remove from the target user. Not checking for this would // be dangerous as it would essentially allow for privilege de-escalation. - for role in roles_to_remove.iter() { + for role in roles_to_remove_from_user.roles() { if !authenticated_user_roles.has_role(role) { - return Ok(error_response_with_reason!( + return Ok(json_error_response_with_reason!( StatusCode::FORBIDDEN, format!( "You cannot remove others' roles which you do not have (missing role: {}).", @@ -755,9 +760,7 @@ pub async fn remove_roles_from_specific_user( } - let user_exists = query::UserQuery::user_exists_by_user_id(&state.database, target_user_id) - .await - .map_err(APIError::InternalError)?; + let user_exists = entities::UserQuery::exists_by_id(&mut transaction, target_user_id).await?; if !user_exists { return Err(APIError::not_found_with_reason( @@ -766,21 +769,16 @@ pub async fn remove_roles_from_specific_user( } - mutation::UserRoleMutation::remove_roles_from_user( - &state.database, + let full_updated_user_role_set = entities::UserRoleMutation::remove_roles_from_user( + &mut transaction, target_user_id, - &roles_to_remove, + roles_to_remove_from_user, ) - .await - .map_err(APIError::InternalError)?; - - let updated_role_set = query::UserRoleQuery::user_roles(&state.database, target_user_id) - .await - .map_err(APIError::InternalError)?; + .await?; Ok(UserRolesResponse { - role_names: updated_role_set.role_names(), + role_names: full_updated_user_role_set.role_names(), } .into_response()) } diff --git a/kolomoni/src/authentication.rs b/kolomoni/src/authentication.rs index bbab333..5bc921e 100644 --- a/kolomoni/src/authentication.rs +++ b/kolomoni/src/authentication.rs @@ -9,14 +9,16 @@ use actix_web::{FromRequest, HttpRequest}; use chrono::{DateTime, Utc}; use kolomoni_auth::{JWTClaims, JWTValidationError, RoleSet, BLANKET_PERMISSION_GRANT}; use kolomoni_auth::{Permission, PermissionSet}; -use kolomoni_database::query::UserRoleQuery; -use miette::{Context, Result}; -use sea_orm::ConnectionTrait; +use kolomoni_core::id::UserId; +use kolomoni_database::{entities, QueryError}; +use sqlx::PgConnection; +use thiserror::Error; use tracing::{debug, error, info}; use crate::state::ApplicationStateInner; + /// User authentication extractor. /// /// **Holding this struct doesn't automatically mean the user is authenticated!** @@ -72,7 +74,7 @@ impl FromRequest for UserAuthenticationExtractor { let jwt_manager = match req.app_data::>() { Some(app_state) => &app_state.jwt_manager, None => { - error!("BUG: No AppState injected, all UserAuth extractors will fail!"); + error!("BUG: No AppState injected, all `UserAuthenticationExtractor`s will fail!"); return future::err( actix_web::error::InternalError::new( @@ -89,6 +91,7 @@ impl FromRequest for UserAuthenticationExtractor { Err(_) => return future::err(actix_web::error::ParseError::Header.into()), }; + // Strip Bearer prefix if !header_value.starts_with("Bearer ") { return future::err(actix_web::error::ParseError::Header.into()); @@ -96,15 +99,17 @@ impl FromRequest for UserAuthenticationExtractor { let token_string = header_value .strip_prefix("Bearer ") + // PANIC SAFETY: We just checked that the value starts with "Bearer ". .expect("BUG: String started with \"Bearer \", but couldn't strip prefix."); + let token = match jwt_manager.decode_token(token_string) { Ok(token) => token, Err(error) => { return match error { - JWTValidationError::Expired(token) => { + JWTValidationError::Expired { expired_token } => { debug!( - user_id = token.user_id, + user_id = %expired_token.user_id, "User tried authenticating with expired token." ); @@ -112,9 +117,9 @@ impl FromRequest for UserAuthenticationExtractor { "Authentication token expired.", )) } - JWTValidationError::InvalidToken(error) => { + JWTValidationError::InvalidToken { reason } => { info!( - error = error, + reason = %reason, "User tried authenticating with invalid token." ); @@ -135,6 +140,17 @@ impl FromRequest for UserAuthenticationExtractor { + +#[derive(Debug, Error)] +pub enum AuthenticatedUserError { + #[error("database error")] + QueryError { + #[from] + #[source] + error: QueryError, + }, +} + /// An authenticated user with a valid JWT token. pub struct AuthenticatedUser { token: JWTClaims, @@ -155,7 +171,7 @@ impl AuthenticatedUser { } /// Returns the ID of the user who owns the token. - pub fn user_id(&self) -> i32 { + pub fn user_id(&self) -> UserId { self.token.user_id } @@ -168,40 +184,52 @@ impl AuthenticatedUser { /// /// Prefer using [`Self::has_permission`] if you'll be checking for a single permission, /// and this method if you're checking for multiple or doing advanced permission logic. - pub async fn permissions(&self, database: &C) -> Result { - let permission_set = - UserRoleQuery::effective_user_permissions_from_user_id(database, self.token.user_id) - .await - .wrap_err("Could not query effective permissions for user.")?; - - Ok(permission_set) + pub async fn fetch_transitive_permissions( + &self, + database_connection: &mut PgConnection, + ) -> Result { + let effective_permission_set = entities::UserRoleQuery::transitive_permissions_for_user( + database_connection, + self.token.user_id, + ) + .await?; + + Ok(effective_permission_set) } - /// Returns a boolean indicating whether the authenticated user has the provided permission. + /// Returns a boolean indicating whether the authenticated user has the provided permission, + /// obtained from any of the granted roles. /// /// This operation performs a database lookup. - pub async fn has_permission( + pub async fn transitively_has_permission( &self, - database: &C, + database_connection: &mut PgConnection, permission: Permission, - ) -> Result { + ) -> Result { if BLANKET_PERMISSION_GRANT.contains(&permission) { return Ok(true); } - UserRoleQuery::user_has_permission(database, self.token.user_id, permission) - .await - .wrap_err("Could not query whether the user has a specific permission.") + let has_permission = entities::UserRoleQuery::user_has_permission_transitively( + database_connection, + self.token.user_id, + permission, + ) + .await?; + + Ok(has_permission) } /// Returns a list of roles the user has. /// /// This operation performs a database lookup. - pub async fn roles(&self, database: &C) -> Result { - let role_set = UserRoleQuery::user_roles(database, self.token.user_id) - .await - .wrap_err("Could not query roles for user.")?; + pub async fn fetch_roles( + &self, + database_connection: &mut PgConnection, + ) -> Result { + let user_role_set = + entities::UserRoleQuery::roles_for_user(database_connection, self.token.user_id).await?; - Ok(role_set) + Ok(user_role_set) } } diff --git a/kolomoni/src/cli.rs b/kolomoni/src/cli.rs index e1fe0fe..8e92e32 100644 --- a/kolomoni/src/cli.rs +++ b/kolomoni/src/cli.rs @@ -22,4 +22,11 @@ pub struct CLIArgs { help = "Path to the configuration file to use. Defaults to ./data/configuration.toml" )] pub configuration_file_path: Option, + + #[arg( + long = "apply-pending-migrations", + action = ArgAction::SetTrue, + help = "On startup, apply any pending database migrations." + )] + pub apply_pending_migrations: bool, } diff --git a/kolomoni/src/lib.rs b/kolomoni/src/lib.rs index 82c272c..960329e 100644 --- a/kolomoni/src/lib.rs +++ b/kolomoni/src/lib.rs @@ -1,5 +1,7 @@ //! Stari Kolomoni backend API project. //! +//! TODO This needs an update. +//! //! # Workspace structure //! - [`kolomoni`][crate] *(this crate)* --- provides the entire API surface, //! with [`actix_web`] as the server software. @@ -61,13 +63,37 @@ //! ``` //! -use itertools::Itertools; -use kolomoni_configuration::Configuration; -use kolomoni_migrations::{Migrator, MigratorTrait}; -use miette::{Context, IntoDiagnostic, Result}; -use sea_orm::{Database, DatabaseConnection}; -use sea_orm_migration::MigrationStatus; -use tracing::info; + +// TODO -- things to do, in rough order: -- +// TODO migrate to the new database structure, which will remove and add some new endpoints +// TODO refactor actix extractors/data into a better structure +// TODO refactor non-library things out of this crate into kolomoni_core (including API request/response models?) +// TODO refactor how state is updated locally, so it can be more general than just for the search crate +// TODO rework search crate with either a deep-dive into tantivy or by removing tantivy and using manual similarity metrics +// TODO rework the kolomoni_sample_data to be rust, and to ingest the Google Sheets document for seeding data +// TODO migrate tests to new database structure +// TODO for clarity, create two directories: `crates` and `binaries`, where workspaces crates will be categorized +// (e.g. `kolomoni` + `kolomoni_openapi` can go in `binaries`) +// TODO review documentation, especially top-level crate docs (+ check for cargo doc warnings) +// TODO review CI +// TODO review makefile +// TODO review unused dependencies + +use std::time::Duration; + +use kolomoni_configuration::DatabaseConfiguration; +use kolomoni_migrations::core::{ + errors::{MigrationApplyError, StatusError}, + migrations::MigrationsWithStatusOptions, + MigrationStatus, +}; +use miette::Result; +use sqlx::{ + postgres::{PgConnectOptions, PgPoolOptions}, + PgConnection, + PgPool, +}; +use thiserror::Error; pub mod api; @@ -79,72 +105,81 @@ pub mod state; #[cfg(feature = "with_test_facilities")] pub mod testing; -pub async fn apply_pending_migrations(database_connection: &DatabaseConnection) -> Result<()> { - let migrations_status = Migrator::get_migration_with_status(database_connection) - .await - .into_diagnostic() - .wrap_err("Failed to check current database migration status.")?; - let pending_migrations = migrations_status +#[derive(Debug, Error)] +pub enum PendingMigrationApplyError { + #[error("failed to retrieve database migration status")] + StatusError( + #[from] + #[source] + StatusError, + ), + + #[error("failed to apply migration")] + MigrationApplyError( + #[from] + #[source] + MigrationApplyError, + ), +} + +// TODO needs logging +pub async fn apply_pending_migrations( + database_connection: &mut PgConnection, +) -> Result<(), PendingMigrationApplyError> { + let manager = kolomoni_migrations::migrations::manager(); + + let migrations = manager + .migrations_with_status( + database_connection, + MigrationsWithStatusOptions::strict(), + ) + .await?; + + let pending_migrations = migrations .into_iter() - .filter(|migration| migration.status() == MigrationStatus::Pending) + .filter(|migration| migration.status() == &MigrationStatus::Pending) .collect::>(); + if pending_migrations.is_empty() { - info!("No pending database migrations."); return Ok(()); } - let num_pending_migrations = pending_migrations.len(); - let pending_migration_names = pending_migrations - .into_iter() - .map(|migration| migration.name().to_string()) - .join(", "); - - info!( - "There are {} pending migrations: {}", - num_pending_migrations, pending_migration_names - ); - - - info!("Applying migrations."); - Migrator::up(database_connection, None) - .await - .into_diagnostic() - .wrap_err("Could not apply database migration.")?; - info!("Migrations applied."); + for pending_migration in pending_migrations { + pending_migration.execute_up(database_connection).await?; + } Ok(()) } +pub async fn establish_database_connection_pool( + database_configuration: &DatabaseConfiguration, +) -> Result { + let mut connection_options = PgConnectOptions::new_without_pgpass() + .application_name(&format!( + "stari-kolomoni-backend-api_v{}", + env!("CARGO_PKG_VERSION") + )) + .statement_cache_capacity(200) + .host(&database_configuration.host) + .port(database_configuration.port) + .username(&database_configuration.username) + .database(&database_configuration.database_name); + + if let Some(password) = &database_configuration.password { + connection_options = connection_options.password(password.as_str()); + } -pub async fn connect_and_set_up_database_with_full_url( - database_url: String, -) -> Result { - let database = Database::connect(database_url) - .await - .into_diagnostic() - .wrap_err("Could not initialize connection to PostgreSQL database.")?; - - info!("Database connection established."); - - apply_pending_migrations(&database).await?; - Ok(database) -} - -/// Connect to PostgreSQL database as specified in the configuration file -/// and apply any pending migrations. -pub async fn connect_and_set_up_database(config: &Configuration) -> Result { - connect_and_set_up_database_with_full_url(format!( - "postgres://{}:{}@{}:{}/{}", - config.database.username, - config.database.password, - config.database.host, - config.database.port, - config.database.database_name, - )) - .await + PgPoolOptions::new() + .idle_timeout(Some(Duration::from_secs(60 * 20))) + .max_lifetime(Some(Duration::from_secs(60 * 60))) + .min_connections(1) + .max_connections(10) + .test_before_acquire(true) + .connect_with(connection_options) + .await } diff --git a/kolomoni/src/state.rs b/kolomoni/src/state.rs index 243cf7e..d92923d 100644 --- a/kolomoni/src/state.rs +++ b/kolomoni/src/state.rs @@ -1,17 +1,19 @@ //! Application-wide state (shared between endpoint functions). use actix_web::web::Data; -use kolomoni_auth::JsonWebTokenManager; +use kolomoni_auth::{ArgonHasher, JsonWebTokenManager}; use kolomoni_configuration::Configuration; use kolomoni_database::mutation::ArgonHasher; use kolomoni_search::{ChangeEvent, KolomoniSearchEngine, SearchResults}; use miette::{Context, IntoDiagnostic, Result}; use sea_orm::{prelude::Uuid, DatabaseConnection}; +use sqlx::PgPool; use tokio::sync::mpsc; use crate::connect_and_set_up_database; +// TODO needs to be reworked to be more general, then connect search into it, or maybe even setup this whole thing to be decoupled by using db triggers or something /// A dictionary search engine. /// /// Handles searching, seeding and incrementally updating the internal index and cache. @@ -136,8 +138,8 @@ pub struct ApplicationStateInner { /// Password hasher helper struct. pub hasher: ArgonHasher, - /// PostgreSQL database connection. - pub database: DatabaseConnection, + /// PostgreSQL database connection pool. + pub database: PgPool, /// Authentication token manager (JSON Web Token). pub jwt_manager: JsonWebTokenManager, diff --git a/kolomoni/src/testing.rs b/kolomoni/src/testing.rs index 8eeb8d5..539206e 100644 --- a/kolomoni/src/testing.rs +++ b/kolomoni/src/testing.rs @@ -37,7 +37,7 @@ pub async fn reset_server(state: ApplicationState) -> EndpointResult { drop_database_and_reapply_migrations(&state.database) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; Ok(HttpResponse::Ok().finish()) } @@ -66,7 +66,7 @@ pub async fn give_full_permissions_to_user( &[Role::User, Role::Administrator], ) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; Ok(HttpResponse::Ok().finish()) } @@ -91,7 +91,7 @@ pub async fn reset_user_roles_to_starting_user_roles( let previous_role_set = query::UserRoleQuery::user_roles(&state.database, target_user_id) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; mutation::UserRoleMutation::remove_roles_from_user( &state.database, @@ -102,7 +102,7 @@ pub async fn reset_user_roles_to_starting_user_roles( .collect::>(), ) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; mutation::UserRoleMutation::add_roles_to_user( &state.database, @@ -110,7 +110,7 @@ pub async fn reset_user_roles_to_starting_user_roles( &[DEFAULT_USER_ROLE], ) .await - .map_err(APIError::InternalError)?; + .map_err(APIError::InternalGenericError)?; Ok(HttpResponse::Ok().finish()) diff --git a/kolomoni_auth/Cargo.toml b/kolomoni_auth/Cargo.toml index 588bc58..9484418 100644 --- a/kolomoni_auth/Cargo.toml +++ b/kolomoni_auth/Cargo.toml @@ -7,6 +7,9 @@ publish = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [dependencies] +kolomoni_core = { path = "../kolomoni_core" } + + tokio = { workspace = true } tracing = { workspace = true } @@ -21,6 +24,7 @@ thiserror = { workspace = true } chrono = { workspace = true } +uuid = { workspace = true } jsonwebtoken = { workspace = true } argon2 = { workspace = true } diff --git a/kolomoni_auth/src/permissions.rs b/kolomoni_auth/src/permissions.rs index fabc219..a4aa68a 100644 --- a/kolomoni_auth/src/permissions.rs +++ b/kolomoni_auth/src/permissions.rs @@ -3,6 +3,7 @@ use std::collections::HashSet; use miette::{miette, Result}; use serde::{Deserialize, Serialize}; +// TODO Make sure this and roles are synced with the database migrations. /// Permissions that we have (inspired by the scope system in OAuth). /// @@ -61,6 +62,9 @@ pub enum Permission { #[serde(rename = "category:create")] CategoryCreate, + #[serde(rename = "category:read")] + CategoryRead, + #[serde(rename = "category:update")] CategoryUpdate, @@ -72,21 +76,22 @@ pub enum Permission { impl Permission { pub fn from_id(internal_permission_id: i32) -> Option { match internal_permission_id { - 1 => Some(Permission::UserSelfRead), - 2 => Some(Permission::UserSelfWrite), - 3 => Some(Permission::UserAnyRead), - 4 => Some(Permission::UserAnyWrite), - 5 => Some(Permission::WordCreate), - 6 => Some(Permission::WordRead), - 7 => Some(Permission::WordUpdate), - 8 => Some(Permission::WordDelete), - 9 => Some(Permission::SuggestionCreate), - 10 => Some(Permission::SuggestionDelete), - 11 => Some(Permission::TranslationCreate), - 12 => Some(Permission::TranslationDelete), - 13 => Some(Permission::CategoryCreate), - 14 => Some(Permission::CategoryUpdate), - 15 => Some(Permission::CategoryDelete), + 1 => Some(Self::UserSelfRead), + 2 => Some(Self::UserSelfWrite), + 3 => Some(Self::UserAnyRead), + 4 => Some(Self::UserAnyWrite), + 5 => Some(Self::WordCreate), + 6 => Some(Self::WordRead), + 7 => Some(Self::WordUpdate), + 8 => Some(Self::WordDelete), + 9 => Some(Self::SuggestionCreate), + 10 => Some(Self::SuggestionDelete), + 11 => Some(Self::TranslationCreate), + 12 => Some(Self::TranslationDelete), + 13 => Some(Self::CategoryCreate), + 14 => Some(Self::CategoryRead), + 15 => Some(Self::CategoryUpdate), + 16 => Some(Self::CategoryDelete), _ => None, } } @@ -95,21 +100,22 @@ impl Permission { /// This ID is used primarily in the database and should not be visible externally. pub fn id(&self) -> i32 { match self { - Permission::UserSelfRead => 1, - Permission::UserSelfWrite => 2, - Permission::UserAnyRead => 3, - Permission::UserAnyWrite => 4, - Permission::WordCreate => 5, - Permission::WordRead => 6, - Permission::WordUpdate => 7, - Permission::WordDelete => 8, - Permission::SuggestionCreate => 9, - Permission::SuggestionDelete => 10, - Permission::TranslationCreate => 11, - Permission::TranslationDelete => 12, - Permission::CategoryCreate => 13, - Permission::CategoryUpdate => 14, - Permission::CategoryDelete => 15, + Self::UserSelfRead => 1, + Self::UserSelfWrite => 2, + Self::UserAnyRead => 3, + Self::UserAnyWrite => 4, + Self::WordCreate => 5, + Self::WordRead => 6, + Self::WordUpdate => 7, + Self::WordDelete => 8, + Self::SuggestionCreate => 9, + Self::SuggestionDelete => 10, + Self::TranslationCreate => 11, + Self::TranslationDelete => 12, + Self::CategoryCreate => 13, + Self::CategoryRead => 14, + Self::CategoryUpdate => 15, + Self::CategoryDelete => 16, } } @@ -129,6 +135,7 @@ impl Permission { "word.translation:create" => Some(Self::TranslationCreate), "word.translation:delete" => Some(Self::TranslationDelete), "category:create" => Some(Self::CategoryCreate), + "category:read" => Some(Self::CategoryRead), "category:update" => Some(Self::CategoryUpdate), "category:delete" => Some(Self::CategoryDelete), _ => None, @@ -138,67 +145,59 @@ impl Permission { /// Get the name of the given [`Permission`]. pub fn name(&self) -> &'static str { match self { - Permission::UserSelfRead => "user.self:read", - Permission::UserSelfWrite => "user.self:write", - Permission::UserAnyRead => "user.any:read", - Permission::UserAnyWrite => "user.any:write", - Permission::WordCreate => "word:create", - Permission::WordRead => "word:read", - Permission::WordUpdate => "word:update", - Permission::WordDelete => "word:delete", - Permission::SuggestionCreate => "word.suggestion:create", - Permission::SuggestionDelete => "word.suggestion:delete", - Permission::TranslationCreate => "word.translation:create", - Permission::TranslationDelete => "word.translation:delete", - Permission::CategoryCreate => "category:create", - Permission::CategoryUpdate => "category:update", - Permission::CategoryDelete => "category:delete", + Self::UserSelfRead => "user.self:read", + Self::UserSelfWrite => "user.self:write", + Self::UserAnyRead => "user.any:read", + Self::UserAnyWrite => "user.any:write", + Self::WordCreate => "word:create", + Self::WordRead => "word:read", + Self::WordUpdate => "word:update", + Self::WordDelete => "word:delete", + Self::SuggestionCreate => "word.suggestion:create", + Self::SuggestionDelete => "word.suggestion:delete", + Self::TranslationCreate => "word.translation:create", + Self::TranslationDelete => "word.translation:delete", + Self::CategoryCreate => "category:create", + Self::CategoryRead => "category:read", + Self::CategoryUpdate => "category:update", + Self::CategoryDelete => "category:delete", } } /// Get the description of the given [`Permission`]. - #[rustfmt::skip] pub fn description(&self) -> &'static str { match self { - Permission::UserSelfRead => - "Allows the user to log in and view their account information.", - Permission::UserSelfWrite => - "Allows the user to update their account information.", - Permission::UserAnyRead => - "Allows the user to view public account information of any other user.", - Permission::UserAnyWrite => - "Allows the user to update account information of any other user.", - Permission::WordCreate => - "Allows the user to create words in the dictionary.", - Permission::WordRead => - "Allows the user to read words in the dictionary.", - Permission::WordUpdate => - "Allows the user to update existing words in the dictionary (but not delete them).", - Permission::WordDelete => - "Allows the user to delete words from the dictionary.", - Permission::SuggestionCreate => - "Allows the user to create a translation suggestion.", - Permission::SuggestionDelete => - "Allows the user to remove a translation suggestion.", - Permission::TranslationCreate => - "Allows the user to translate a word.", - Permission::TranslationDelete => - "Allows the user to remove a word translation.", - Permission::CategoryCreate => - "Allows the user to create a word category.", - Permission::CategoryUpdate => - "Allows the user to update an existing word category.", - Permission::CategoryDelete => - "Allows the user to delete a word category.", - + Self::UserSelfRead => "Allows the user to log in and view their account information.", + Self::UserSelfWrite => "Allows the user to update their account information.", + Self::UserAnyRead => { + "Allows the user to view public account information of any other user." + } + Self::UserAnyWrite => "Allows the user to update account information of any other user.", + Self::WordCreate => "Allows the user to create words in the dictionary.", + Self::WordRead => "Allows the user to read words in the dictionary.", + Self::WordUpdate => { + "Allows the user to update existing words in the dictionary (but not delete them)." + } + Self::WordDelete => "Allows the user to delete words from the dictionary.", + Self::SuggestionCreate => "Allows the user to create a translation suggestion.", + Self::SuggestionDelete => "Allows the user to remove a translation suggestion.", + Self::TranslationCreate => "Allows the user to translate a word.", + Self::TranslationDelete => "Allows the user to remove a word translation.", + Self::CategoryCreate => "Allows the user to create a word category.", + Self::CategoryRead => "Allows the user to read categories.", + Self::CategoryUpdate => "Allows the user to update an existing word category.", + Self::CategoryDelete => "Allows the user to delete a word category.", } } } /// List of permissions that are given to **ANY API CALLER**, /// authenticated or not. -pub const BLANKET_PERMISSION_GRANT: [Permission; 2] = - [Permission::WordRead, Permission::UserAnyRead]; +pub const BLANKET_PERMISSION_GRANT: [Permission; 3] = [ + Permission::WordRead, + Permission::UserAnyRead, + Permission::CategoryRead, +]; diff --git a/kolomoni_auth/src/roles.rs b/kolomoni_auth/src/roles.rs index b36149f..e11cf53 100644 --- a/kolomoni_auth/src/roles.rs +++ b/kolomoni_auth/src/roles.rs @@ -2,7 +2,7 @@ use std::collections::HashSet; use serde::{Deserialize, Serialize}; -use crate::Permission; +use crate::{Permission, PermissionSet}; /// User roles that we have. @@ -128,6 +128,16 @@ impl RoleSet { self.roles.contains(role) } + pub fn granted_permission_set(&self) -> PermissionSet { + let mut permission_hash_set = HashSet::new(); + + for role in self.roles.iter() { + permission_hash_set.extend(role.permissions_granted()); + } + + PermissionSet::from_permission_hash_set(permission_hash_set) + } + /// Consumes the [`RoleSet`] and returns a raw [`HashSet`] of [`Role`]s. pub fn into_roles(self) -> HashSet { self.roles diff --git a/kolomoni_auth/src/token.rs b/kolomoni_auth/src/token.rs index b2ea4c8..db2d1b2 100644 --- a/kolomoni_auth/src/token.rs +++ b/kolomoni_auth/src/token.rs @@ -1,9 +1,10 @@ +use std::borrow::Cow; use std::ops::Add; use chrono::{DateTime, Duration, SubsecRound, Utc}; use jsonwebtoken::errors::ErrorKind; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation}; -use miette::{Context, IntoDiagnostic, Result}; +use kolomoni_core::id::UserId; use serde::{Deserialize, Serialize}; use serde_with::serde_as; use serde_with::TimestampSeconds; @@ -24,10 +25,10 @@ const JWT_SUBJECT: &str = "API token"; #[derive(Error, Debug)] pub enum JWTValidationError { #[error("token has expired")] - Expired(JWTClaims), + Expired { expired_token: JWTClaims }, - #[error("token is invalid: `{0}`")] - InvalidToken(String), + #[error("token is invalid: {}", .reason)] + InvalidToken { reason: Cow<'static, str> }, } @@ -75,16 +76,15 @@ pub struct JWTClaims { #[serde_as(as = "TimestampSeconds")] pub exp: DateTime, - /// JWT private claim: Internal user ID - /// - /// Internal ID the user was given upon registration. - pub user_id: i32, + /// JWT private claim: UUIDv7 of the user the token belongs to. + pub user_id: UserId, /// JWT private claim: Token type (access or refresh token) /// - /// Access tokens can be used to call restricted endpoints and - /// refresh tokens can be used to generate new access tokens when they - /// expire (refresh tokens have a longer expiration time). + /// *Access tokens* can be used to call restricted endpoints. + /// + /// *Refresh tokens* can be used to generate new access tokens when they + /// expire (refresh tokens have a longer expiration time compared to access tokens). pub token_type: JWTTokenType, } @@ -94,7 +94,7 @@ impl JWTClaims { /// Note that the `issued_at` timestamp will have its sub-second content truncated /// (see [`trunc_subsecs`][chrono::round::SubsecRound::trunc_subsecs]). pub fn create( - user_id: i32, + user_id: UserId, issued_at: DateTime, valid_for: Duration, token_type: JWTTokenType, @@ -114,6 +114,18 @@ impl JWTClaims { } + +#[derive(Debug, Error)] +pub enum JWTCreationError { + #[error("JWT error")] + JWTError { + #[from] + #[source] + error: jsonwebtoken::errors::Error, + }, +} + + /// JSON Web Token manager --- encoder and decoder. pub struct JsonWebTokenManager { /// Token header. @@ -155,35 +167,40 @@ impl JsonWebTokenManager { } /// Create (encode) a new token. Returns a string with the encoded content. - pub fn create_token(&self, claims: JWTClaims) -> Result { + pub fn create_token(&self, claims: JWTClaims) -> Result { jsonwebtoken::encode(&self.header, &claims, &self.encoding_key) - .into_diagnostic() - .wrap_err("Failed to create JWT token.") + .map_err(|error| JWTCreationError::JWTError { error }) } /// Decode a JSON Web Token from a string. pub fn decode_token(&self, token: &str) -> Result { - let token_data = - jsonwebtoken::decode::(token, &self.decoding_key, &self.validation) - .map_err(|err| match err.kind() { - ErrorKind::InvalidIssuer => "Invalid token: invalid issuer.".to_string(), - ErrorKind::InvalidSubject => "Invalid token: invalid subject.".to_string(), - _ => format!("Errored while decoding token: {err}."), - }) - .map_err(JWTValidationError::InvalidToken)?; + let token_data = jsonwebtoken::decode::( + token, + &self.decoding_key, + &self.validation, + ) + .map_err(|error| JWTValidationError::InvalidToken { + reason: match error.kind() { + ErrorKind::InvalidIssuer => Cow::from("failed to parse JWT token: invalid issuer"), + ErrorKind::InvalidSubject => Cow::from("failed to parse JWT token: invalid subject"), + _ => Cow::from(format!("failed to parse JWT token: {}", error)), + }, + })?; let current_time = Utc::now(); // Validate issued at (if `iat` is in the future, this token is broken) if token_data.claims.iat > current_time { - return Err(JWTValidationError::InvalidToken( - "Invalid token: `iat` field is in the future!".to_string(), - )); + return Err(JWTValidationError::InvalidToken { + reason: Cow::from("invalid JWT token: issued-at field is in the future"), + }); } // Validate expiry time (if `exp` is in the past, it has expired) if token_data.claims.exp <= current_time { - return Err(JWTValidationError::Expired(token_data.claims)); + return Err(JWTValidationError::Expired { + expired_token: token_data.claims, + }); } Ok(token_data.claims) @@ -194,6 +211,7 @@ impl JsonWebTokenManager { #[cfg(test)] mod test { use chrono::SubsecRound; + use uuid::Uuid; use super::*; @@ -204,7 +222,14 @@ mod test { let issued_at = Utc::now().trunc_subsecs(0); let valid_for = chrono::Duration::from_std(std::time::Duration::from_secs(60)).unwrap(); - let claims = JWTClaims::create(1, issued_at, valid_for, JWTTokenType::Access); + let user_id = UserId::new(Uuid::now_v7()); + + let claims = JWTClaims::create( + user_id, + issued_at, + valid_for, + JWTTokenType::Access, + ); let encoded_token = manager.create_token(claims).unwrap(); @@ -215,7 +240,7 @@ mod test { assert_eq!(decoded_claims.sub, JWT_SUBJECT); assert_eq!(decoded_claims.iat, issued_at); assert_eq!(decoded_claims.exp, issued_at + valid_for); - assert_eq!(decoded_claims.user_id, 1); + assert_eq!(decoded_claims.user_id, user_id); assert_eq!(decoded_claims.token_type, JWTTokenType::Access); } } diff --git a/kolomoni_configuration/src/lib.rs b/kolomoni_configuration/src/lib.rs index db5c8f4..36503a9 100644 --- a/kolomoni_configuration/src/lib.rs +++ b/kolomoni_configuration/src/lib.rs @@ -19,6 +19,8 @@ //! additional configuration validation in [`resolve`][traits::ResolvableConfiguration::resolve], //! e.g. raising an error if some specified file path doesn't actually exist. +// TODO need to upgrade traits (two types, try resolve and normal resolve) + #![allow(rustdoc::private_intra_doc_links)] mod structure; diff --git a/kolomoni_configuration/src/structure/database.rs b/kolomoni_configuration/src/structure/database.rs index 921907c..0760372 100644 --- a/kolomoni_configuration/src/structure/database.rs +++ b/kolomoni_configuration/src/structure/database.rs @@ -12,13 +12,13 @@ pub struct DatabaseConfiguration { pub host: String, /// Port the database is listening at. - pub port: usize, + pub port: u16, /// Login username. pub username: String, /// Login password. - pub password: String, + pub password: Option, /// Database name. pub database_name: String, diff --git a/kolomoni_core/Cargo.toml b/kolomoni_core/Cargo.toml index eb44645..9c9b9c7 100644 --- a/kolomoni_core/Cargo.toml +++ b/kolomoni_core/Cargo.toml @@ -10,3 +10,12 @@ chrono = { workspace = true } serde = { workspace = true } serde_with = { workspace = true } serde_json = { workspace = true } + +utoipa = { workspace = true } + + +[features] +# Implements Deserialize on API response models and Serialize on +# API request models, allowing us to write e.g. test code that +# handles both constructing API requests and parsing API responses. +more_serde_impls = [] diff --git a/kolomoni_core/src/api_models/dictionary.rs b/kolomoni_core/src/api_models/dictionary.rs new file mode 100644 index 0000000..69c196d --- /dev/null +++ b/kolomoni_core/src/api_models/dictionary.rs @@ -0,0 +1,19 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::id::CategoryId; + + +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] +pub struct Category { + pub id: CategoryId, + + pub slovene_name: String, + + pub english_name: String, + + pub created_at: DateTime, + + pub last_modified_at: DateTime, +} diff --git a/kolomoni_core/src/api_models/mod.rs b/kolomoni_core/src/api_models/mod.rs new file mode 100644 index 0000000..f5d6fa7 --- /dev/null +++ b/kolomoni_core/src/api_models/mod.rs @@ -0,0 +1,4 @@ +mod users; +pub use users::*; +mod dictionary; +pub use dictionary::*; diff --git a/kolomoni_core/src/api_models/users.rs b/kolomoni_core/src/api_models/users.rs new file mode 100644 index 0000000..d1708bf --- /dev/null +++ b/kolomoni_core/src/api_models/users.rs @@ -0,0 +1,291 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::id::UserId; + + + +/// User login information. +#[derive(Deserialize, PartialEq, Eq, Debug, ToSchema)] +#[cfg_attr(feature = "more_serde_impls", derive(Serialize))] +#[schema( + example = json!({ + "username": "sample_user", + "password": "verysecurepassword" + }) +)] +pub struct UserLoginRequest { + /// Username to log in as. + pub username: String, + + /// Password. + pub password: String, +} + + +/// Information with which to refresh a user's login, generating a new access token. +#[derive(Deserialize, ToSchema)] +#[schema( + example = json!({ + "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdGFyaSBLb2xvbW9uaSIsInN\ + 1YiI6IkFQSSB0b2tlbiIsImlhdCI6MTY4Nzk3MTMyMiwiZXhwIjoxNjg4NTc2MTIyLCJ1c2V\ + ybmFtZSI6InRlc3QiLCJ0b2tlbl90eXBlIjoicmVmcmVzaCJ9.Ze6DI5EZ-swXRQrMW3NIpp\ + YejclGbyI9D6zmYBWJMLk" + }) +)] +pub struct UserLoginRefreshRequest { + /// Refresh token to use to generate an access token. + /// + /// Token must not have expired to work. + pub refresh_token: String, +} + + +/// Response on successful login refresh. +#[derive(Serialize, Debug, ToSchema)] +#[schema( + example = json!({ + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdGFyaSBLb2xvbW9uaSIsInN1\ + YiI6IkFQSSB0b2tlbiIsImlhdCI6MTY4Nzk3MTMyMiwiZXhwIjoxNjg4MDU3NzIyLCJ1c2Vyb\ + mFtZSI6InRlc3QiLCJ0b2tlbl90eXBlIjoiYWNjZXNzIn0.ZnuhEVacQD_pYzkW9h6aX3eoRN\ + OAs2-y3EngGBglxkk" + }) +)] +pub struct UserLoginRefreshResponse { + /// Newly-generated access token to use in future requests. + pub access_token: String, +} + + + +/// Response on successful user login. +/// +/// Contains two tokens: +/// - the `access_token` that should be appended to future requests and +/// - the `refresh_token` that can be used on `POST /api/v1/users/login/refresh` to +/// receive a new (fresh) request token. +/// +/// This works because the `refresh_token` has a longer expiration time. +#[derive(Serialize, PartialEq, Eq, Debug, ToSchema)] +#[cfg_attr(feature = "more_serde_impls", derive(serde::Deserialize))] +#[schema( + example = json!({ + "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdGFyaSBLb2xvbW9uaSIsInN1Y\ + iI6IkFQSSB0b2tlbiIsImlhdCI6MTY4Nzk3MTMyMiwiZXhwIjoxNjg4MDU3NzIyLCJ1c2VybmF\ + tZSI6InRlc3QiLCJ0b2tlbl90eXBlIjoiYWNjZXNzIn0.ZnuhEVacQD_pYzkW9h6aX3eoRNOAs\ + 2-y3EngGBglxkk", + "refresh_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJpc3MiOiJTdGFyaSBLb2xvbW9uaSIsInN1\ + YiI6IkFQSSB0b2tlbiIsImlhdCI6MTY4Nzk3MTMyMiwiZXhwIjoxNjg4NTc2MTIyLCJ1c2Vyb\ + mFtZSI6InRlc3QiLCJ0b2tlbl90eXBlIjoicmVmcmVzaCJ9.Ze6DI5EZ-swXRQrMW3NIppYej\ + clGbyI9D6zmYBWJMLk" + }) +)] +pub struct UserLoginResponse { + /// JWT access token. + /// Provide in subsequent requests in the `Authorization` header as `Bearer your_token_here`. + pub access_token: String, + + /// JWT refresh token. + pub refresh_token: String, +} + +/// Information about a single user. +/// +/// This struct is used as part of a response in the public API. +/// +/// TODO needs updated example +#[derive(Serialize, PartialEq, Eq, Clone, Debug, ToSchema)] +#[cfg_attr(feature = "more_serde_impls", derive(Deserialize))] +#[schema(example = json!({ + "id": 1, + "username": "janeznovak", + "display_name": "Janez Novak", + "joined_at": "2023-06-27T20:33:53.078789Z", + "last_modified_at": "2023-06-27T20:34:27.217273Z", + "last_active_at": "2023-06-27T20:34:27.253746Z" +}))] +pub struct UserInfo { + /// Internal user ID. + pub id: UserId, + + /// Unique username for login. + pub username: String, + + /// Unique display name. + pub display_name: String, + + /// Registration date and time. + pub joined_at: DateTime, + + /// Last modification date and time. + pub last_modified_at: DateTime, + + /// Last activity date and time. + pub last_active_at: DateTime, +} + + + +/// Information about one user in particular. +/// +/// This struct is used as a response in the public API. +#[derive(Serialize, PartialEq, Eq, Debug, ToSchema)] +#[cfg_attr(feature = "more_serde_impls", derive(Deserialize))] +#[schema(example = json!({ + "user": { + "id": 1, + "username": "janeznovak", + "display_name": "Janez Novak", + "joined_at": "2023-06-27T20:33:53.078789Z", + "last_modified_at": "2023-06-27T20:34:27.217273Z", + "last_active_at": "2023-06-27T20:34:27.253746Z" + } +}))] +pub struct UserInfoResponse { + pub user: UserInfo, +} + +/* +impl UserInfoResponse { + pub fn new(model: entities::user::Model) -> Self { + Self { + user: UserInformation::from_user_model(model), + } + } +} +*/ + + +/// User (API caller) request to change a user's display name. +/// +/// This struct is used as a request in the public API. +#[derive(Deserialize, PartialEq, Eq, Clone, Debug, ToSchema)] +#[cfg_attr(feature = "more_serde_impls", derive(Serialize))] +#[schema( + example = json!({ + "new_display_name": "Janez Novak Veliki" + }) +)] +pub struct UserDisplayNameChangeRequest { + /// Display name to change to. + pub new_display_name: String, +} + + + +/// Response indicating successful change of a display name. +/// Contains the updated user information. +/// +/// This struct is used as a response in the public API. +#[derive(Serialize, PartialEq, Eq, Clone, Debug, ToSchema)] +#[cfg_attr(feature = "more_serde_impls", derive(Deserialize))] +pub struct UserDisplayNameChangeResponse { + pub user: UserInfo, +} + + +#[derive(Serialize, PartialEq, Eq, Debug, ToSchema)] +#[cfg_attr(feature = "more_serde_impls", derive(Deserialize))] +#[schema( + example = json!({ + "role_names": [ + "user", + "administrator" + ] + }) +)] + + + +pub struct UserRolesResponse { + pub role_names: Vec, +} +/// Response containing a list of active permissions. +/// +/// This struct is used as a response in the public API. +#[derive(Serialize, PartialEq, Eq, Debug, ToSchema)] +#[cfg_attr(feature = "more_serde_impls", derive(Deserialize))] +#[schema( + example = json!({ + "permissions": [ + "user.self:read", + "user.self:write", + "user.any:read" + ] + }) +)] +pub struct UserPermissionsResponse { + pub permissions: Vec, +} + +impl UserPermissionsResponse { + pub fn from_permission_names(permission_names: Vec) -> Self { + Self { + permissions: permission_names, + } + } +} + + + +/// List of registered users. +#[derive(Serialize, PartialEq, Eq, Debug, ToSchema)] +#[cfg_attr(feature = "more_serde_impls", derive(serde::Deserialize))] +#[schema(example = json!({ + "users": [ + { + "id": 1, + "username": "janeznovak", + "display_name": "Janez Novak", + "joined_at": "2023-06-27T20:33:53.078789Z", + "last_modified_at": "2023-06-27T20:34:27.217273Z", + "last_active_at": "2023-06-27T20:34:27.253746Z" + }, + ] +}))] +pub struct RegisteredUsersListResponse { + pub users: Vec, +} + + + +/// User registration request provided by an API caller. +#[derive(Deserialize, Clone, Debug, ToSchema)] +#[schema(example = json!({ + "username": "janeznovak", + "display_name": "Janez Novak", + "password": "perica_reže_raci_rep" +}))] +#[cfg_attr(feature = "more_serde_impls", derive(serde::Serialize))] +pub struct UserRegistrationRequest { + /// Username to register as (not the same as the display name). + pub username: String, + + /// Name to display as in the UI. + pub display_name: String, + + /// Password for this user account. + pub password: String, +} + + +/// API-serializable response upon successful user registration. +/// Contains the newly-created user's information. +#[derive(Serialize, PartialEq, Eq, Debug, ToSchema)] +#[cfg_attr(feature = "more_serde_impls", derive(serde::Deserialize))] +#[schema( + example = json!({ + "user": { + "id": 1, + "username": "janeznovak", + "display_name": "Janez Novak", + "joined_at": "2023-06-27T20:33:53.078789Z", + "last_modified_at": "2023-06-27T20:34:27.217273Z", + "last_active_at": "2023-06-27T20:34:27.253746Z" + } + }) +)] +pub struct UserRegistrationResponse { + pub user: UserInfo, +} diff --git a/kolomoni_core/src/id.rs b/kolomoni_core/src/id.rs index d436230..7034a5f 100644 --- a/kolomoni_core/src/id.rs +++ b/kolomoni_core/src/id.rs @@ -1,33 +1,34 @@ +use serde::{Deserialize, Serialize}; use uuid::Uuid; -macro_rules! impl_ser_de_for_newtype_struct { - ($struct_type:ty, $inner_type:ty) => { - impl serde::Serialize for $struct_type { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - self.0.serialize(serializer) +macro_rules! impl_uuid_display_for_newtype_struct { + ($struct_type:ty) => { + impl std::fmt::Display for $struct_type { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + uuid::fmt::Simple::from_uuid(self.0).fmt(f) } } + }; +} - impl<'de> serde::Deserialize<'de> for $struct_type { - fn deserialize(deserializer: D) -> Result - where - D: serde::Deserializer<'de>, - { - Ok(Self( - <$inner_type as serde::Deserialize>::deserialize(deserializer)?, - )) +macro_rules! impl_transparent_display_for_newtype_struct { + ($struct_type:ty) => { + impl std::fmt::Display for $struct_type { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.fmt(f) } } }; } + + #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct CategoryId(pub(crate) Uuid); +#[derive(Serialize, Deserialize)] +#[serde(transparent)] +pub struct CategoryId(#[serde(with = "uuid::serde::simple")] pub(crate) Uuid); impl CategoryId { #[inline] @@ -36,17 +37,24 @@ impl CategoryId { } #[inline] - pub fn into_inner(self) -> Uuid { + pub fn generate() -> Self { + Self(Uuid::now_v7()) + } + + #[inline] + pub fn into_uuid(self) -> Uuid { self.0 } } -impl_ser_de_for_newtype_struct!(CategoryId, Uuid); +impl_uuid_display_for_newtype_struct!(CategoryId); #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct EditId(pub(crate) Uuid); +#[derive(Serialize, Deserialize)] +#[serde(transparent)] +pub struct EditId(#[serde(with = "uuid::serde::simple")] pub(crate) Uuid); impl EditId { #[inline] @@ -60,11 +68,13 @@ impl EditId { } } -impl_ser_de_for_newtype_struct!(EditId, Uuid); +impl_uuid_display_for_newtype_struct!(EditId); #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] +#[derive(Serialize, Deserialize)] +#[serde(transparent)] pub struct PermissionId(pub(crate) i32); impl PermissionId { @@ -79,12 +89,14 @@ impl PermissionId { } } -impl_ser_de_for_newtype_struct!(PermissionId, i32); +impl_transparent_display_for_newtype_struct!(PermissionId); #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct RoleId(i32); +#[derive(Serialize, Deserialize)] +#[serde(transparent)] +pub struct RoleId(pub(crate) i32); impl RoleId { #[inline] @@ -98,74 +110,91 @@ impl RoleId { } } -impl_ser_de_for_newtype_struct!(RoleId, i32); +impl_transparent_display_for_newtype_struct!(RoleId); + #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct UserId(Uuid); +#[derive(Serialize, Deserialize)] +#[serde(transparent)] +pub struct UserId(#[serde(with = "uuid::serde::simple")] pub(crate) Uuid); impl UserId { #[inline] - pub fn new(uuid: Uuid) -> Self { + pub const fn new(uuid: Uuid) -> Self { Self(uuid) } #[inline] - pub fn into_inner(self) -> Uuid { + pub const fn into_uuid(self) -> Uuid { self.0 } } -impl_ser_de_for_newtype_struct!(UserId, Uuid); +impl_uuid_display_for_newtype_struct!(UserId); + #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct WordId(pub(crate) Uuid); +#[derive(Serialize, Deserialize)] +#[serde(transparent)] +pub struct WordId(#[serde(with = "uuid::serde::simple")] pub(crate) Uuid); impl WordId { #[inline] - pub fn new(uuid: Uuid) -> Self { + pub const fn new(uuid: Uuid) -> Self { Self(uuid) } #[inline] - pub fn into_inner(self) -> Uuid { + pub const fn into_inner(self) -> Uuid { self.0 } } -impl_ser_de_for_newtype_struct!(WordId, Uuid); +impl_uuid_display_for_newtype_struct!(WordId); + #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct EnglishWordId(Uuid); +#[derive(Serialize, Deserialize)] +#[serde(transparent)] +pub struct EnglishWordId(#[serde(with = "uuid::serde::simple")] pub(crate) Uuid); impl EnglishWordId { #[inline] - pub fn new(uuid: Uuid) -> Self { + pub const fn new(uuid: Uuid) -> Self { Self(uuid) } + #[inline] + pub fn generate() -> Self { + Self(Uuid::now_v7()) + } + #[inline] pub fn into_word_id(self) -> WordId { WordId::new(self.0) } #[inline] - pub fn into_inner(self) -> Uuid { + pub fn into_uuid(self) -> Uuid { self.0 } } -impl_ser_de_for_newtype_struct!(EnglishWordId, Uuid); +impl_uuid_display_for_newtype_struct!(EnglishWordId); + #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct SloveneWordId(Uuid); +#[derive(Serialize, Deserialize)] +#[serde(transparent)] +pub struct SloveneWordId(#[serde(with = "uuid::serde::simple")] pub(crate) Uuid); impl SloveneWordId { #[inline] @@ -173,23 +202,31 @@ impl SloveneWordId { Self(uuid) } + #[inline] + pub fn generate() -> Self { + Self(Uuid::now_v7()) + } + #[inline] pub fn into_word_id(self) -> WordId { WordId::new(self.0) } #[inline] - pub fn into_inner(self) -> Uuid { + pub fn into_uuid(self) -> Uuid { self.0 } } -impl_ser_de_for_newtype_struct!(SloveneWordId, Uuid); +impl_uuid_display_for_newtype_struct!(SloveneWordId); + #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct WordMeaningId(Uuid); +#[derive(Serialize, Deserialize)] +#[serde(transparent)] +pub struct WordMeaningId(#[serde(with = "uuid::serde::simple")] pub(crate) Uuid); impl WordMeaningId { #[inline] @@ -203,12 +240,15 @@ impl WordMeaningId { } } -impl_ser_de_for_newtype_struct!(WordMeaningId, Uuid); +impl_uuid_display_for_newtype_struct!(WordMeaningId); + #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct EnglishWordMeaningId(Uuid); +#[derive(Serialize, Deserialize)] +#[serde(transparent)] +pub struct EnglishWordMeaningId(#[serde(with = "uuid::serde::simple")] pub(crate) Uuid); impl EnglishWordMeaningId { #[inline] @@ -216,6 +256,11 @@ impl EnglishWordMeaningId { Self(uuid) } + #[inline] + pub fn generate() -> Self { + Self(Uuid::now_v7()) + } + #[inline] pub fn into_word_meaning_id(self) -> WordMeaningId { WordMeaningId::new(self.0) @@ -227,12 +272,15 @@ impl EnglishWordMeaningId { } } -impl_ser_de_for_newtype_struct!(EnglishWordMeaningId, Uuid); +impl_uuid_display_for_newtype_struct!(EnglishWordMeaningId); + #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)] -pub struct SloveneWordMeaningId(Uuid); +#[derive(Serialize, Deserialize)] +#[serde(transparent)] +pub struct SloveneWordMeaningId(#[serde(with = "uuid::serde::simple")] pub(crate) Uuid); impl SloveneWordMeaningId { #[inline] @@ -240,6 +288,11 @@ impl SloveneWordMeaningId { Self(uuid) } + #[inline] + pub fn generate() -> Self { + Self(Uuid::now_v7()) + } + #[inline] pub fn into_word_meaning_id(self) -> WordMeaningId { WordMeaningId::new(self.0) @@ -251,4 +304,4 @@ impl SloveneWordMeaningId { } } -impl_ser_de_for_newtype_struct!(SloveneWordMeaningId, Uuid); +impl_uuid_display_for_newtype_struct!(SloveneWordMeaningId); diff --git a/kolomoni_core/src/lib.rs b/kolomoni_core/src/lib.rs index 6066513..9d1ffc9 100644 --- a/kolomoni_core/src/lib.rs +++ b/kolomoni_core/src/lib.rs @@ -1,2 +1,3 @@ +pub mod api_models; pub mod edit; pub mod id; diff --git a/kolomoni_database/Cargo.toml b/kolomoni_database/Cargo.toml index 3fcd3d0..70d6c3d 100644 --- a/kolomoni_database/Cargo.toml +++ b/kolomoni_database/Cargo.toml @@ -17,6 +17,7 @@ serde_json = { workspace = true } sqlx = { workspace = true } thiserror = { workspace = true } +tracing = { workspace = true } argon2 = { workspace = true } diff --git a/kolomoni_database/src/entities/category/mod.rs b/kolomoni_database/src/entities/category/mod.rs index 8177ff8..1826ff6 100644 --- a/kolomoni_database/src/entities/category/mod.rs +++ b/kolomoni_database/src/entities/category/mod.rs @@ -1,2 +1,7 @@ mod model; +mod mutation; +mod query; + pub use model::*; +pub use mutation::*; +pub use query::*; diff --git a/kolomoni_database/src/entities/category/model.rs b/kolomoni_database/src/entities/category/model.rs index f2a01b9..fb09f58 100644 --- a/kolomoni_database/src/entities/category/model.rs +++ b/kolomoni_database/src/entities/category/model.rs @@ -2,10 +2,10 @@ use chrono::{DateTime, Utc}; use kolomoni_core::id::CategoryId; use uuid::Uuid; -use crate::IntoModel; +use crate::IntoExternalModel; -pub struct Model { +pub struct CategoryModel { pub id: CategoryId, pub parent_category_id: Option, @@ -20,28 +20,28 @@ pub struct Model { } -pub(super) struct IntermediateModel { - pub(super) id: Uuid, +pub struct InternalCategoryModel { + pub(crate) id: Uuid, - pub(super) parent_category_id: Option, + pub(crate) parent_category_id: Option, - pub(super) name_sl: String, + pub(crate) name_sl: String, - pub(super) name_en: String, + pub(crate) name_en: String, - pub(super) created_at: DateTime, + pub(crate) created_at: DateTime, - pub(super) last_modified_at: DateTime, + pub(crate) last_modified_at: DateTime, } -impl IntoModel for IntermediateModel { - type Model = Model; +impl IntoExternalModel for InternalCategoryModel { + type ExternalModel = CategoryModel; - fn into_model(self) -> Self::Model { + fn into_external_model(self) -> Self::ExternalModel { let id = CategoryId::new(self.id); let parent_category_id = self.parent_category_id.map(CategoryId::new); - Self::Model { + Self::ExternalModel { id, parent_category_id, slovene_name: self.name_sl, diff --git a/kolomoni_database/src/entities/category/mutation.rs b/kolomoni_database/src/entities/category/mutation.rs new file mode 100644 index 0000000..561447a --- /dev/null +++ b/kolomoni_database/src/entities/category/mutation.rs @@ -0,0 +1,149 @@ +use std::borrow::Cow; + +use chrono::Utc; +use kolomoni_core::id::CategoryId; +use sqlx::{PgConnection, Postgres, QueryBuilder}; + +use super::CategoryModel; +use crate::{IntoExternalModel, QueryError, QueryResult}; + + + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct NewCategory { + pub parent_category_id: Option, + pub slovene_name: String, + pub english_name: String, +} + + + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct CategoryValuesToUpdate { + pub parent_category_id: Option>, + pub slovene_name: Option, + pub english_name: Option, +} + +impl CategoryValuesToUpdate { + fn has_any_values_to_update(&self) -> bool { + self.parent_category_id.is_some() + || self.slovene_name.is_some() + || self.english_name.is_some() + } +} + + +fn build_category_update_query( + category_id: CategoryId, + values_to_update: CategoryValuesToUpdate, +) -> QueryBuilder<'static, Postgres> { + let mut update_query_builder = QueryBuilder::new("UPDATE kolomoni.category SET "); + + let mut separated_set_expressions = update_query_builder.separated(", "); + + if let Some(new_parent_category_id) = values_to_update.parent_category_id { + separated_set_expressions.push_unseparated("parent_category_id = "); + separated_set_expressions.push_bind(new_parent_category_id.map(|id| id.into_uuid())); + } + + if let Some(new_slovene_name) = values_to_update.slovene_name { + separated_set_expressions.push_unseparated("name_sl = "); + separated_set_expressions.push_bind(new_slovene_name); + } + + if let Some(new_english_name) = values_to_update.english_name { + separated_set_expressions.push_unseparated("name_en = "); + separated_set_expressions.push_bind(new_english_name); + } + + + update_query_builder.push(" WHERE id = "); + update_query_builder.push_bind(category_id.into_uuid()); + + update_query_builder +} + + + + +pub struct CategoryMutation; + +impl CategoryMutation { + pub async fn create( + database_connection: &mut PgConnection, + new_category: NewCategory, + ) -> QueryResult { + let new_category_id = CategoryId::generate(); + let new_category_created_at = Utc::now(); + let new_category_last_modified_at = new_category_created_at; + + let newly_created_category = sqlx::query_as!( + super::InternalCategoryModel, + "INSERT INTO kolomoni.category \ + (id, parent_category_id, name_sl, name_en, \ + created_at, last_modified_at) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + RETURNING \ + id, parent_category_id, name_sl, name_en, \ + created_at, last_modified_at", + new_category_id.into_uuid(), + new_category.parent_category_id.map(|id| id.into_uuid()), + new_category.slovene_name, + new_category.english_name, + new_category_created_at, + new_category_last_modified_at + ) + .fetch_one(database_connection) + .await?; + + Ok(newly_created_category.into_external_model()) + } + + + pub async fn update( + database_connection: &mut PgConnection, + category_id: CategoryId, + category_values_to_update: CategoryValuesToUpdate, + ) -> QueryResult { + if !category_values_to_update.has_any_values_to_update() { + return Ok(true); + } + + + let mut update_query_builder = + build_category_update_query(category_id, category_values_to_update); + + let query_result = update_query_builder + .build() + .execute(database_connection) + .await?; + + + Ok(query_result.rows_affected() == 1) + } + + pub async fn delete( + database_connection: &mut PgConnection, + category_id: CategoryId, + ) -> QueryResult { + let query_result = sqlx::query!( + "DELETE FROM kolomoni.category \ + WHERE id = $1", + category_id.into_uuid() + ) + .execute(database_connection) + .await?; + + + if query_result.rows_affected() > 1 { + return Err(QueryError::DatabaseInconsistencyError { + problem: Cow::from( + "attempted to delete a category by ID, but more than one row matched", + ), + }); + } + + Ok(query_result.rows_affected() == 1) + } +} diff --git a/kolomoni_database/src/entities/category/query.rs b/kolomoni_database/src/entities/category/query.rs new file mode 100644 index 0000000..e498ad6 --- /dev/null +++ b/kolomoni_database/src/entities/category/query.rs @@ -0,0 +1,110 @@ +use futures_core::stream::BoxStream; +use kolomoni_core::id::CategoryId; +use sqlx::PgConnection; + +use super::CategoryModel; +use crate::{IntoExternalModel, QueryError, QueryResult}; + +type RawCategoryStream<'c> = BoxStream<'c, Result>; + +create_async_stream_wrapper!( + pub struct CategoryStream<'c>; + transforms stream RawCategoryStream<'c> => stream of QueryResult: + |value| + value.map( + |some| some + .map(super::InternalCategoryModel::into_external_model) + .map_err(|error| QueryError::SqlxError { error }) + ) +); + + +pub struct CategoryQuery; + +impl CategoryQuery { + pub async fn get_all_categories(database_connection: &mut PgConnection) -> CategoryStream<'_> { + let internal_category_stream = sqlx::query_as!( + super::InternalCategoryModel, + "SELECT \ + id, parent_category_id, name_sl, name_en, \ + created_at, last_modified_at \ + FROM kolomoni.category", + ) + .fetch(database_connection); + + CategoryStream::new(internal_category_stream) + } + + pub async fn get_by_id( + database_connection: &mut PgConnection, + category_id: CategoryId, + ) -> QueryResult> { + let internal_category = sqlx::query_as!( + super::InternalCategoryModel, + "SELECT \ + id, parent_category_id, name_sl, name_en, \ + created_at, last_modified_at \ + FROM kolomoni.category \ + WHERE id = $1", + category_id.into_uuid() + ) + .fetch_optional(database_connection) + .await?; + + Ok(internal_category.map(|category| category.into_external_model())) + } + + pub async fn exists_by_id( + database_connection: &mut PgConnection, + category_id: CategoryId, + ) -> QueryResult { + let exists = sqlx::query_scalar!( + "SELECT EXISTS (\ + SELECT 1 \ + FROM kolomoni.category \ + WHERE id = $1 + )", + category_id.into_uuid() + ) + .fetch_one(database_connection) + .await?; + + Ok(exists.unwrap_or(false)) + } + + pub async fn exists_by_slovene_name( + database_connection: &mut PgConnection, + slovene_category_name: &str, + ) -> QueryResult { + let exists = sqlx::query_scalar!( + "SELECT EXISTS (\ + SELECT 1 \ + FROM kolomoni.category \ + WHERE name_sl = $1 + )", + slovene_category_name + ) + .fetch_one(database_connection) + .await?; + + Ok(exists.unwrap_or(false)) + } + + pub async fn exists_by_english_name( + database_connection: &mut PgConnection, + english_category_name: &str, + ) -> QueryResult { + let exists = sqlx::query_scalar!( + "SELECT EXISTS (\ + SELECT 1 \ + FROM kolomoni.category \ + WHERE name_en = $1 + )", + english_category_name + ) + .fetch_one(database_connection) + .await?; + + Ok(exists.unwrap_or(false)) + } +} diff --git a/kolomoni_database/src/entities/edit/mod.rs b/kolomoni_database/src/entities/edit/mod.rs index 8177ff8..4a7ebf6 100644 --- a/kolomoni_database/src/entities/edit/mod.rs +++ b/kolomoni_database/src/entities/edit/mod.rs @@ -1,2 +1,3 @@ mod model; + pub use model::*; diff --git a/kolomoni_database/src/entities/edit/model.rs b/kolomoni_database/src/entities/edit/model.rs index 9f9d86c..59d79fe 100644 --- a/kolomoni_database/src/entities/edit/model.rs +++ b/kolomoni_database/src/entities/edit/model.rs @@ -7,11 +7,11 @@ use kolomoni_core::{ }; use uuid::Uuid; -use crate::TryIntoModel; +use crate::TryIntoExternalModel; -pub struct Model { +pub struct EditModel { pub id: EditId, /// Contains certain duplicated information which we've decided *not* to @@ -30,23 +30,23 @@ pub struct Model { -pub struct IntermediateModel { - pub id: Uuid, +pub struct InternalEditModel { + pub(crate) id: Uuid, - pub data: serde_json::Value, + pub(crate) data: serde_json::Value, - pub data_schema_version: i32, + pub(crate) data_schema_version: i32, - pub performed_at: DateTime, + pub(crate) performed_at: DateTime, - pub performed_by: Uuid, + pub(crate) performed_by: Uuid, } -impl TryIntoModel for IntermediateModel { - type Model = Model; +impl TryIntoExternalModel for InternalEditModel { + type ExternalModel = EditModel; type Error = Cow<'static, str>; - fn try_into_model(self) -> Result { + fn try_into_external_model(self) -> Result { let id = EditId::new(self.id); let data = serde_json::from_value::(self.data).map_err(|error| { @@ -65,7 +65,7 @@ impl TryIntoModel for IntermediateModel { let performed_by = UserId::new(self.performed_by); - Ok(Self::Model { + Ok(Self::ExternalModel { id, data, data_schema_version, diff --git a/kolomoni_database/src/entities/mod.rs b/kolomoni_database/src/entities/mod.rs index 0bfeb42..bb0d2e7 100644 --- a/kolomoni_database/src/entities/mod.rs +++ b/kolomoni_database/src/entities/mod.rs @@ -1,15 +1,29 @@ -pub mod category; -pub mod edit; -pub mod permission; -pub mod role; -pub mod user; -pub mod user_role; -pub mod word; -pub mod word_english; -pub mod word_english_meaning; -pub mod word_meaning; -pub mod word_meaning_translation; -pub mod word_slovene; -pub mod word_slovene_meaning; +mod category; +mod edit; +mod permission; +mod role; +mod user; +mod user_role; +mod word; +mod word_english; +mod word_english_meaning; +mod word_meaning; +mod word_meaning_translation; +mod word_slovene; +mod word_slovene_meaning; -// TODO rename all model / query / mutation expots for individual sub-modules here. +// TODO refactor query, model and mutation names to no need renaming when re-exported + +pub use category::*; +pub use edit::*; +pub use permission::*; +pub use role::*; +pub use user::*; +pub use user_role::*; +pub use word::*; +pub use word_english::*; +pub use word_english_meaning::*; +pub use word_meaning::*; +pub use word_meaning_translation::*; +pub use word_slovene::*; +pub use word_slovene_meaning::*; diff --git a/kolomoni_database/src/entities/permission/model.rs b/kolomoni_database/src/entities/permission/model.rs index edbf303..3736943 100644 --- a/kolomoni_database/src/entities/permission/model.rs +++ b/kolomoni_database/src/entities/permission/model.rs @@ -1,6 +1,6 @@ use kolomoni_core::id::PermissionId; -pub struct FullModel { +pub struct PermissionModel { /// Internal ID of the permission, don't expose externally. pub id: PermissionId, @@ -10,12 +10,3 @@ pub struct FullModel { pub description_sl: String, } - -pub struct ReducedModel { - /// Internal ID of the permission, don't expose externally. - pub id: PermissionId, - - pub key: String, -} - -// TODO continue from here diff --git a/kolomoni_database/src/entities/role/mod.rs b/kolomoni_database/src/entities/role/mod.rs index 8177ff8..4a7ebf6 100644 --- a/kolomoni_database/src/entities/role/mod.rs +++ b/kolomoni_database/src/entities/role/mod.rs @@ -1,2 +1,3 @@ mod model; + pub use model::*; diff --git a/kolomoni_database/src/entities/role/model.rs b/kolomoni_database/src/entities/role/model.rs index 8f6d76f..0f5d15f 100644 --- a/kolomoni_database/src/entities/role/model.rs +++ b/kolomoni_database/src/entities/role/model.rs @@ -1,6 +1,6 @@ use kolomoni_core::id::RoleId; -pub struct FullModel { +pub struct RoleModel { pub id: RoleId, pub key: String, @@ -9,9 +9,3 @@ pub struct FullModel { pub description_sl: String, } - -pub struct ReducedModel { - pub id: RoleId, - - pub key: String, -} diff --git a/kolomoni_database/src/entities/user/mod.rs b/kolomoni_database/src/entities/user/mod.rs index 4253d6c..b4479c1 100644 --- a/kolomoni_database/src/entities/user/mod.rs +++ b/kolomoni_database/src/entities/user/mod.rs @@ -1,5 +1,52 @@ mod model; +mod mutation; mod query; +use std::borrow::Cow; + +use kolomoni_auth::ArgonHasherError; pub use model::*; +pub use mutation::*; pub use query::*; +use thiserror::Error; + +use crate::QueryError; + + + +#[derive(Debug, Error)] +pub enum UserQueryError { + #[error("sqlx error")] + SqlxError { + #[from] + #[source] + error: sqlx::Error, + }, + + #[error("model error: {}", .reason)] + ModelError { reason: Cow<'static, str> }, + + #[error("hasher error")] + HasherError { + #[from] + #[source] + error: ArgonHasherError, + }, + + #[error("database consistency error: {}", .reason)] + DatabaseConsistencyError { reason: Cow<'static, str> }, +} + +impl From for UserQueryError { + fn from(value: QueryError) -> Self { + match value { + QueryError::SqlxError { error } => Self::SqlxError { error }, + QueryError::ModelError { reason } => Self::ModelError { reason }, + QueryError::DatabaseInconsistencyError { problem: reason } => { + Self::DatabaseConsistencyError { reason } + } + } + } +} + +pub type UserQueryResult = Result; diff --git a/kolomoni_database/src/entities/user/model.rs b/kolomoni_database/src/entities/user/model.rs index e38aae7..a5fc4be 100644 --- a/kolomoni_database/src/entities/user/model.rs +++ b/kolomoni_database/src/entities/user/model.rs @@ -2,11 +2,11 @@ use chrono::{DateTime, Utc}; use kolomoni_core::id::UserId; use uuid::Uuid; -use crate::IntoModel; +use crate::{IntoExternalModel, IntoInternalModel}; -pub struct Model { +pub struct UserModel { /// UUIDv7 pub id: UserId, @@ -23,31 +23,48 @@ pub struct Model { pub last_active_at: DateTime, } +impl IntoInternalModel for UserModel { + type InternalModel = InternalUserModel; -pub(super) struct IntermediateModel { + fn into_internal_model(self) -> Self::InternalModel { + Self::InternalModel { + id: self.id.into_uuid(), + username: self.username, + display_name: self.display_name, + hashed_password: self.hashed_password, + joined_at: self.joined_at, + last_modified_at: self.last_modified_at, + last_active_at: self.last_active_at, + } + } +} + + + +pub struct InternalUserModel { /// UUIDv7 - pub id: Uuid, + pub(crate) id: Uuid, - pub username: String, + pub(crate) username: String, - pub display_name: String, + pub(crate) display_name: String, - pub hashed_password: String, + pub(crate) hashed_password: String, - pub joined_at: DateTime, + pub(crate) joined_at: DateTime, - pub last_modified_at: DateTime, + pub(crate) last_modified_at: DateTime, - pub last_active_at: DateTime, + pub(crate) last_active_at: DateTime, } -impl IntoModel for IntermediateModel { - type Model = Model; +impl IntoExternalModel for InternalUserModel { + type ExternalModel = UserModel; - fn into_model(self) -> Self::Model { + fn into_external_model(self) -> Self::ExternalModel { let user_id = UserId::new(self.id); - Self::Model { + Self::ExternalModel { id: user_id, username: self.username, display_name: self.display_name, diff --git a/kolomoni_database/src/entities/user/mutation.rs b/kolomoni_database/src/entities/user/mutation.rs new file mode 100644 index 0000000..3cb082f --- /dev/null +++ b/kolomoni_database/src/entities/user/mutation.rs @@ -0,0 +1,92 @@ +use chrono::Utc; +use kolomoni_auth::ArgonHasher; +use kolomoni_core::id::UserId; +use sqlx::PgConnection; +use uuid::Uuid; + +use super::UserQueryResult; +use crate::{IntoExternalModel, IntoInternalModel, QueryResult}; + + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct UserRegistrationInfo { + pub username: String, + pub display_name: String, + pub password: String, +} + + +pub struct UserMutation; + +impl UserMutation { + pub async fn create_user( + database_connection: &mut PgConnection, + hasher: &ArgonHasher, + user_registration_info: UserRegistrationInfo, + ) -> UserQueryResult { + let hashed_password = hasher.hash_password(&user_registration_info.password)?; + + let user_uuid = Uuid::now_v7(); + let registration_time = Utc::now(); + + + let user_model = super::UserModel { + id: UserId::new(user_uuid), + username: user_registration_info.username, + display_name: user_registration_info.display_name, + hashed_password: hashed_password.to_string(), + joined_at: registration_time, + last_active_at: registration_time, + last_modified_at: registration_time, + } + .into_internal_model(); + + + let newly_created_user = sqlx::query_as!( + super::InternalUserModel, + "INSERT INTO kolomoni.user \ + (id, username, display_name, hashed_password, \ + joined_at, last_active_at, last_modified_at) \ + VALUES ($1, $2, $3, $4, $5, $6, $7) \ + RETURNING \ + id, username, display_name, hashed_password, \ + joined_at, last_active_at, last_modified_at", + user_model.id, + user_model.username, + user_model.display_name, + user_model.hashed_password, + user_model.joined_at, + user_model.last_active_at, + user_model.last_modified_at, + ) + .fetch_one(database_connection) + .await?; + + Ok(newly_created_user.into_external_model()) + } + + pub async fn change_display_name_by_user_id( + database_connection: &mut PgConnection, + user_id: UserId, + new_display_name: &str, + ) -> QueryResult { + let updated_user_model = sqlx::query_as!( + super::InternalUserModel, + "UPDATE kolomoni.user \ + SET \ + display_name = $1, \ + last_modified_at = $2 \ + WHERE id = $3 \ + RETURNING \ + id, username, display_name, hashed_password, \ + joined_at, last_active_at, last_modified_at", + new_display_name, + Utc::now(), + user_id.into_uuid() + ) + .fetch_one(database_connection) + .await?; + + Ok(updated_user_model.into_external_model()) + } +} diff --git a/kolomoni_database/src/entities/user/query.rs b/kolomoni_database/src/entities/user/query.rs index ff98b0a..b855628 100644 --- a/kolomoni_database/src/entities/user/query.rs +++ b/kolomoni_database/src/entities/user/query.rs @@ -1,52 +1,23 @@ -use std::borrow::Cow; - use futures_core::stream::BoxStream; -use kolomoni_auth::{ArgonHasher, ArgonHasherError}; +use kolomoni_auth::ArgonHasher; use kolomoni_core::id::UserId; use sqlx::PgConnection; -use thiserror::Error; - -use crate::{IntoModel, QueryError, QueryResult}; - -#[derive(Debug, Error)] -pub enum UserCredentialValidationError { - #[error("sqlx error")] - SqlxError { - #[source] - error: sqlx::Error, - }, +use super::{UserQueryError, UserQueryResult}; +use crate::{IntoExternalModel, QueryError, QueryResult}; - #[error("model error: {}", .reason)] - ModelError { reason: Cow<'static, str> }, - - #[error("hasher error")] - HasherError { - #[source] - error: ArgonHasherError, - }, -} - -impl From for UserCredentialValidationError { - fn from(value: QueryError) -> Self { - match value { - QueryError::SqlxError { error } => Self::SqlxError { error }, - QueryError::ModelError { reason } => Self::ModelError { reason }, - } - } -} -type RawUserStream<'c> = BoxStream<'c, Result>; +type RawUserStream<'c> = BoxStream<'c, Result>; create_async_stream_wrapper!( pub struct UserStream<'c>; - transforms stream RawUserStream<'c> => stream of QueryResult: + transforms stream RawUserStream<'c> => stream of QueryResult: |value| value.map(|result| { result - .map(super::IntermediateModel::into_model) + .map(super::InternalUserModel::into_external_model) .map_err(|error| QueryError::SqlxError { error }) }) ); @@ -54,37 +25,37 @@ create_async_stream_wrapper!( -pub struct Query; +pub struct UserQuery; -impl Query { +impl UserQuery { pub async fn get_user_by_id( connection: &mut PgConnection, user_id: UserId, - ) -> QueryResult> { + ) -> QueryResult> { let optional_intermediate_model = sqlx::query_as!( - super::IntermediateModel, + super::InternalUserModel, "SELECT \ id, username, display_name, hashed_password, \ joined_at, last_modified_at, last_active_at \ FROM kolomoni.user \ WHERE id = $1", - user_id.into_inner() + user_id.into_uuid() ) .fetch_optional(connection) .await?; - Ok(optional_intermediate_model.map(super::IntermediateModel::into_model)) + Ok(optional_intermediate_model.map(super::InternalUserModel::into_external_model)) } pub async fn get_user_by_username( connection: &mut PgConnection, username: U, - ) -> QueryResult> + ) -> QueryResult> where U: AsRef, { let optional_intermediate_model = sqlx::query_as!( - super::IntermediateModel, + super::InternalUserModel, "SELECT \ id, username, display_name, hashed_password, \ joined_at, last_modified_at, last_active_at \ @@ -95,13 +66,13 @@ impl Query { .fetch_optional(connection) .await?; - Ok(optional_intermediate_model.map(super::IntermediateModel::into_model)) + Ok(optional_intermediate_model.map(super::InternalUserModel::into_external_model)) } pub async fn exists_by_id(connection: &mut PgConnection, user_id: UserId) -> QueryResult { sqlx::query_scalar!( "SELECT EXISTS (SELECT 1 FROM kolomoni.user WHERE id = $1)", - user_id.into_inner() + user_id.into_uuid() ) .fetch_one(connection) .await @@ -148,7 +119,7 @@ impl Query { hasher: &ArgonHasher, username: U, password: P, - ) -> QueryResult, UserCredentialValidationError> + ) -> UserQueryResult> where U: AsRef, P: AsRef, @@ -161,7 +132,7 @@ impl Query { let is_valid_password = hasher .verify_password_against_hash(password.as_ref(), &user.hashed_password) - .map_err(|error| UserCredentialValidationError::HasherError { error })?; + .map_err(|error| UserQueryError::HasherError { error })?; if is_valid_password { @@ -173,7 +144,7 @@ impl Query { pub fn get_all_users(connection: &mut PgConnection) -> UserStream<'_> { let user_stream = sqlx::query_as!( - super::IntermediateModel, + super::InternalUserModel, "SELECT \ id, username, display_name, hashed_password, \ joined_at, last_modified_at, last_active_at \ diff --git a/kolomoni_database/src/entities/user_role/mod.rs b/kolomoni_database/src/entities/user_role/mod.rs index 4253d6c..1826ff6 100644 --- a/kolomoni_database/src/entities/user_role/mod.rs +++ b/kolomoni_database/src/entities/user_role/mod.rs @@ -1,5 +1,7 @@ mod model; +mod mutation; mod query; pub use model::*; +pub use mutation::*; pub use query::*; diff --git a/kolomoni_database/src/entities/user_role/model.rs b/kolomoni_database/src/entities/user_role/model.rs index 3ed018f..e267b4e 100644 --- a/kolomoni_database/src/entities/user_role/model.rs +++ b/kolomoni_database/src/entities/user_role/model.rs @@ -2,15 +2,15 @@ use kolomoni_core::id::{RoleId, UserId}; use uuid::Uuid; -pub struct Model { +pub struct UserRoleModel { pub user_id: UserId, pub role_id: RoleId, } #[allow(dead_code)] -pub(super) struct IntermediateModel { - pub(super) user_id: Uuid, +pub struct InternalUserRoleModel { + pub(crate) user_id: Uuid, - pub(super) role_id: i32, + pub(crate) role_id: i32, } diff --git a/kolomoni_database/src/entities/user_role/mutation.rs b/kolomoni_database/src/entities/user_role/mutation.rs new file mode 100644 index 0000000..c91062c --- /dev/null +++ b/kolomoni_database/src/entities/user_role/mutation.rs @@ -0,0 +1,132 @@ +use std::collections::HashSet; + +use kolomoni_auth::{Role, RoleSet}; +use kolomoni_core::id::UserId; +use sqlx::PgConnection; + +use crate::{QueryError, QueryResult}; + +pub struct UserRoleMutation; + +impl UserRoleMutation { + /// Gives the specified user a set of roles. If the user + /// already had one or more of the specified roles, nothing bad happens + /// (those are ignored). + /// + /// Returns a full updated set of roles the user has. + pub async fn add_roles_to_user( + database_connection: &mut PgConnection, + user_id: UserId, + roles_to_add: RoleSet, + ) -> QueryResult { + struct SelectedRoleId { + role_id: i32, + } + + let role_ids_nested = roles_to_add + .into_roles() + .into_iter() + .map(|role| role.id()) + .collect::>(); + + let user_ids_nested = std::iter::repeat(user_id.into_uuid()) + .take(role_ids_nested.len()) + .collect::>(); + + let updated_full_user_role_set = sqlx::query_as!( + SelectedRoleId, + "INSERT INTO kolomoni.user_role (user_id, role_id) \ + SELECT * FROM UNNEST($1::uuid[], $2::integer[]) \ + ON CONFLICT DO NOTHING \ + RETURNING \ + (SELECT DISTINCT role_id \ + FROM kolomoni.user_role \ + WHERE user_id = $3) as \"role_id!\"", + user_ids_nested.as_slice(), + role_ids_nested.as_slice(), + user_id.into_uuid() + ) + .fetch_all(database_connection) + .await?; + + if updated_full_user_role_set.is_empty() { + return Ok(RoleSet::new_empty()); + } + + + let mut role_hash_set = HashSet::with_capacity(updated_full_user_role_set.len()); + for raw_role in updated_full_user_role_set { + let Some(role) = Role::from_id(raw_role.role_id) else { + return Err(QueryError::ModelError { + reason: format!( + "unexpected internal role ID: {}", + raw_role.role_id + ) + .into(), + }); + }; + + role_hash_set.insert(role); + } + + Ok(RoleSet::from_role_hash_set(role_hash_set)) + } + + /// Removes a set of roles from the specified user. + /// If the user did not have any of the specified roles, + /// nothing bad happens (non-matches are ignored). + /// + /// Returns a full updated set of roles the user has. + pub async fn remove_roles_from_user( + database_connection: &mut PgConnection, + user_id: UserId, + roles_to_remove: RoleSet, + ) -> QueryResult { + struct SelectedRoleId { + role_id: i32, + } + + let role_ids_nested = roles_to_remove + .into_roles() + .into_iter() + .map(|role| role.id()) + .collect::>(); + + + let updated_full_user_role_set = sqlx::query_as!( + SelectedRoleId, + "DELETE FROM kolomoni.user_role \ + WHERE user_id = $1 AND role_id = ANY($2::integer[]) \ + RETURNING \ + (SELECT DISTINCT role_id \ + FROM kolomoni.user_role \ + WHERE user_id = $1) as \"role_id!\"", + user_id.into_uuid(), + role_ids_nested.as_slice() + ) + .fetch_all(database_connection) + .await?; + + if updated_full_user_role_set.is_empty() { + return Ok(RoleSet::new_empty()); + } + + + let mut role_hash_set = HashSet::with_capacity(updated_full_user_role_set.len()); + for raw_role in updated_full_user_role_set { + let Some(role) = Role::from_id(raw_role.role_id) else { + return Err(QueryError::ModelError { + reason: format!( + "unexpected internal role ID: {}", + raw_role.role_id + ) + .into(), + }); + }; + + role_hash_set.insert(role); + } + + Ok(RoleSet::from_role_hash_set(role_hash_set)) + } +} diff --git a/kolomoni_database/src/entities/user_role/query.rs b/kolomoni_database/src/entities/user_role/query.rs index b262d98..20a436c 100644 --- a/kolomoni_database/src/entities/user_role/query.rs +++ b/kolomoni_database/src/entities/user_role/query.rs @@ -6,9 +6,11 @@ use sqlx::PgConnection; use crate::{QueryError, QueryResult}; -pub struct Query; -impl Query { + +pub struct UserRoleQuery; + +impl UserRoleQuery { pub async fn roles_for_user( connection: &mut PgConnection, user_id: UserId, @@ -22,7 +24,7 @@ impl Query { "SELECT DISTINCT role_id \ FROM kolomoni.user_role \ WHERE user_id = $1", - user_id.into_inner(), + user_id.into_uuid(), ) .fetch_all(connection) .await?; @@ -66,7 +68,7 @@ impl Query { INNER JOIN kolomoni.user_role \ ON role_permission.role_id = user_role.role_id \ WHERE user_role.user_id = $1", - user_id.into_inner() + user_id.into_uuid() ) .fetch_all(connection) .await?; @@ -116,7 +118,7 @@ impl Query { WHERE \ user_role.user_id = $1 AND role_permission.permission_id = $2 \ )", - user_id.into_inner(), + user_id.into_uuid(), permission.id() ) .fetch_one(connection) diff --git a/kolomoni_database/src/entities/word/mod.rs b/kolomoni_database/src/entities/word/mod.rs index 8177ff8..4a7ebf6 100644 --- a/kolomoni_database/src/entities/word/mod.rs +++ b/kolomoni_database/src/entities/word/mod.rs @@ -1,2 +1,3 @@ mod model; + pub use model::*; diff --git a/kolomoni_database/src/entities/word/model.rs b/kolomoni_database/src/entities/word/model.rs index 6b875b9..fe63801 100644 --- a/kolomoni_database/src/entities/word/model.rs +++ b/kolomoni_database/src/entities/word/model.rs @@ -4,7 +4,7 @@ use chrono::{DateTime, Utc}; use kolomoni_core::id::WordId; use uuid::Uuid; -use crate::TryIntoModel; +use crate::TryIntoExternalModel; #[derive(Clone, Copy, Eq, Hash, Ord, PartialEq, PartialOrd)] @@ -32,7 +32,7 @@ impl WordLanguage { -pub struct Model { +pub struct WordModel { pub id: WordId, pub language: WordLanguage, @@ -43,30 +43,30 @@ pub struct Model { } -pub(super) struct IntermediateModel { - pub(super) id: Uuid, +pub struct InternalWordModel { + pub(crate) id: Uuid, - pub(super) language: String, + pub(crate) language_code: String, - pub(super) created_at: DateTime, + pub(crate) created_at: DateTime, - pub(super) last_modified_at: DateTime, + pub(crate) last_modified_at: DateTime, } -impl TryIntoModel for IntermediateModel { - type Model = Model; +impl TryIntoExternalModel for InternalWordModel { + type ExternalModel = WordModel; type Error = Cow<'static, str>; - fn try_into_model(self) -> Result { + fn try_into_external_model(self) -> Result { let language = - WordLanguage::from_ietf_bcp_47_language_tag(&self.language).ok_or_else(|| { + WordLanguage::from_ietf_bcp_47_language_tag(&self.language_code).ok_or_else(|| { Cow::from(format!( "unexpected language tag \"{}\", expected \"en\" or \"sl\"", - self.language + self.language_code )) })?; - Ok(Self::Model { + Ok(Self::ExternalModel { id: WordId::new(self.id), language, created_at: self.created_at, diff --git a/kolomoni_database/src/entities/word_english/mod.rs b/kolomoni_database/src/entities/word_english/mod.rs index 9a852a2..1826ff6 100644 --- a/kolomoni_database/src/entities/word_english/mod.rs +++ b/kolomoni_database/src/entities/word_english/mod.rs @@ -1,4 +1,7 @@ mod model; +mod mutation; mod query; + pub use model::*; +pub use mutation::*; pub use query::*; diff --git a/kolomoni_database/src/entities/word_english/model.rs b/kolomoni_database/src/entities/word_english/model.rs index 35cb78f..1ca0dc8 100644 --- a/kolomoni_database/src/entities/word_english/model.rs +++ b/kolomoni_database/src/entities/word_english/model.rs @@ -1,13 +1,22 @@ +use std::borrow::Cow; + use chrono::{DateTime, Utc}; use kolomoni_core::id::EnglishWordId; use uuid::Uuid; -use crate::IntoModel; +use crate::{ + entities::{ + EnglishWordMeaningModelWithCategoriesAndTranslations, + InternalEnglishWordMeaningModelWithCategoriesAndTranslations, + }, + IntoExternalModel, + TryIntoExternalModel, +}; -pub struct ExtendedModel { +pub struct EnglishWordModel { pub word_id: EnglishWordId, pub created_at: DateTime, @@ -18,31 +27,30 @@ pub struct ExtendedModel { } -pub struct Model { - pub word_id: EnglishWordId, +pub struct InternalEnglishWordReducedModel { + pub(crate) word_id: Uuid, - pub lemma: String, + pub(crate) lemma: String, } +pub struct InternalEnglishWordModel { + pub(crate) word_id: Uuid, -pub(super) struct IntermediateExtendedModel { - pub(super) word_id: Uuid, + pub(crate) lemma: String, - pub(super) lemma: String, + pub(crate) created_at: DateTime, - pub(super) created_at: DateTime, - - pub(super) last_modified_at: DateTime, + pub(crate) last_modified_at: DateTime, } -impl IntoModel for IntermediateExtendedModel { - type Model = ExtendedModel; +impl IntoExternalModel for InternalEnglishWordModel { + type ExternalModel = EnglishWordModel; - fn into_model(self) -> Self::Model { + fn into_external_model(self) -> Self::ExternalModel { let word_id = EnglishWordId::new(self.word_id); - Self::Model { + Self::ExternalModel { word_id, lemma: self.lemma, created_at: self.created_at, @@ -50,3 +58,63 @@ impl IntoModel for IntermediateExtendedModel { } } } + + + +pub struct EnglishWordWithMeaningsModel { + pub word_id: EnglishWordId, + + pub lemma: String, + + pub created_at: DateTime, + + pub last_modified_at: DateTime, + + pub meanings: Vec, +} + + + +pub struct InternalEnglishWordWithMeaningsModel { + pub(crate) word_id: Uuid, + + pub(crate) lemma: String, + + pub(crate) created_at: DateTime, + + pub(crate) last_modified_at: DateTime, + + pub(crate) meanings: serde_json::Value, +} + + +impl TryIntoExternalModel for InternalEnglishWordWithMeaningsModel { + type ExternalModel = EnglishWordWithMeaningsModel; + type Error = Cow<'static, str>; + + fn try_into_external_model(self) -> Result { + let internal_meanings = serde_json::from_value::< + Vec, + >(self.meanings) + .map_err(|error| { + Cow::from(format!( + "failed to parse returned JSON as internal english word meaning model: {}", + error + )) + })?; + + let meanings = internal_meanings + .into_iter() + .map(|internal_meaning| internal_meaning.into_external_model()) + .collect(); + + + Ok(Self::ExternalModel { + word_id: EnglishWordId::new(self.word_id), + lemma: self.lemma, + created_at: self.created_at, + last_modified_at: self.last_modified_at, + meanings, + }) + } +} diff --git a/kolomoni_database/src/entities/word_english/mutation.rs b/kolomoni_database/src/entities/word_english/mutation.rs new file mode 100644 index 0000000..c867f22 --- /dev/null +++ b/kolomoni_database/src/entities/word_english/mutation.rs @@ -0,0 +1,123 @@ +use chrono::Utc; +use kolomoni_core::id::EnglishWordId; +use sqlx::PgConnection; + +use crate::{ + entities::{self, WordLanguage}, + QueryError, + QueryResult, +}; + + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct NewEnglishWord { + pub lemma: String, +} + + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct EnglishWordFieldsToUpdate { + pub new_lemma: Option, +} + + + +pub struct EnglishWordMutation; + +impl EnglishWordMutation { + pub async fn create( + database_connection: &mut PgConnection, + word_to_create: NewEnglishWord, + ) -> QueryResult { + let new_word_id = EnglishWordId::generate(); + let new_word_language_code = WordLanguage::English.to_ietf_bcp_47_language_tag(); + let new_word_created_at = Utc::now(); + let new_word_last_modified_at = new_word_created_at; + + let bare_word_model = sqlx::query_as!( + entities::InternalWordModel, + "INSERT INTO kolomoni.word (id, language_code, created_at, last_modified_at) \ + VALUES ($1, $2, $3, $4) \ + RETURNING id, language_code, created_at, last_modified_at", + new_word_id.into_uuid(), + new_word_language_code, + new_word_created_at, + new_word_last_modified_at + ) + .fetch_one(&mut *database_connection) + .await?; + + let english_word_model = sqlx::query_as!( + super::InternalEnglishWordReducedModel, + "INSERT INTO kolomoni.word_english (word_id, lemma) \ + VALUES ($1, $2) \ + RETURNING word_id, lemma", + new_word_id.into_uuid(), + &word_to_create.lemma, + ) + .fetch_one(database_connection) + .await?; + + + Ok(super::EnglishWordModel { + word_id: EnglishWordId::new(english_word_model.word_id), + lemma: english_word_model.lemma, + created_at: bare_word_model.created_at, + last_modified_at: bare_word_model.last_modified_at, + }) + } + + pub async fn update( + database_connection: &mut PgConnection, + english_word_id: EnglishWordId, + fields_to_update: EnglishWordFieldsToUpdate, + ) -> QueryResult { + let Some(new_lemma) = fields_to_update.new_lemma else { + return Ok(true); + }; + + + let english_word_id = english_word_id.into_uuid(); + + let query_result = sqlx::query!( + "UPDATE kolomoni.word_english \ + SET lemma = $1 \ + WHERE word_id = $2", + new_lemma, + english_word_id + ) + .execute(database_connection) + .await?; + + if query_result.rows_affected() > 1 { + return Err(QueryError::database_inconsistency( + "more than one row was affected when updating an english word", + )); + } + + Ok(query_result.rows_affected() == 1) + } + + pub async fn delete( + database_connection: &mut PgConnection, + english_word_id: EnglishWordId, + ) -> QueryResult { + let word_uuid = english_word_id.into_uuid(); + + let query_result = sqlx::query!( + "DELETE FROM kolomoni.word \ + WHERE id = $1", + word_uuid + ) + .execute(database_connection) + .await?; + + if query_result.rows_affected() > 1 { + return Err(QueryError::database_inconsistency( + "more than one row was affected when deleting an english word", + )); + } + + Ok(query_result.rows_affected() == 1) + } +} diff --git a/kolomoni_database/src/entities/word_english/query.rs b/kolomoni_database/src/entities/word_english/query.rs index 6146838..a6621e9 100644 --- a/kolomoni_database/src/entities/word_english/query.rs +++ b/kolomoni_database/src/entities/word_english/query.rs @@ -1,28 +1,58 @@ +use chrono::{DateTime, Utc}; use futures_core::stream::BoxStream; use kolomoni_core::id::EnglishWordId; use sqlx::PgConnection; -use crate::{IntoModel, QueryError, QueryResult}; +use crate::{IntoExternalModel, QueryError, QueryResult, TryIntoExternalModel}; -type RawEnglishWordStream<'c> = BoxStream<'c, Result>; +type RawEnglishWordStream<'c> = BoxStream<'c, Result>; create_async_stream_wrapper!( pub struct EnglishWordStream<'c>; - transforms stream RawEnglishWordStream<'c> => stream of QueryResult: + transforms stream RawEnglishWordStream<'c> => stream of QueryResult: |value| value.map( |some| some - .map(super::IntermediateExtendedModel::into_model) + .map(super::InternalEnglishWordModel::into_external_model) .map_err(|error| QueryError::SqlxError { error }) ) ); -pub struct Query; +type RawEnglishWordWithMeaningsStream<'c> = + BoxStream<'c, Result>; -impl Query { +create_async_stream_wrapper!( + pub struct EnglishWordWithMeaningsStream<'c>; + transforms stream RawEnglishWordWithMeaningsStream<'c> => stream of QueryResult: + |value| { + let Some(value) = value else { + return std::task::Poll::Ready(None); + }; + + let internal_model = value.map_err(|error| QueryError::SqlxError { error })?; + + Some( + internal_model.try_into_external_model() + .map_err(|reason| QueryError::ModelError { reason }) + ) + } +); + + + +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct EnglishWordsQueryOptions { + /// Ignored if `None` (i.e. no filtering). + pub only_words_modified_after: Option>, +} + + +pub struct EnglishWordQuery; + +impl EnglishWordQuery { pub async fn exists_by_id( connection: &mut PgConnection, english_word_id: EnglishWordId, @@ -33,7 +63,7 @@ impl Query { FROM kolomoni.word_english \ WHERE word_id = $1 \ )", - english_word_id.into_inner() + english_word_id.into_uuid() ) .fetch_one(connection) .await?; @@ -62,28 +92,139 @@ impl Query { pub async fn get_by_id( connection: &mut PgConnection, english_word_id: EnglishWordId, - ) -> QueryResult> { + ) -> QueryResult> { let intermediate_extended_model = sqlx::query_as!( - super::IntermediateExtendedModel, + super::InternalEnglishWordModel, "SELECT word_id, lemma, created_at, last_modified_at \ FROM kolomoni.word_english \ INNER JOIN kolomoni.word \ ON word.id = word_english.word_id \ WHERE word_english.word_id = $1", - english_word_id.into_inner() + english_word_id.into_uuid() ) .fetch_optional(connection) .await?; - Ok(intermediate_extended_model.map(super::IntermediateExtendedModel::into_model)) + Ok(intermediate_extended_model.map(super::InternalEnglishWordModel::into_external_model)) + } + + pub async fn get_by_id_with_meanings( + connection: &mut PgConnection, + english_word_id: EnglishWordId, + ) -> QueryResult> { + let intermediate_extended_model = sqlx::query_as!( + super::InternalEnglishWordWithMeaningsModel, + "SELECT \ + we.word_id as \"word_id\", \ + we.lemma as \"lemma\", \ + w.created_at as \"created_at\", \ + w.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(meanings) \ + FILTER (WHERE meanings.word_meaning_id IS NOT NULL), \ + '[]'::json \ + ) as \"meanings!\" \ + FROM kolomoni.word_english as we \ + INNER JOIN kolomoni.word as w \ + ON we.word_id = w.id \ + LEFT JOIN LATERAL ( \ + SELECT \ + wem.word_meaning_id as \"word_meaning_id\", \ + wem.disambiguation as \"disambiguation\", \ + wem.abbreviation as \"abbreviation\", \ + wem.description as \"description\", \ + wem.created_at as \"created_at\", \ + wem.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories) \ + FILTER (WHERE categories.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + coalesce( \ + json_agg(translates_into) \ + FILTER (WHERE translates_into.translated_at IS NOT NULL), \ + '[]'::json \ + ) as \"translates_into\" \ + FROM kolomoni.word_english_meaning as wem \ + INNER JOIN kolomoni.word_meaning as wm \ + ON wem.word_meaning_id = wm.id \ + LEFT JOIN LATERAL ( \ + SELECT wec.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec \ + WHERE wec.word_meaning_id = wem.word_meaning_id \ + ) categories ON TRUE \ + LEFT JOIN LATERAL ( \ + SELECT \ + wsm.word_meaning_id as \"word_meaning_id\", \ + wsm.description as \"description\", \ + wsm.disambiguation as \"disambiguation\", \ + wsm.abbreviation as \"abbreviation\", \ + wsm.created_at as \"created_at\", \ + wsm.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories_on_translated) \ + FILTER (WHERE categories_on_translated.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + translated_at, \ + translated_by \ + FROM kolomoni.word_meaning_translation wmt \ + INNER JOIN kolomoni.word_slovene_meaning as wsm \ + ON wmt.slovene_word_meaning_id = wsm.word_meaning_id \ + LEFT JOIN LATERAL ( \ + SELECT wec_t.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec_t \ + WHERE wec_t.word_meaning_id = wsm.word_meaning_id \ + ) categories_on_translated ON TRUE \ + WHERE wmt.english_word_meaning_id = wm.id \ + GROUP BY \ + wsm.word_meaning_id, \ + wsm.description, \ + wsm.disambiguation, \ + wsm.abbreviation, \ + wsm.created_at, \ + wsm.last_modified_at, \ + wmt.translated_at, \ + wmt.translated_by \ + ) translates_into ON TRUE \ + WHERE wm.word_id = we.word_id \ + GROUP BY \ + wem.word_meaning_id, \ + wem.disambiguation, \ + wem.abbreviation, \ + wem.description, \ + wem.created_at, \ + wem.last_modified_at \ + ) meanings ON TRUE \ + WHERE we.word_id = $1 + GROUP BY \ + we.word_id, \ + we.lemma, \ + w.created_at, \ + w.last_modified_at", + english_word_id.into_uuid() + ) + .fetch_optional(connection) + .await?; + + + let Some(intermediate_model) = intermediate_extended_model else { + return Ok(None); + }; + + Ok(Some( + intermediate_model + .try_into_external_model() + .map_err(|reason| QueryError::ModelError { reason })?, + )) } pub async fn get_by_exact_lemma( connection: &mut PgConnection, lemma: &str, - ) -> QueryResult> { + ) -> QueryResult> { let intermediate_extended_model = sqlx::query_as!( - super::IntermediateExtendedModel, + super::InternalEnglishWordModel, "SELECT word_id, lemma, created_at, last_modified_at \ FROM kolomoni.word_english \ INNER JOIN kolomoni.word \ @@ -94,19 +235,346 @@ impl Query { .fetch_optional(connection) .await?; - Ok(intermediate_extended_model.map(super::IntermediateExtendedModel::into_model)) + Ok(intermediate_extended_model.map(super::InternalEnglishWordModel::into_external_model)) } - pub async fn get_all_english_words(connection: &mut PgConnection) -> EnglishWordStream<'_> { - let intermediate_word_stream = sqlx::query_as!( - super::IntermediateExtendedModel, - "SELECT word_id, lemma, created_at, last_modified_at \ - FROM kolomoni.word_english \ - INNER JOIN kolomoni.word \ - ON word.id = word_english.word_id" + pub async fn get_by_exact_lemma_with_meanings( + connection: &mut PgConnection, + lemma: &str, + ) -> QueryResult> { + let intermediate_extended_model = sqlx::query_as!( + super::InternalEnglishWordWithMeaningsModel, + "SELECT \ + we.word_id as \"word_id\", \ + we.lemma as \"lemma\", \ + w.created_at as \"created_at\", \ + w.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(meanings) \ + FILTER (WHERE meanings.word_meaning_id IS NOT NULL), \ + '[]'::json \ + ) as \"meanings!\" \ + FROM kolomoni.word_english as we \ + INNER JOIN kolomoni.word as w \ + ON we.word_id = w.id \ + LEFT JOIN LATERAL ( \ + SELECT \ + wem.word_meaning_id as \"word_meaning_id\", \ + wem.disambiguation as \"disambiguation\", \ + wem.abbreviation as \"abbreviation\", \ + wem.description as \"description\", \ + wem.created_at as \"created_at\", \ + wem.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories) \ + FILTER (WHERE categories.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + coalesce( \ + json_agg(translates_into) \ + FILTER (WHERE translates_into.translated_at IS NOT NULL), \ + '[]'::json \ + ) as \"translates_into\" \ + FROM kolomoni.word_english_meaning as wem \ + INNER JOIN kolomoni.word_meaning as wm \ + ON wem.word_meaning_id = wm.id \ + LEFT JOIN LATERAL ( \ + SELECT wec.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec \ + WHERE wec.word_meaning_id = wem.word_meaning_id \ + ) categories ON TRUE \ + LEFT JOIN LATERAL ( \ + SELECT \ + wsm.word_meaning_id as \"word_meaning_id\", \ + wsm.description as \"description\", \ + wsm.disambiguation as \"disambiguation\", \ + wsm.abbreviation as \"abbreviation\", \ + wsm.created_at as \"created_at\", \ + wsm.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories_on_translated) \ + FILTER (WHERE categories_on_translated.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + translated_at, \ + translated_by \ + FROM kolomoni.word_meaning_translation wmt \ + INNER JOIN kolomoni.word_slovene_meaning as wsm \ + ON wmt.slovene_word_meaning_id = wsm.word_meaning_id \ + LEFT JOIN LATERAL ( \ + SELECT wec_t.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec_t \ + WHERE wec_t.word_meaning_id = wsm.word_meaning_id \ + ) categories_on_translated ON TRUE \ + WHERE wmt.english_word_meaning_id = wm.id \ + GROUP BY \ + wsm.word_meaning_id, \ + wsm.description, \ + wsm.disambiguation, \ + wsm.abbreviation, \ + wsm.created_at, \ + wsm.last_modified_at, \ + wmt.translated_at, \ + wmt.translated_by \ + ) translates_into ON TRUE \ + WHERE wm.word_id = we.word_id \ + GROUP BY \ + wem.word_meaning_id, \ + wem.disambiguation, \ + wem.abbreviation, \ + wem.description, \ + wem.created_at, \ + wem.last_modified_at \ + ) meanings ON TRUE \ + WHERE we.lemma = $1 \ + GROUP BY \ + we.word_id, \ + we.lemma, \ + w.created_at, \ + w.last_modified_at", + lemma ) - .fetch(connection); + .fetch_optional(connection) + .await?; + + + let Some(intermediate_model) = intermediate_extended_model else { + return Ok(None); + }; + + Ok(Some( + intermediate_model + .try_into_external_model() + .map_err(|reason| QueryError::ModelError { reason })?, + )) + } + + pub async fn get_all_english_words( + connection: &mut PgConnection, + options: EnglishWordsQueryOptions, + ) -> EnglishWordStream<'_> { + if let Some(only_modified_after) = options.only_words_modified_after { + let intermediate_word_stream = sqlx::query_as!( + super::InternalEnglishWordModel, + "SELECT word_id, lemma, created_at, last_modified_at \ + FROM kolomoni.word_english \ + INNER JOIN kolomoni.word \ + ON word.id = word_english.word_id + WHERE last_modified_at >= $1", + only_modified_after + ) + .fetch(connection); + + EnglishWordStream::new(intermediate_word_stream) + } else { + let intermediate_word_stream = sqlx::query_as!( + super::InternalEnglishWordModel, + "SELECT word_id, lemma, created_at, last_modified_at \ + FROM kolomoni.word_english \ + INNER JOIN kolomoni.word \ + ON word.id = word_english.word_id" + ) + .fetch(connection); + + EnglishWordStream::new(intermediate_word_stream) + } + } + + // TODO Needs to be tested. + pub async fn get_all_english_words_with_meanings( + database_connection: &mut PgConnection, + options: EnglishWordsQueryOptions, + ) -> EnglishWordWithMeaningsStream<'_> { + if let Some(only_modified_after) = options.only_words_modified_after { + let internal_words_with_meanings_stream = sqlx::query_as!( + super::InternalEnglishWordWithMeaningsModel, + "SELECT \ + we.word_id as \"word_id\", \ + we.lemma as \"lemma\", \ + w.created_at as \"created_at\", \ + w.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(meanings) \ + FILTER (WHERE meanings.word_meaning_id IS NOT NULL), \ + '[]'::json \ + ) as \"meanings!\" \ + FROM kolomoni.word_english as we \ + INNER JOIN kolomoni.word as w \ + ON we.word_id = w.id \ + LEFT JOIN LATERAL ( \ + SELECT \ + wem.word_meaning_id as \"word_meaning_id\", \ + wem.disambiguation as \"disambiguation\", \ + wem.abbreviation as \"abbreviation\", \ + wem.description as \"description\", \ + wem.created_at as \"created_at\", \ + wem.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories) \ + FILTER (WHERE categories.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + coalesce( \ + json_agg(translates_into) \ + FILTER (WHERE translates_into.translated_at IS NOT NULL), \ + '[]'::json \ + ) as \"translates_into\" \ + FROM kolomoni.word_english_meaning as wem \ + INNER JOIN kolomoni.word_meaning as wm \ + ON wem.word_meaning_id = wm.id \ + LEFT JOIN LATERAL ( \ + SELECT wec.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec \ + WHERE wec.word_meaning_id = wem.word_meaning_id \ + ) categories ON TRUE \ + LEFT JOIN LATERAL ( \ + SELECT \ + wsm.word_meaning_id as \"word_meaning_id\", \ + wsm.description as \"description\", \ + wsm.disambiguation as \"disambiguation\", \ + wsm.abbreviation as \"abbreviation\", \ + wsm.created_at as \"created_at\", \ + wsm.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories_on_translated) \ + FILTER (WHERE categories_on_translated.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + translated_at, \ + translated_by \ + FROM kolomoni.word_meaning_translation wmt \ + INNER JOIN kolomoni.word_slovene_meaning as wsm \ + ON wmt.slovene_word_meaning_id = wsm.word_meaning_id \ + LEFT JOIN LATERAL ( \ + SELECT wec_t.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec_t \ + WHERE wec_t.word_meaning_id = wsm.word_meaning_id \ + ) categories_on_translated ON TRUE \ + WHERE wmt.english_word_meaning_id = wm.id \ + GROUP BY \ + wsm.word_meaning_id, \ + wsm.description, \ + wsm.disambiguation, \ + wsm.abbreviation, \ + wsm.created_at, \ + wsm.last_modified_at, \ + wmt.translated_at, \ + wmt.translated_by \ + ) translates_into ON TRUE \ + WHERE wm.word_id = we.word_id \ + GROUP BY \ + wem.word_meaning_id, \ + wem.disambiguation, \ + wem.abbreviation, \ + wem.description, \ + wem.created_at, \ + wem.last_modified_at \ + ) meanings ON TRUE \ + WHERE w.last_modified_at >= $1 \ + GROUP BY \ + we.word_id, \ + we.lemma, \ + w.created_at, \ + w.last_modified_at", + only_modified_after + ) + .fetch(database_connection); + + EnglishWordWithMeaningsStream::new(internal_words_with_meanings_stream) + } else { + let internal_words_with_meanings_stream = sqlx::query_as!( + super::InternalEnglishWordWithMeaningsModel, + "SELECT \ + we.word_id as \"word_id\", \ + we.lemma as \"lemma\", \ + w.created_at as \"created_at\", \ + w.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(meanings) \ + FILTER (WHERE meanings.word_meaning_id IS NOT NULL), \ + '[]'::json \ + ) as \"meanings!\" \ + FROM kolomoni.word_english as we \ + INNER JOIN kolomoni.word as w \ + ON we.word_id = w.id \ + LEFT JOIN LATERAL ( \ + SELECT \ + wem.word_meaning_id as \"word_meaning_id\", \ + wem.disambiguation as \"disambiguation\", \ + wem.abbreviation as \"abbreviation\", \ + wem.description as \"description\", \ + wem.created_at as \"created_at\", \ + wem.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories) \ + FILTER (WHERE categories.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + coalesce( \ + json_agg(translates_into) \ + FILTER (WHERE translates_into.translated_at IS NOT NULL), \ + '[]'::json \ + ) as \"translates_into\" \ + FROM kolomoni.word_english_meaning as wem \ + INNER JOIN kolomoni.word_meaning as wm \ + ON wem.word_meaning_id = wm.id \ + LEFT JOIN LATERAL ( \ + SELECT wec.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec \ + WHERE wec.word_meaning_id = wem.word_meaning_id \ + ) categories ON TRUE \ + LEFT JOIN LATERAL ( \ + SELECT \ + wsm.word_meaning_id as \"word_meaning_id\", \ + wsm.description as \"description\", \ + wsm.disambiguation as \"disambiguation\", \ + wsm.abbreviation as \"abbreviation\", \ + wsm.created_at as \"created_at\", \ + wsm.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories_on_translated) \ + FILTER (WHERE categories_on_translated.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + translated_at, \ + translated_by \ + FROM kolomoni.word_meaning_translation wmt \ + INNER JOIN kolomoni.word_slovene_meaning as wsm \ + ON wmt.slovene_word_meaning_id = wsm.word_meaning_id \ + LEFT JOIN LATERAL ( \ + SELECT wec_t.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec_t \ + WHERE wec_t.word_meaning_id = wsm.word_meaning_id \ + ) categories_on_translated ON TRUE \ + WHERE wmt.english_word_meaning_id = wm.id \ + GROUP BY \ + wsm.word_meaning_id, \ + wsm.description, \ + wsm.disambiguation, \ + wsm.abbreviation, \ + wsm.created_at, \ + wsm.last_modified_at, \ + wmt.translated_at, \ + wmt.translated_by \ + ) translates_into ON TRUE \ + WHERE wm.word_id = we.word_id \ + GROUP BY \ + wem.word_meaning_id, \ + wem.disambiguation, \ + wem.abbreviation, \ + wem.description, \ + wem.created_at, \ + wem.last_modified_at \ + ) meanings ON TRUE \ + GROUP BY \ + we.word_id, \ + we.lemma, \ + w.created_at, \ + w.last_modified_at", + ) + .fetch(database_connection); - EnglishWordStream::new(intermediate_word_stream) + EnglishWordWithMeaningsStream::new(internal_words_with_meanings_stream) + } } } diff --git a/kolomoni_database/src/entities/word_english_meaning/mod.rs b/kolomoni_database/src/entities/word_english_meaning/mod.rs index 8177ff8..1826ff6 100644 --- a/kolomoni_database/src/entities/word_english_meaning/mod.rs +++ b/kolomoni_database/src/entities/word_english_meaning/mod.rs @@ -1,2 +1,7 @@ mod model; +mod mutation; +mod query; + pub use model::*; +pub use mutation::*; +pub use query::*; diff --git a/kolomoni_database/src/entities/word_english_meaning/model.rs b/kolomoni_database/src/entities/word_english_meaning/model.rs index 1d420f6..a2b1dbe 100644 --- a/kolomoni_database/src/entities/word_english_meaning/model.rs +++ b/kolomoni_database/src/entities/word_english_meaning/model.rs @@ -1,13 +1,33 @@ +use std::borrow::Cow; + use chrono::{DateTime, Utc}; -use kolomoni_core::id::EnglishWordMeaningId; +use kolomoni_core::id::{CategoryId, EnglishWordMeaningId, SloveneWordMeaningId, UserId}; +use serde::Deserialize; use uuid::Uuid; -use crate::IntoModel; +use crate::{IntoExternalModel, TryIntoStronglyTypedInternalModel}; + + +// TODO These names are a mess, refactor. + + +pub struct EnglishWordMeaningModel { + pub id: EnglishWordMeaningId, + + pub disambiguation: Option, + + pub abbreviation: Option, + + pub description: Option, + + pub created_at: DateTime, + pub last_modified_at: DateTime, +} -pub struct Model { +pub struct EnglishWordMeaningModelWithCategoriesAndTranslations { pub id: EnglishWordMeaningId, pub disambiguation: Option, @@ -19,31 +39,225 @@ pub struct Model { pub created_at: DateTime, pub last_modified_at: DateTime, + + pub categories: Vec, + + pub translates_into: Vec, +} + + + +pub struct TranslatesIntoSloveneWordModel { + pub word_meaning_id: SloveneWordMeaningId, + + pub disambiguation: Option, + + pub abbreviation: Option, + + pub description: Option, + + pub created_at: DateTime, + + pub last_modified_at: DateTime, + + pub categories: Vec, + + pub translated_at: DateTime, + + pub translated_by: Option, +} + + + +pub struct InternalEnglishWordMeaningModel { + pub(crate) word_meaning_id: Uuid, + + pub(crate) disambiguation: Option, + + pub(crate) abbreviation: Option, + + pub(crate) description: Option, + + pub(crate) created_at: DateTime, + + pub(crate) last_modified_at: DateTime, +} + + + +pub struct EnglishWordMeaningModelWithWeaklyTypedCategoriesAndTranslations { + pub(crate) word_meaning_id: Uuid, + + pub(crate) disambiguation: Option, + + pub(crate) abbreviation: Option, + + pub(crate) description: Option, + + pub(crate) created_at: DateTime, + + pub(crate) last_modified_at: DateTime, + + pub(crate) categories: serde_json::Value, + + pub(crate) translates_into: serde_json::Value, } +impl TryIntoStronglyTypedInternalModel + for EnglishWordMeaningModelWithWeaklyTypedCategoriesAndTranslations +{ + type InternalModel = InternalEnglishWordMeaningModelWithCategoriesAndTranslations; + type Error = Cow<'static, str>; -pub(super) struct IntermediateModel { - pub(super) word_meaning_id: Uuid, + fn try_into_strongly_typed_internal_model(self) -> Result { + let internal_categories = serde_json::from_value::>( + self.categories, + ) + .map_err(|error| { + Cow::from(format!( + "failed to parse returned JSON as internal ID-only categories model: {}", + error + )) + })?; - pub(super) disambiguation: Option, + let internal_translates_into = serde_json::from_value::< + Vec, + >(self.translates_into) + .map_err(|error| { + Cow::from(format!( + "failed to parse returned JSON as internal slovene translations model: {}", + error + )) + })?; - pub(super) abbreviation: Option, - pub(super) description: Option, + Ok(Self::InternalModel { + word_meaning_id: self.word_meaning_id, + disambiguation: self.disambiguation, + abbreviation: self.abbreviation, + description: self.description, + created_at: self.created_at, + last_modified_at: self.last_modified_at, + categories: internal_categories, + translates_into: internal_translates_into, + }) + } +} + + + +#[derive(Deserialize)] +pub struct InternalEnglishWordMeaningModelWithCategoriesAndTranslations { + pub(crate) word_meaning_id: Uuid, + + pub(crate) disambiguation: Option, + + pub(crate) abbreviation: Option, + + pub(crate) description: Option, + + pub(crate) created_at: DateTime, - pub(super) created_at: DateTime, + pub(crate) last_modified_at: DateTime, - pub(super) last_modified_at: DateTime, + pub(crate) categories: Vec, + + pub(crate) translates_into: Vec, +} + +impl IntoExternalModel for InternalEnglishWordMeaningModelWithCategoriesAndTranslations { + type ExternalModel = EnglishWordMeaningModelWithCategoriesAndTranslations; + + fn into_external_model(self) -> Self::ExternalModel { + Self::ExternalModel { + id: EnglishWordMeaningId::new(self.word_meaning_id), + disambiguation: self.disambiguation, + abbreviation: self.abbreviation, + description: self.description, + created_at: self.created_at, + last_modified_at: self.last_modified_at, + categories: self + .categories + .into_iter() + .map(|internal_category| internal_category.into_external_model()) + .collect(), + translates_into: self + .translates_into + .into_iter() + .map(|internal_translation| internal_translation.into_external_model()) + .collect(), + } + } } -impl IntoModel for IntermediateModel { - type Model = Model; - fn into_model(self) -> Self::Model { + +#[derive(Deserialize)] +pub struct InternalCategoryIdOnlyModel { + pub(crate) category_id: Uuid, +} + +impl IntoExternalModel for InternalCategoryIdOnlyModel { + type ExternalModel = CategoryId; + + fn into_external_model(self) -> Self::ExternalModel { + CategoryId::new(self.category_id) + } +} + + +#[derive(Deserialize)] +pub struct InternalTranslatesIntoSloveneWordModel { + pub(crate) word_meaning_id: Uuid, + + pub(crate) disambiguation: Option, + + pub(crate) abbreviation: Option, + + pub(crate) description: Option, + + pub(crate) created_at: DateTime, + + pub(crate) last_modified_at: DateTime, + + pub(crate) categories: Vec, + + pub(crate) translated_at: DateTime, + + pub(crate) translated_by: Option, +} + +impl IntoExternalModel for InternalTranslatesIntoSloveneWordModel { + type ExternalModel = TranslatesIntoSloveneWordModel; + + fn into_external_model(self) -> Self::ExternalModel { + Self::ExternalModel { + word_meaning_id: SloveneWordMeaningId::new(self.word_meaning_id), + disambiguation: self.disambiguation, + abbreviation: self.abbreviation, + description: self.description, + created_at: self.created_at, + last_modified_at: self.last_modified_at, + categories: self + .categories + .into_iter() + .map(|internal_model| CategoryId::new(internal_model.category_id)) + .collect(), + translated_at: self.translated_at, + translated_by: self.translated_by.map(UserId::new), + } + } +} + + +impl IntoExternalModel for InternalEnglishWordMeaningModel { + type ExternalModel = EnglishWordMeaningModel; + + fn into_external_model(self) -> Self::ExternalModel { let id = EnglishWordMeaningId::new(self.word_meaning_id); - Self::Model { + Self::ExternalModel { id, disambiguation: self.disambiguation, abbreviation: self.abbreviation, diff --git a/kolomoni_database/src/entities/word_english_meaning/mutation.rs b/kolomoni_database/src/entities/word_english_meaning/mutation.rs new file mode 100644 index 0000000..04112d0 --- /dev/null +++ b/kolomoni_database/src/entities/word_english_meaning/mutation.rs @@ -0,0 +1,203 @@ +use std::borrow::Cow; + +use chrono::Utc; +use kolomoni_core::id::{EnglishWordId, EnglishWordMeaningId}; +use sqlx::{PgConnection, Postgres, QueryBuilder}; + +use super::EnglishWordMeaningModel; +use crate::{IntoExternalModel, QueryError, QueryResult}; + + + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct NewEnglishWordMeaning { + pub disambiguation: Option, + + pub abbreviation: Option, + + pub description: Option, +} + + +// TODO Migrate to double options. +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct EnglishWordMeaningUpdate { + pub disambiguation: Option>, + + pub abbreviation: Option>, + + pub description: Option>, +} + +impl EnglishWordMeaningUpdate { + fn no_values_to_update(&self) -> bool { + self.disambiguation.is_none() && self.abbreviation.is_none() && self.description.is_none() + } +} + + +fn build_english_word_meaning_update_query( + english_word_meaning_id: EnglishWordMeaningId, + values_to_update: EnglishWordMeaningUpdate, +) -> QueryBuilder<'static, Postgres> { + let mut update_query_builder: QueryBuilder = + QueryBuilder::new("UPDATE kolomoni.word_english_meaning SET "); + + let mut separated_set_expressions = update_query_builder.separated(", "); + + + if let Some(new_disambiguation) = values_to_update.disambiguation { + separated_set_expressions.push_unseparated("disambiguation = "); + separated_set_expressions.push_bind(new_disambiguation); + } + + if let Some(new_abbreviation) = values_to_update.abbreviation { + separated_set_expressions.push_unseparated("abbreviation = "); + separated_set_expressions.push_bind(new_abbreviation); + } + + if let Some(new_description) = values_to_update.description { + separated_set_expressions.push_unseparated("description = "); + separated_set_expressions.push_bind(new_description); + } + + + update_query_builder.push(" WHERE word_meaning_id = "); + update_query_builder.push_bind(english_word_meaning_id.into_uuid()); + + update_query_builder +} + + + +pub struct EnglishWordMeaningMutation; + +impl EnglishWordMeaningMutation { + pub async fn create( + database_connection: &mut PgConnection, + english_word_id: EnglishWordId, + meaning_to_create: NewEnglishWordMeaning, + ) -> QueryResult { + let new_meaning_id = EnglishWordMeaningId::generate(); + let new_meaning_created_at = Utc::now(); + let new_meaning_last_modified_at = new_meaning_created_at; + + + let internal_meaning_query_result = sqlx::query!( + "INSERT INTO kolomoni.word_meaning (id, word_id) \ + VALUES ($1, $2)", + new_meaning_id.into_uuid(), + english_word_id.into_uuid() + ) + .execute(&mut *database_connection) + .await?; + + if internal_meaning_query_result.rows_affected() != 1 { + return Err(QueryError::DatabaseInconsistencyError { + problem: Cow::from(format!( + "inserted word meaning, but got abnormal number of affected rows ({})", + internal_meaning_query_result.rows_affected() + )), + }); + } + + + let internal_english_meaning = sqlx::query_as!( + super::InternalEnglishWordMeaningModel, + "INSERT INTO kolomoni.word_english_meaning \ + (word_meaning_id, disambiguation, abbreviation, \ + description, created_at, last_modified_at) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + RETURNING \ + word_meaning_id, disambiguation, abbreviation, \ + description, created_at, last_modified_at", + new_meaning_id.into_uuid(), + meaning_to_create.disambiguation, + meaning_to_create.abbreviation, + meaning_to_create.description, + new_meaning_created_at, + new_meaning_last_modified_at + ) + .fetch_one(database_connection) + .await?; + + Ok(internal_english_meaning.into_external_model()) + } + + pub async fn update( + database_connection: &mut PgConnection, + english_word_meaning_id: EnglishWordMeaningId, + values_to_update: EnglishWordMeaningUpdate, + ) -> QueryResult { + if values_to_update.no_values_to_update() { + return Ok(true); + }; + + let mut update_query_builder = + build_english_word_meaning_update_query(english_word_meaning_id, values_to_update); + + let query_result = update_query_builder + .build() + .execute(database_connection) + .await?; + + Ok(query_result.rows_affected() == 1) + } + + pub async fn delete( + database_connection: &mut PgConnection, + english_word_meaning_id: EnglishWordMeaningId, + ) -> QueryResult { + let query_result = sqlx::query!( + "DELETE FROM kolomoni.word_english_meaning \ + WHERE word_meaning_id = $1", + english_word_meaning_id.into_uuid() + ) + .execute(database_connection) + .await?; + + if query_result.rows_affected() > 1 { + return Err(QueryError::database_inconsistency(format!( + "while deleting english word meaning {} more than one row was affected ({})", + english_word_meaning_id, + query_result.rows_affected() + ))); + } + + Ok(query_result.rows_affected() == 1) + } +} + + + +#[cfg(test)] +mod test { + use sqlx::Execute; + use uuid::Uuid; + + use super::*; + + #[test] + fn builds_correct_meaning_update_queries() { + let meaning_id = EnglishWordMeaningId::new(Uuid::nil()); + + assert_eq!( + build_english_word_meaning_update_query( + meaning_id, + EnglishWordMeaningUpdate { + abbreviation: Some(Some("a".into())), + description: Some(None), + disambiguation: Some(None), + } + ) + .build() + .sql(), + format!( + "UPDATE kolomoni.word_english_meaning SET abbreviation = $1 WHERE word_meaning_id = {}", + meaning_id.into_uuid() + ) + ); + + // TODO Other tests. + } +} diff --git a/kolomoni_database/src/entities/word_english_meaning/query.rs b/kolomoni_database/src/entities/word_english_meaning/query.rs new file mode 100644 index 0000000..a06a44c --- /dev/null +++ b/kolomoni_database/src/entities/word_english_meaning/query.rs @@ -0,0 +1,214 @@ +use kolomoni_core::id::{EnglishWordId, EnglishWordMeaningId}; +use sqlx::PgConnection; + +use super::EnglishWordMeaningModelWithCategoriesAndTranslations; +use crate::{IntoExternalModel, QueryError, QueryResult, TryIntoStronglyTypedInternalModel}; + +pub struct EnglishWordMeaningQuery; + +impl EnglishWordMeaningQuery { + pub async fn get_all_by_english_word_id( + database_connection: &mut PgConnection, + english_word_id: EnglishWordId, + ) -> QueryResult> { + let internal_meanings_weak = sqlx::query_as!( + super::EnglishWordMeaningModelWithWeaklyTypedCategoriesAndTranslations, + "SELECT \ + wem.word_meaning_id as \"word_meaning_id\", \ + wem.disambiguation as \"disambiguation\", \ + wem.abbreviation as \"abbreviation\", \ + wem.description as \"description\", \ + wem.created_at as \"created_at\", \ + wem.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories) \ + FILTER (WHERE categories.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories!\", \ + coalesce( \ + json_agg(translates_into) \ + FILTER (WHERE translates_into.translated_at IS NOT NULL), \ + '[]'::json \ + ) as \"translates_into!\" \ + FROM kolomoni.word_english_meaning as wem \ + INNER JOIN kolomoni.word_meaning as wm \ + ON wem.word_meaning_id = wm.id \ + LEFT JOIN LATERAL ( \ + SELECT wec.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec \ + WHERE wec.word_meaning_id = wem.word_meaning_id \ + ) categories ON TRUE \ + LEFT JOIN LATERAL ( \ + SELECT \ + wsm.word_meaning_id as \"meaning_id\", \ + wsm.description as \"description\", \ + wsm.disambiguation as \"disambiguation\", \ + wsm.abbreviation as \"abbreviation\", \ + wsm.created_at as \"created_at\", \ + wsm.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories_on_translated) \ + FILTER (WHERE categories_on_translated.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + translated_at, \ + translated_by \ + FROM kolomoni.word_meaning_translation wmt \ + INNER JOIN kolomoni.word_slovene_meaning as wsm \ + ON wmt.slovene_word_meaning_id = wsm.word_meaning_id \ + LEFT JOIN LATERAL ( \ + SELECT wec_t.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec_t \ + WHERE wec_t.word_meaning_id = wsm.word_meaning_id \ + ) categories_on_translated ON TRUE \ + WHERE wmt.english_word_meaning_id = wm.id \ + GROUP BY \ + wsm.word_meaning_id, \ + wsm.description, \ + wsm.disambiguation, \ + wsm.abbreviation, \ + wsm.created_at, \ + wsm.last_modified_at, \ + wmt.translated_at, \ + wmt.translated_by \ + ) translates_into ON TRUE \ + WHERE wm.word_id = $1 \ + GROUP BY \ + wem.word_meaning_id, \ + wem.disambiguation, \ + wem.abbreviation, \ + wem.description, \ + wem.created_at, \ + wem.last_modified_at", + english_word_id.into_uuid() + ) + .fetch_all(database_connection) + .await?; + + + let mut external_meanings = Vec::with_capacity(internal_meanings_weak.len()); + + for weak_internal_meaning in internal_meanings_weak { + let external_meaning = weak_internal_meaning + .try_into_strongly_typed_internal_model() + .map_err(|reason| QueryError::ModelError { reason })? + .into_external_model(); + + external_meanings.push(external_meaning); + } + + + Ok(external_meanings) + } + + pub async fn get( + database_connection: &mut PgConnection, + english_word_id: EnglishWordId, + english_word_meaning_id: EnglishWordMeaningId, + ) -> QueryResult> { + let internal_meaning_weak = sqlx::query_as!( + super::EnglishWordMeaningModelWithWeaklyTypedCategoriesAndTranslations, + "SELECT \ + wem.word_meaning_id as \"word_meaning_id\", \ + wem.disambiguation as \"disambiguation\", \ + wem.abbreviation as \"abbreviation\", \ + wem.description as \"description\", \ + wem.created_at as \"created_at\", \ + wem.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories) \ + FILTER (WHERE categories.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories!\", \ + coalesce( \ + json_agg(translates_into) \ + FILTER (WHERE translates_into.translated_at IS NOT NULL), \ + '[]'::json \ + ) as \"translates_into!\" \ + FROM kolomoni.word_english_meaning as wem \ + INNER JOIN kolomoni.word_meaning as wm \ + ON wem.word_meaning_id = wm.id \ + LEFT JOIN LATERAL ( \ + SELECT wec.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec \ + WHERE wec.word_meaning_id = wem.word_meaning_id \ + ) categories ON TRUE \ + LEFT JOIN LATERAL ( \ + SELECT \ + wsm.word_meaning_id as \"meaning_id\", \ + wsm.description as \"description\", \ + wsm.disambiguation as \"disambiguation\", \ + wsm.abbreviation as \"abbreviation\", \ + wsm.created_at as \"created_at\", \ + wsm.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories_on_translated) \ + FILTER (WHERE categories_on_translated.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + translated_at, \ + translated_by \ + FROM kolomoni.word_meaning_translation wmt \ + INNER JOIN kolomoni.word_slovene_meaning as wsm \ + ON wmt.slovene_word_meaning_id = wsm.word_meaning_id \ + LEFT JOIN LATERAL ( \ + SELECT wec_t.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec_t \ + WHERE wec_t.word_meaning_id = wsm.word_meaning_id \ + ) categories_on_translated ON TRUE \ + WHERE wmt.english_word_meaning_id = wm.id \ + GROUP BY \ + wsm.word_meaning_id, \ + wsm.description, \ + wsm.disambiguation, \ + wsm.abbreviation, \ + wsm.created_at, \ + wsm.last_modified_at, \ + wmt.translated_at, \ + wmt.translated_by \ + ) translates_into ON TRUE \ + WHERE wm.word_id = $1 AND wm.id = $2 \ + GROUP BY \ + wem.word_meaning_id, \ + wem.disambiguation, \ + wem.abbreviation, \ + wem.description, \ + wem.created_at, \ + wem.last_modified_at", + english_word_id.into_uuid(), + english_word_meaning_id.into_uuid() + ) + .fetch_optional(database_connection) + .await?; + + let Some(internal_meaning_weak) = internal_meaning_weak else { + return Ok(None); + }; + + + Ok(Some( + internal_meaning_weak + .try_into_strongly_typed_internal_model() + .map_err(|reason| QueryError::ModelError { reason })? + .into_external_model(), + )) + } + + pub async fn exists_by_id( + database_connection: &mut PgConnection, + english_word_meaning_id: EnglishWordMeaningId, + ) -> QueryResult { + let exists = sqlx::query_scalar!( + "SELECT EXISTS (\ + SELECT 1 \ + FROM kolomoni.word_english_meaning \ + WHERE word_meaning_id = $1 + )", + english_word_meaning_id.into_uuid() + ) + .fetch_one(database_connection) + .await?; + + Ok(exists.unwrap_or(false)) + } +} diff --git a/kolomoni_database/src/entities/word_meaning/mod.rs b/kolomoni_database/src/entities/word_meaning/mod.rs index 8177ff8..4a7ebf6 100644 --- a/kolomoni_database/src/entities/word_meaning/mod.rs +++ b/kolomoni_database/src/entities/word_meaning/mod.rs @@ -1,2 +1,3 @@ mod model; + pub use model::*; diff --git a/kolomoni_database/src/entities/word_meaning/model.rs b/kolomoni_database/src/entities/word_meaning/model.rs index fe06fc4..6b55820 100644 --- a/kolomoni_database/src/entities/word_meaning/model.rs +++ b/kolomoni_database/src/entities/word_meaning/model.rs @@ -1,31 +1,31 @@ use kolomoni_core::id::{WordId, WordMeaningId}; use uuid::Uuid; -use crate::IntoModel; +use crate::IntoExternalModel; -pub struct Model { +pub struct WordMeaningModel { pub meaning_id: WordMeaningId, pub word_id: WordId, } -pub(super) struct IntermediateModel { - pub(super) id: Uuid, +pub struct InternalWordMeaningModel { + pub(crate) id: Uuid, - pub(super) word_id: Uuid, + pub(crate) word_id: Uuid, } -impl IntoModel for IntermediateModel { - type Model = Model; +impl IntoExternalModel for InternalWordMeaningModel { + type ExternalModel = WordMeaningModel; - fn into_model(self) -> Self::Model { + fn into_external_model(self) -> Self::ExternalModel { let meaning_id = WordMeaningId::new(self.id); let word_id = WordId::new(self.word_id); - Self::Model { + Self::ExternalModel { meaning_id, word_id, } diff --git a/kolomoni_database/src/entities/word_meaning_translation/mod.rs b/kolomoni_database/src/entities/word_meaning_translation/mod.rs index 8177ff8..1826ff6 100644 --- a/kolomoni_database/src/entities/word_meaning_translation/mod.rs +++ b/kolomoni_database/src/entities/word_meaning_translation/mod.rs @@ -1,2 +1,7 @@ mod model; +mod mutation; +mod query; + pub use model::*; +pub use mutation::*; +pub use query::*; diff --git a/kolomoni_database/src/entities/word_meaning_translation/model.rs b/kolomoni_database/src/entities/word_meaning_translation/model.rs index 5497147..adfa7ab 100644 --- a/kolomoni_database/src/entities/word_meaning_translation/model.rs +++ b/kolomoni_database/src/entities/word_meaning_translation/model.rs @@ -2,11 +2,11 @@ use chrono::{DateTime, Utc}; use kolomoni_core::id::{EnglishWordMeaningId, SloveneWordMeaningId, UserId}; use uuid::Uuid; -use crate::IntoModel; +use crate::IntoExternalModel; -pub struct Model { +pub struct WordMeaningTranslationModel { pub slovene_word_meaning_id: SloveneWordMeaningId, pub english_word_meaning_id: EnglishWordMeaningId, @@ -18,27 +18,27 @@ pub struct Model { -pub(super) struct IntermediateModel { - pub(super) slovene_word_meaning_id: Uuid, +pub struct InternalWordMeaningTranslationModel { + pub(crate) slovene_word_meaning_id: Uuid, - pub(super) english_word_meaning_id: Uuid, + pub(crate) english_word_meaning_id: Uuid, - pub(super) translated_at: DateTime, + pub(crate) translated_at: DateTime, - pub(super) translated_by: Option, + pub(crate) translated_by: Option, } -impl IntoModel for IntermediateModel { - type Model = Model; +impl IntoExternalModel for InternalWordMeaningTranslationModel { + type ExternalModel = WordMeaningTranslationModel; - fn into_model(self) -> Self::Model { + fn into_external_model(self) -> Self::ExternalModel { let slovene_word_meaning_id = SloveneWordMeaningId::new(self.slovene_word_meaning_id); let english_word_meaning_id = EnglishWordMeaningId::new(self.english_word_meaning_id); let translated_by = self.translated_by.map(UserId::new); - Self::Model { + Self::ExternalModel { slovene_word_meaning_id, english_word_meaning_id, translated_at: self.translated_at, diff --git a/kolomoni_database/src/entities/word_meaning_translation/mutation.rs b/kolomoni_database/src/entities/word_meaning_translation/mutation.rs new file mode 100644 index 0000000..a436a1a --- /dev/null +++ b/kolomoni_database/src/entities/word_meaning_translation/mutation.rs @@ -0,0 +1,63 @@ +use chrono::Utc; +use kolomoni_core::id::{EnglishWordMeaningId, SloveneWordMeaningId, UserId}; +use sqlx::PgConnection; + +use super::WordMeaningTranslationModel; +use crate::{IntoExternalModel, QueryError, QueryResult}; + +pub struct WordMeaningTranslationMutation; + +impl WordMeaningTranslationMutation { + pub async fn create( + database_connection: &mut PgConnection, + english_word_meaning_id: EnglishWordMeaningId, + slovene_word_meaning_id: SloveneWordMeaningId, + translated_by: Option, + ) -> QueryResult { + let translated_at = Utc::now(); + + let newly_created_translation = sqlx::query_as!( + super::InternalWordMeaningTranslationModel, + "INSERT INTO kolomoni.word_meaning_translation \ + (slovene_word_meaning_id, english_word_meaning_id, \ + translated_at, translated_by) \ + VALUES ($1, $2, $3, $4) \ + RETURNING \ + slovene_word_meaning_id, english_word_meaning_id, \ + translated_at, translated_by", + slovene_word_meaning_id.into_uuid(), + english_word_meaning_id.into_uuid(), + translated_at, + translated_by.map(|id| id.into_uuid()) + ) + .fetch_one(database_connection) + .await?; + + Ok(newly_created_translation.into_external_model()) + } + + pub async fn delete( + database_connection: &mut PgConnection, + english_word_meaning_id: EnglishWordMeaningId, + slovene_word_meaning_id: SloveneWordMeaningId, + ) -> QueryResult { + let query_result = sqlx::query_scalar!( + "DELETE FROM kolomoni.word_meaning_translation \ + WHERE slovene_word_meaning_id = $1 \ + AND english_word_meaning_id = $2", + slovene_word_meaning_id.into_uuid(), + english_word_meaning_id.into_uuid(), + ) + .execute(database_connection) + .await?; + + if query_result.rows_affected() > 1 { + return Err(QueryError::database_inconsistency( + "more than one row was affected while deleting a translation", + )); + } + + + Ok(query_result.rows_affected() == 1) + } +} diff --git a/kolomoni_database/src/entities/word_meaning_translation/query.rs b/kolomoni_database/src/entities/word_meaning_translation/query.rs new file mode 100644 index 0000000..fc1a690 --- /dev/null +++ b/kolomoni_database/src/entities/word_meaning_translation/query.rs @@ -0,0 +1,30 @@ +use kolomoni_core::id::{EnglishWordMeaningId, SloveneWordMeaningId}; +use sqlx::PgConnection; + +use crate::QueryResult; + +pub struct WordMeaningTranslationQuery; + +impl WordMeaningTranslationQuery { + pub async fn exists( + database_connection: &mut PgConnection, + english_word_meaning_id: EnglishWordMeaningId, + slovene_word_meaning_id: SloveneWordMeaningId, + ) -> QueryResult { + let exists = sqlx::query_scalar!( + "SELECT EXISTS (\ + SELECT 1 FROM kolomoni.word_meaning_translation \ + WHERE english_word_meaning_id = $1 + AND slovene_word_meaning_id = $2 + )", + english_word_meaning_id.into_uuid(), + slovene_word_meaning_id.into_uuid(), + ) + .fetch_one(database_connection) + .await?; + + Ok(exists.unwrap_or(false)) + } + + // TODO +} diff --git a/kolomoni_database/src/entities/word_slovene/mod.rs b/kolomoni_database/src/entities/word_slovene/mod.rs index a990df1..1826ff6 100644 --- a/kolomoni_database/src/entities/word_slovene/mod.rs +++ b/kolomoni_database/src/entities/word_slovene/mod.rs @@ -1,7 +1,7 @@ mod model; +mod mutation; mod query; -pub use model::ExtendedModel as ExtendedWordModel; -pub use model::Model as WordModel; pub use model::*; +pub use mutation::*; pub use query::*; diff --git a/kolomoni_database/src/entities/word_slovene/model.rs b/kolomoni_database/src/entities/word_slovene/model.rs index 9ed4c3f..8d898b9 100644 --- a/kolomoni_database/src/entities/word_slovene/model.rs +++ b/kolomoni_database/src/entities/word_slovene/model.rs @@ -1,12 +1,21 @@ +use std::borrow::Cow; + use chrono::{DateTime, Utc}; use kolomoni_core::id::SloveneWordId; use uuid::Uuid; -use crate::IntoModel; +use crate::{ + entities::{ + InternalSloveneWordMeaningModelWithCategoriesAndTranslations, + SloveneWordMeaningModelWithCategoriesAndTranslations, + }, + IntoExternalModel, + TryIntoExternalModel, +}; -pub struct ExtendedModel { +pub struct SloveneWordModel { pub word_id: SloveneWordId, pub created_at: DateTime, @@ -17,31 +26,31 @@ pub struct ExtendedModel { } -pub struct Model { - pub word_id: SloveneWordId, +pub struct InternalSloveneWordReducedModel { + pub(crate) word_id: Uuid, - pub lemma: String, + pub(crate) lemma: String, } -pub(super) struct IntermediateExtendedModel { - pub(super) word_id: Uuid, +pub struct InternalSloveneWordModel { + pub(crate) word_id: Uuid, - pub(super) lemma: String, + pub(crate) lemma: String, - pub(super) created_at: DateTime, + pub(crate) created_at: DateTime, - pub(super) last_modified_at: DateTime, + pub(crate) last_modified_at: DateTime, } -impl IntoModel for IntermediateExtendedModel { - type Model = ExtendedModel; +impl IntoExternalModel for InternalSloveneWordModel { + type ExternalModel = SloveneWordModel; - fn into_model(self) -> Self::Model { + fn into_external_model(self) -> Self::ExternalModel { let word_id = SloveneWordId::new(self.word_id); - Self::Model { + Self::ExternalModel { word_id, lemma: self.lemma, created_at: self.created_at, @@ -49,3 +58,61 @@ impl IntoModel for IntermediateExtendedModel { } } } + + +pub struct SloveneWordWithMeaningsModel { + pub word_id: SloveneWordId, + + pub lemma: String, + + pub created_at: DateTime, + + pub last_modified_at: DateTime, + + pub meanings: Vec, +} + + + +pub struct InternalSloveneWordWithMeaningsModel { + pub(crate) word_id: Uuid, + + pub(crate) lemma: String, + + pub(crate) created_at: DateTime, + + pub(crate) last_modified_at: DateTime, + + pub(crate) meanings: serde_json::Value, +} + +impl TryIntoExternalModel for InternalSloveneWordWithMeaningsModel { + type ExternalModel = SloveneWordWithMeaningsModel; + type Error = Cow<'static, str>; + + fn try_into_external_model(self) -> Result { + let internal_meanings = serde_json::from_value::< + Vec, + >(self.meanings) + .map_err(|error| { + Cow::from(format!( + "failed to parse returned JSON as internal slovene word meaning: {}", + error + )) + })?; + + let meanings = internal_meanings + .into_iter() + .map(|internal_meaning| internal_meaning.into_external_model()) + .collect(); + + + Ok(Self::ExternalModel { + word_id: SloveneWordId::new(self.word_id), + lemma: self.lemma, + created_at: self.created_at, + last_modified_at: self.last_modified_at, + meanings, + }) + } +} diff --git a/kolomoni_database/src/entities/word_slovene/mutation.rs b/kolomoni_database/src/entities/word_slovene/mutation.rs new file mode 100644 index 0000000..4991e8a --- /dev/null +++ b/kolomoni_database/src/entities/word_slovene/mutation.rs @@ -0,0 +1,132 @@ +use chrono::Utc; +use kolomoni_core::id::SloveneWordId; +use sqlx::PgConnection; + +use super::SloveneWordModel; +use crate::{ + entities::{ + self, + EnglishWordModel, + InternalSloveneWordReducedModel, + InternalWordModel, + WordLanguage, + }, + QueryError, + QueryResult, +}; + + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct NewSloveneWord { + pub lemma: String, +} + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct SloveneWordFieldsToUpdate { + pub new_lemma: Option, +} + + + + +pub struct SloveneWordMutation; + +impl SloveneWordMutation { + pub async fn create( + database_connection: &mut PgConnection, + word_to_create: NewSloveneWord, + ) -> QueryResult { + let new_word_id = SloveneWordId::generate(); + let new_word_language_code = WordLanguage::Slovene.to_ietf_bcp_47_language_tag(); + let new_word_created_at = Utc::now(); + let new_word_last_modified_at = new_word_created_at; + + let bare_word_model = sqlx::query_as!( + InternalWordModel, + "INSERT INTO kolomoni.word (id, language_code, created_at, last_modified_at) \ + VALUES ($1, $2, $3, $4) \ + RETURNING id, language_code, created_at, last_modified_at", + new_word_id.into_uuid(), + new_word_language_code, + new_word_created_at, + new_word_last_modified_at + ) + .fetch_one(&mut *database_connection) + .await?; + + let english_word_model = sqlx::query_as!( + InternalSloveneWordReducedModel, + "INSERT INTO kolomoni.word_slovene (word_id, lemma) \ + VALUES ($1, $2) \ + RETURNING word_id, lemma", + new_word_id.into_uuid(), + &word_to_create.lemma, + ) + .fetch_one(database_connection) + .await?; + + + Ok(SloveneWordModel { + word_id: SloveneWordId::new(english_word_model.word_id), + lemma: english_word_model.lemma, + created_at: bare_word_model.created_at, + last_modified_at: bare_word_model.last_modified_at, + }) + } + + pub async fn update( + database_connection: &mut PgConnection, + slovene_word_id: SloveneWordId, + fields_to_update: SloveneWordFieldsToUpdate, + ) -> QueryResult { + let Some(new_lemma) = fields_to_update.new_lemma else { + return Ok(true); + }; + + + let slovene_word_id = slovene_word_id.into_uuid(); + + let query_result = sqlx::query!( + "UPDATE kolomoni.word_slovene \ + SET lemma = $1 \ + WHERE word_id = $2", + new_lemma, + slovene_word_id + ) + .execute(database_connection) + .await?; + + + if query_result.rows_affected() > 1 { + return Err(QueryError::database_inconsistency( + "more than one row was affected when updating a slovene word", + )); + } + + Ok(query_result.rows_affected() == 1) + } + + pub async fn delete( + database_connection: &mut PgConnection, + slovene_word_id: SloveneWordId, + ) -> QueryResult { + // TODO refactor this and EnglishWordMutation::delete to forward to EnglishWord::delete + let word_uuid = slovene_word_id.into_uuid(); + + let query_result = sqlx::query!( + "DELETE FROM kolomoni.word \ + WHERE id = $1", + word_uuid + ) + .execute(database_connection) + .await?; + + if query_result.rows_affected() > 1 { + return Err(QueryError::database_inconsistency( + "more than one row was affected when deleting an english word", + )); + } + + Ok(query_result.rows_affected() == 1) + } +} diff --git a/kolomoni_database/src/entities/word_slovene/query.rs b/kolomoni_database/src/entities/word_slovene/query.rs index 52e686b..8ebaee1 100644 --- a/kolomoni_database/src/entities/word_slovene/query.rs +++ b/kolomoni_database/src/entities/word_slovene/query.rs @@ -1,28 +1,61 @@ +use chrono::{DateTime, Utc}; use futures_core::stream::BoxStream; use kolomoni_core::id::SloveneWordId; use sqlx::PgConnection; -use crate::{IntoModel, QueryError, QueryResult}; +use super::SloveneWordWithMeaningsModel; +use crate::{IntoExternalModel, QueryError, QueryResult, TryIntoExternalModel}; -type RawSloveneWordStream<'c> = BoxStream<'c, Result>; + +#[derive(Clone, PartialEq, Eq, Debug, Default)] +pub struct SloveneWordsQueryOptions { + /// Ignored if `None` (i.e. no filtering). + pub only_words_modified_after: Option>, +} + + +type RawSloveneWordStream<'c> = BoxStream<'c, Result>; create_async_stream_wrapper!( pub struct SloveneWordStream<'c>; - transforms stream RawSloveneWordStream<'c> => stream of QueryResult: + transforms stream RawSloveneWordStream<'c> => stream of QueryResult: |value| value.map( |some| some - .map(super::IntermediateExtendedModel::into_model) + .map(super::InternalSloveneWordModel::into_external_model) .map_err(|error| QueryError::SqlxError { error }) ) ); -pub struct Query; +type RawSloveneWordWithMeaningsStream<'c> = + BoxStream<'c, Result>; + +create_async_stream_wrapper!( + pub struct SloveneWordWithMeaningsStream<'c>; + transforms stream RawSloveneWordWithMeaningsStream<'c> => stream of QueryResult: + |value| { + let Some(value) = value else { + return std::task::Poll::Ready(None); + }; + + let internal_model = value.map_err(|error| QueryError::SqlxError { error })?; + + Some( + internal_model.try_into_external_model() + .map_err(|reason| QueryError::ModelError { reason }) + ) + } +); -impl Query { + + + +pub struct SloveneWordQuery; + +impl SloveneWordQuery { pub async fn exists_by_id( connection: &mut PgConnection, slovene_word_id: SloveneWordId, @@ -33,7 +66,7 @@ impl Query { FROM kolomoni.word_slovene \ WHERE word_id = $1 \ )", - slovene_word_id.into_inner() + slovene_word_id.into_uuid() ) .fetch_one(connection) .await?; @@ -62,28 +95,139 @@ impl Query { pub async fn get_by_id( connection: &mut PgConnection, slovene_word_id: SloveneWordId, - ) -> QueryResult> { + ) -> QueryResult> { let intermediate_extended_model = sqlx::query_as!( - super::IntermediateExtendedModel, + super::InternalSloveneWordModel, "SELECT word_id, lemma, created_at, last_modified_at \ FROM kolomoni.word_slovene \ INNER JOIN kolomoni.word \ ON word.id = word_slovene.word_id \ WHERE word_slovene.word_id = $1", - slovene_word_id.into_inner() + slovene_word_id.into_uuid() ) .fetch_optional(connection) .await?; - Ok(intermediate_extended_model.map(super::IntermediateExtendedModel::into_model)) + Ok(intermediate_extended_model.map(super::InternalSloveneWordModel::into_external_model)) + } + + pub async fn get_by_id_with_meanings( + database_connection: &mut PgConnection, + slovene_word_id: SloveneWordId, + ) -> QueryResult> { + let internal_word_with_meanings = sqlx::query_as!( + super::InternalSloveneWordWithMeaningsModel, + "SELECT \ + ws.word_id as \"word_id\", \ + ws.lemma as \"lemma\", \ + w.created_at as \"created_at\", \ + w.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(meanings) \ + FILTER (WHERE meanings.word_meaning_id IS NOT NULL), \ + '[]'::json \ + ) as \"meanings!\" \ + FROM kolomoni.word_slovene as ws \ + INNER JOIN kolomoni.word as w \ + ON ws.word_id = w.id \ + LEFT JOIN LATERAL ( \ + SELECT \ + wsm.word_meaning_id as \"word_meaning_id\", \ + wsm.disambiguation as \"disambiguation\", \ + wsm.abbreviation as \"abbreviation\", \ + wsm.description as \"description\", \ + wsm.created_at as \"created_at\", \ + wsm.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories) \ + FILTER (WHERE categories.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + coalesce( \ + json_agg(translates_into) \ + FILTER (WHERE translates_into.translated_at IS NOT NULL), \ + '[]'::json \ + ) as \"translates_into\" \ + FROM kolomoni.word_slovene_meaning as wsm \ + INNER JOIN kolomoni.word_meaning as wm \ + ON wsm.word_meaning_id = wm.id \ + LEFT JOIN LATERAL ( \ + SELECT wec.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec \ + WHERE wec.word_meaning_id = wsm.word_meaning_id \ + ) categories ON TRUE \ + LEFT JOIN LATERAL ( \ + SELECT \ + wem.word_meaning_id as \"meaning_id\", \ + wem.description as \"description\", \ + wem.disambiguation as \"disambiguation\", \ + wem.abbreviation as \"abbreviation\", \ + wem.created_at as \"created_at\", \ + wem.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories_on_translated) \ + FILTER (WHERE categories_on_translated.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + translated_at, \ + translated_by \ + FROM kolomoni.word_meaning_translation wmt \ + INNER JOIN kolomoni.word_english_meaning as wem \ + ON wmt.english_word_meaning_id = wem.word_meaning_id \ + LEFT JOIN LATERAL ( \ + SELECT wec_t.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec_t \ + WHERE wec_t.word_meaning_id = wem.word_meaning_id \ + ) categories_on_translated ON TRUE \ + WHERE wmt.slovene_word_meaning_id = wm.id \ + GROUP BY \ + wem.word_meaning_id, \ + wem.description, \ + wem.disambiguation, \ + wem.abbreviation, \ + wem.created_at, \ + wem.last_modified_at, \ + wmt.translated_at, \ + wmt.translated_by \ + ) translates_into ON TRUE \ + WHERE wm.word_id = ws.word_id \ + GROUP BY \ + wsm.word_meaning_id, \ + wsm.disambiguation, \ + wsm.abbreviation, \ + wsm.description, \ + wsm.created_at, \ + wsm.last_modified_at \ + ) meanings ON TRUE \ + WHERE ws.word_id = $1 \ + GROUP BY \ + ws.word_id, \ + ws.lemma, \ + w.created_at, \ + w.last_modified_at", + slovene_word_id.into_uuid() + ) + .fetch_optional(database_connection).await?; + + + let Some(internal_model) = internal_word_with_meanings else { + return Ok(None); + }; + + + Ok(Some( + internal_model + .try_into_external_model() + .map_err(|reason| QueryError::ModelError { reason })?, + )) } pub async fn get_by_exact_lemma( connection: &mut PgConnection, lemma: &str, - ) -> QueryResult> { + ) -> QueryResult> { let intermediate_extended_model = sqlx::query_as!( - super::IntermediateExtendedModel, + super::InternalSloveneWordModel, "SELECT word_id, lemma, created_at, last_modified_at \ FROM kolomoni.word_slovene \ INNER JOIN kolomoni.word \ @@ -94,12 +238,123 @@ impl Query { .fetch_optional(connection) .await?; - Ok(intermediate_extended_model.map(super::IntermediateExtendedModel::into_model)) + Ok(intermediate_extended_model.map(super::InternalSloveneWordModel::into_external_model)) + } + + pub async fn get_by_exact_lemma_with_meanings( + database_connection: &mut PgConnection, + lemma: &str, + ) -> QueryResult> { + let internal_word_with_meanings = sqlx::query_as!( + super::InternalSloveneWordWithMeaningsModel, + "SELECT \ + ws.word_id as \"word_id\", \ + ws.lemma as \"lemma\", \ + w.created_at as \"created_at\", \ + w.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(meanings) \ + FILTER (WHERE meanings.word_meaning_id IS NOT NULL), \ + '[]'::json \ + ) as \"meanings!\" \ + FROM kolomoni.word_slovene as ws \ + INNER JOIN kolomoni.word as w \ + ON ws.word_id = w.id \ + LEFT JOIN LATERAL ( \ + SELECT \ + wsm.word_meaning_id as \"word_meaning_id\", \ + wsm.disambiguation as \"disambiguation\", \ + wsm.abbreviation as \"abbreviation\", \ + wsm.description as \"description\", \ + wsm.created_at as \"created_at\", \ + wsm.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories) \ + FILTER (WHERE categories.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + coalesce( \ + json_agg(translates_into) \ + FILTER (WHERE translates_into.translated_at IS NOT NULL), \ + '[]'::json \ + ) as \"translates_into\" \ + FROM kolomoni.word_slovene_meaning as wsm \ + INNER JOIN kolomoni.word_meaning as wm \ + ON wsm.word_meaning_id = wm.id \ + LEFT JOIN LATERAL ( \ + SELECT wec.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec \ + WHERE wec.word_meaning_id = wsm.word_meaning_id \ + ) categories ON TRUE \ + LEFT JOIN LATERAL ( \ + SELECT \ + wem.word_meaning_id as \"meaning_id\", \ + wem.description as \"description\", \ + wem.disambiguation as \"disambiguation\", \ + wem.abbreviation as \"abbreviation\", \ + wem.created_at as \"created_at\", \ + wem.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories_on_translated) \ + FILTER (WHERE categories_on_translated.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + translated_at, \ + translated_by \ + FROM kolomoni.word_meaning_translation wmt \ + INNER JOIN kolomoni.word_english_meaning as wem \ + ON wmt.english_word_meaning_id = wem.word_meaning_id \ + LEFT JOIN LATERAL ( \ + SELECT wec_t.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec_t \ + WHERE wec_t.word_meaning_id = wem.word_meaning_id \ + ) categories_on_translated ON TRUE \ + WHERE wmt.slovene_word_meaning_id = wm.id \ + GROUP BY \ + wem.word_meaning_id, \ + wem.description, \ + wem.disambiguation, \ + wem.abbreviation, \ + wem.created_at, \ + wem.last_modified_at, \ + wmt.translated_at, \ + wmt.translated_by \ + ) translates_into ON TRUE \ + WHERE wm.word_id = ws.word_id \ + GROUP BY \ + wsm.word_meaning_id, \ + wsm.disambiguation, \ + wsm.abbreviation, \ + wsm.description, \ + wsm.created_at, \ + wsm.last_modified_at \ + ) meanings ON TRUE \ + WHERE ws.lemma = $1 \ + GROUP BY \ + ws.word_id, \ + ws.lemma, \ + w.created_at, \ + w.last_modified_at", + lemma + ) + .fetch_optional(database_connection).await?; + + + let Some(internal_model) = internal_word_with_meanings else { + return Ok(None); + }; + + + Ok(Some( + internal_model + .try_into_external_model() + .map_err(|reason| QueryError::ModelError { reason })?, + )) } pub async fn get_all_slovene_words(connection: &mut PgConnection) -> SloveneWordStream<'_> { let intermediate_word_stream = sqlx::query_as!( - super::IntermediateExtendedModel, + super::InternalSloveneWordModel, "SELECT word_id, lemma, created_at, last_modified_at \ FROM kolomoni.word_slovene \ INNER JOIN kolomoni.word \ @@ -109,4 +364,201 @@ impl Query { SloveneWordStream::new(intermediate_word_stream) } + + pub async fn get_all_slovene_words_with_meanings( + database_connection: &mut PgConnection, + options: SloveneWordsQueryOptions, + ) -> SloveneWordWithMeaningsStream<'_> { + if let Some(only_modified_after) = options.only_words_modified_after { + let internal_words_with_meanings_stream = sqlx::query_as!( + super::InternalSloveneWordWithMeaningsModel, + "SELECT \ + ws.word_id as \"word_id\", \ + ws.lemma as \"lemma\", \ + w.created_at as \"created_at\", \ + w.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(meanings) \ + FILTER (WHERE meanings.word_meaning_id IS NOT NULL), \ + '[]'::json \ + ) as \"meanings!\" \ + FROM kolomoni.word_slovene as ws \ + INNER JOIN kolomoni.word as w \ + ON ws.word_id = w.id \ + LEFT JOIN LATERAL ( \ + SELECT \ + wsm.word_meaning_id as \"word_meaning_id\", \ + wsm.disambiguation as \"disambiguation\", \ + wsm.abbreviation as \"abbreviation\", \ + wsm.description as \"description\", \ + wsm.created_at as \"created_at\", \ + wsm.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories) \ + FILTER (WHERE categories.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + coalesce( \ + json_agg(translates_into) \ + FILTER (WHERE translates_into.translated_at IS NOT NULL), \ + '[]'::json \ + ) as \"translates_into\" \ + FROM kolomoni.word_slovene_meaning as wsm \ + INNER JOIN kolomoni.word_meaning as wm \ + ON wsm.word_meaning_id = wm.id \ + LEFT JOIN LATERAL ( \ + SELECT wec.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec \ + WHERE wec.word_meaning_id = wsm.word_meaning_id \ + ) categories ON TRUE \ + LEFT JOIN LATERAL ( \ + SELECT \ + wem.word_meaning_id as \"meaning_id\", \ + wem.description as \"description\", \ + wem.disambiguation as \"disambiguation\", \ + wem.abbreviation as \"abbreviation\", \ + wem.created_at as \"created_at\", \ + wem.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories_on_translated) \ + FILTER (WHERE categories_on_translated.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + translated_at, \ + translated_by \ + FROM kolomoni.word_meaning_translation wmt \ + INNER JOIN kolomoni.word_english_meaning as wem \ + ON wmt.english_word_meaning_id = wem.word_meaning_id \ + LEFT JOIN LATERAL ( \ + SELECT wec_t.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec_t \ + WHERE wec_t.word_meaning_id = wem.word_meaning_id \ + ) categories_on_translated ON TRUE \ + WHERE wmt.slovene_word_meaning_id = wm.id \ + GROUP BY \ + wem.word_meaning_id, \ + wem.description, \ + wem.disambiguation, \ + wem.abbreviation, \ + wem.created_at, \ + wem.last_modified_at, \ + wmt.translated_at, \ + wmt.translated_by \ + ) translates_into ON TRUE \ + WHERE wm.word_id = ws.word_id \ + GROUP BY \ + wsm.word_meaning_id, \ + wsm.disambiguation, \ + wsm.abbreviation, \ + wsm.description, \ + wsm.created_at, \ + wsm.last_modified_at \ + ) meanings ON TRUE \ + WHERE w.last_modified_at >= $1 \ + GROUP BY \ + ws.word_id, \ + ws.lemma, \ + w.created_at, \ + w.last_modified_at", + only_modified_after + ) + .fetch(database_connection); + + SloveneWordWithMeaningsStream::new(internal_words_with_meanings_stream) + } else { + let internal_words_with_meanings_stream = sqlx::query_as!( + super::InternalSloveneWordWithMeaningsModel, + "SELECT \ + ws.word_id as \"word_id\", \ + ws.lemma as \"lemma\", \ + w.created_at as \"created_at\", \ + w.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(meanings) \ + FILTER (WHERE meanings.word_meaning_id IS NOT NULL), \ + '[]'::json \ + ) as \"meanings!\" \ + FROM kolomoni.word_slovene as ws \ + INNER JOIN kolomoni.word as w \ + ON ws.word_id = w.id \ + LEFT JOIN LATERAL ( \ + SELECT \ + wsm.word_meaning_id as \"word_meaning_id\", \ + wsm.disambiguation as \"disambiguation\", \ + wsm.abbreviation as \"abbreviation\", \ + wsm.description as \"description\", \ + wsm.created_at as \"created_at\", \ + wsm.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories) \ + FILTER (WHERE categories.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + coalesce( \ + json_agg(translates_into) \ + FILTER (WHERE translates_into.translated_at IS NOT NULL), \ + '[]'::json \ + ) as \"translates_into\" \ + FROM kolomoni.word_slovene_meaning as wsm \ + INNER JOIN kolomoni.word_meaning as wm \ + ON wsm.word_meaning_id = wm.id \ + LEFT JOIN LATERAL ( \ + SELECT wec.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec \ + WHERE wec.word_meaning_id = wsm.word_meaning_id \ + ) categories ON TRUE \ + LEFT JOIN LATERAL ( \ + SELECT \ + wem.word_meaning_id as \"meaning_id\", \ + wem.description as \"description\", \ + wem.disambiguation as \"disambiguation\", \ + wem.abbreviation as \"abbreviation\", \ + wem.created_at as \"created_at\", \ + wem.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories_on_translated) \ + FILTER (WHERE categories_on_translated.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + translated_at, \ + translated_by \ + FROM kolomoni.word_meaning_translation wmt \ + INNER JOIN kolomoni.word_english_meaning as wem \ + ON wmt.english_word_meaning_id = wem.word_meaning_id \ + LEFT JOIN LATERAL ( \ + SELECT wec_t.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec_t \ + WHERE wec_t.word_meaning_id = wem.word_meaning_id \ + ) categories_on_translated ON TRUE \ + WHERE wmt.slovene_word_meaning_id = wm.id \ + GROUP BY \ + wem.word_meaning_id, \ + wem.description, \ + wem.disambiguation, \ + wem.abbreviation, \ + wem.created_at, \ + wem.last_modified_at, \ + wmt.translated_at, \ + wmt.translated_by \ + ) translates_into ON TRUE \ + WHERE wm.word_id = ws.word_id \ + GROUP BY \ + wsm.word_meaning_id, \ + wsm.disambiguation, \ + wsm.abbreviation, \ + wsm.description, \ + wsm.created_at, \ + wsm.last_modified_at \ + ) meanings ON TRUE \ + GROUP BY \ + ws.word_id, \ + ws.lemma, \ + w.created_at, \ + w.last_modified_at" + ) + .fetch(database_connection); + + SloveneWordWithMeaningsStream::new(internal_words_with_meanings_stream) + } + } } diff --git a/kolomoni_database/src/entities/word_slovene_meaning/mod.rs b/kolomoni_database/src/entities/word_slovene_meaning/mod.rs index 8177ff8..1826ff6 100644 --- a/kolomoni_database/src/entities/word_slovene_meaning/mod.rs +++ b/kolomoni_database/src/entities/word_slovene_meaning/mod.rs @@ -1,2 +1,7 @@ mod model; +mod mutation; +mod query; + pub use model::*; +pub use mutation::*; +pub use query::*; diff --git a/kolomoni_database/src/entities/word_slovene_meaning/model.rs b/kolomoni_database/src/entities/word_slovene_meaning/model.rs index c8414f1..1cf3664 100644 --- a/kolomoni_database/src/entities/word_slovene_meaning/model.rs +++ b/kolomoni_database/src/entities/word_slovene_meaning/model.rs @@ -1,12 +1,19 @@ +use std::borrow::Cow; + use chrono::{DateTime, Utc}; -use kolomoni_core::id::SloveneWordMeaningId; +use kolomoni_core::id::{CategoryId, EnglishWordMeaningId, SloveneWordMeaningId, UserId}; +use serde::Deserialize; use uuid::Uuid; -use crate::IntoModel; +use crate::{ + entities::InternalCategoryIdOnlyModel, + IntoExternalModel, + TryIntoStronglyTypedInternalModel, +}; -pub struct Model { +pub struct SloveneWordMeaningModel { pub id: SloveneWordMeaningId, pub disambiguation: Option, @@ -21,28 +28,218 @@ pub struct Model { } +pub struct SloveneWordMeaningModelWithCategoriesAndTranslations { + pub id: SloveneWordMeaningId, + + pub disambiguation: Option, + + pub abbreviation: Option, + + pub description: Option, + + pub created_at: DateTime, + + pub last_modified_at: DateTime, + + pub categories: Vec, + + pub translates_into: Vec, +} + + +#[derive(Deserialize)] +pub struct InternalSloveneWordMeaningModelWithCategoriesAndTranslations { + pub id: Uuid, + + pub disambiguation: Option, + + pub abbreviation: Option, + + pub description: Option, + + pub created_at: DateTime, + + pub last_modified_at: DateTime, + + pub categories: Vec, + + pub translates_into: Vec, +} + +impl IntoExternalModel for InternalSloveneWordMeaningModelWithCategoriesAndTranslations { + type ExternalModel = SloveneWordMeaningModelWithCategoriesAndTranslations; + + fn into_external_model(self) -> Self::ExternalModel { + Self::ExternalModel { + id: SloveneWordMeaningId::new(self.id), + disambiguation: self.disambiguation, + abbreviation: self.abbreviation, + description: self.description, + created_at: self.created_at, + last_modified_at: self.last_modified_at, + categories: self + .categories + .into_iter() + .map(|internal_category| internal_category.into_external_model()) + .collect(), + translates_into: self + .translates_into + .into_iter() + .map(|internal_translation| internal_translation.into_external_model()) + .collect(), + } + } +} + + +pub struct SloveneWordMeaningModelWithWeaklyTypedCategoriesAndTranslations { + pub word_meaning_id: Uuid, + + pub disambiguation: Option, + + pub abbreviation: Option, + + pub description: Option, + + pub created_at: DateTime, + + pub last_modified_at: DateTime, + + pub categories: serde_json::Value, + + pub translates_into: serde_json::Value, +} + +impl TryIntoStronglyTypedInternalModel + for SloveneWordMeaningModelWithWeaklyTypedCategoriesAndTranslations +{ + type InternalModel = InternalSloveneWordMeaningModelWithCategoriesAndTranslations; + type Error = Cow<'static, str>; + + fn try_into_strongly_typed_internal_model(self) -> Result { + let internal_categories = serde_json::from_value::>( + self.categories, + ) + .map_err(|error| { + Cow::from(format!( + "failed to parse returned JSON as internal ID-only categories model: {}", + error + )) + })?; + + let internal_translates_into = serde_json::from_value::< + Vec, + >(self.translates_into) + .map_err(|error| { + Cow::from(format!( + "failed to parse returned JSON as internal english translations model: {}", + error + )) + })?; + + + Ok(Self::InternalModel { + id: self.word_meaning_id, + disambiguation: self.disambiguation, + abbreviation: self.abbreviation, + description: self.description, + created_at: self.created_at, + last_modified_at: self.last_modified_at, + categories: internal_categories, + translates_into: internal_translates_into, + }) + } +} + + + +pub struct TranslatesIntoEnglishWordMeaningModel { + pub word_meaning_id: EnglishWordMeaningId, + + pub disambiguation: Option, + + pub abbreviation: Option, + + pub description: Option, + + pub created_at: DateTime, + + pub last_modified_at: DateTime, + + pub categories: Vec, + + pub translated_at: DateTime, + + pub translated_by: Option, +} + +#[derive(Deserialize)] +pub struct InternalTranslatesIntoEnglishWordModel { + pub word_meaning_id: Uuid, + + pub disambiguation: Option, + + pub abbreviation: Option, + + pub description: Option, + + pub created_at: DateTime, + + pub last_modified_at: DateTime, + + pub categories: Vec, + + pub translated_at: DateTime, + + pub translated_by: Option, +} + +impl IntoExternalModel for InternalTranslatesIntoEnglishWordModel { + type ExternalModel = TranslatesIntoEnglishWordMeaningModel; + + fn into_external_model(self) -> Self::ExternalModel { + Self::ExternalModel { + word_meaning_id: EnglishWordMeaningId::new(self.word_meaning_id), + disambiguation: self.disambiguation, + abbreviation: self.abbreviation, + description: self.description, + created_at: self.created_at, + last_modified_at: self.last_modified_at, + categories: self + .categories + .into_iter() + .map(|internal_model| internal_model.into_external_model()) + .collect(), + translated_at: self.translated_at, + translated_by: self.translated_by.map(UserId::new), + } + } +} + + + -pub(super) struct IntermediateModel { - pub(super) word_meaning_id: Uuid, +pub struct InternalSloveneWordMeaningModel { + pub(crate) word_meaning_id: Uuid, - pub(super) disambiguation: Option, + pub(crate) disambiguation: Option, - pub(super) abbreviation: Option, + pub(crate) abbreviation: Option, - pub(super) description: Option, + pub(crate) description: Option, - pub(super) created_at: DateTime, + pub(crate) created_at: DateTime, - pub(super) last_modified_at: DateTime, + pub(crate) last_modified_at: DateTime, } -impl IntoModel for IntermediateModel { - type Model = Model; +impl IntoExternalModel for InternalSloveneWordMeaningModel { + type ExternalModel = SloveneWordMeaningModel; - fn into_model(self) -> Self::Model { + fn into_external_model(self) -> Self::ExternalModel { let id = SloveneWordMeaningId::new(self.word_meaning_id); - Self::Model { + Self::ExternalModel { id, disambiguation: self.disambiguation, abbreviation: self.abbreviation, diff --git a/kolomoni_database/src/entities/word_slovene_meaning/mutation.rs b/kolomoni_database/src/entities/word_slovene_meaning/mutation.rs new file mode 100644 index 0000000..9e9bc95 --- /dev/null +++ b/kolomoni_database/src/entities/word_slovene_meaning/mutation.rs @@ -0,0 +1,168 @@ +use std::borrow::Cow; + +use chrono::Utc; +use kolomoni_core::id::{SloveneWordId, SloveneWordMeaningId}; +use sqlx::{PgConnection, Postgres, QueryBuilder}; + +use super::SloveneWordMeaningModel; +use crate::{entities::InternalSloveneWordMeaningModel, IntoExternalModel, QueryError, QueryResult}; + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct NewSloveneWordMeaning { + pub disambiguation: Option, + + pub abbreviation: Option, + + pub description: Option, +} + + +#[derive(Clone, PartialEq, Eq, Debug)] +pub struct SloveneWordMeaningUpdate { + pub disambiguation: Option>, + + pub abbreviation: Option>, + + pub description: Option>, +} + +impl SloveneWordMeaningUpdate { + fn no_values_to_update(&self) -> bool { + self.disambiguation.is_none() && self.abbreviation.is_none() && self.description.is_none() + } +} + + +fn build_slovene_word_meaning_update_query( + slovene_word_meaning_id: SloveneWordMeaningId, + values_to_update: SloveneWordMeaningUpdate, +) -> QueryBuilder<'static, Postgres> { + let mut update_query_builder: QueryBuilder = + QueryBuilder::new("UPDATE kolomoni.word_slovene_meaning SET "); + + let mut separated_set_expressions = update_query_builder.separated(", "); + + + if let Some(new_disambiguation) = values_to_update.disambiguation { + separated_set_expressions.push_unseparated("disambiguation = "); + separated_set_expressions.push_bind(new_disambiguation); + } + + if let Some(new_abbreviation) = values_to_update.abbreviation { + separated_set_expressions.push_unseparated("abbreviation = "); + separated_set_expressions.push_bind(new_abbreviation); + } + + if let Some(new_description) = values_to_update.description { + separated_set_expressions.push_unseparated("description = "); + separated_set_expressions.push_bind(new_description); + } + + + update_query_builder.push(" WHERE word_meaning_id = "); + update_query_builder.push_bind(slovene_word_meaning_id.into_uuid()); + + update_query_builder +} + + + + +pub struct SloveneWordMeaningMutation; + +impl SloveneWordMeaningMutation { + pub async fn create( + database_connection: &mut PgConnection, + slovene_word_id: SloveneWordId, + meaning_to_create: NewSloveneWordMeaning, + ) -> QueryResult { + let new_meaning_id = SloveneWordMeaningId::generate(); + let new_meaning_created_at = Utc::now(); + let new_meaning_last_modified_at = new_meaning_created_at; + + + let internal_meaning_query_result = sqlx::query!( + "INSERT INTO kolomoni.word_meaning (id, word_id) \ + VALUES ($1, $2)", + new_meaning_id.into_uuid(), + slovene_word_id.into_uuid() + ) + .execute(&mut *database_connection) + .await?; + + if internal_meaning_query_result.rows_affected() != 1 { + return Err(QueryError::database_inconsistency(format!( + "inserted word meaning, but got abnormal number of affected rows ({})", + internal_meaning_query_result.rows_affected() + ))); + } + + + let internal_slovene_meaning = sqlx::query_as!( + InternalSloveneWordMeaningModel, + "INSERT INTO kolomoni.word_slovene_meaning \ + (word_meaning_id, disambiguation, abbreviation, \ + description, created_at, last_modified_at) \ + VALUES ($1, $2, $3, $4, $5, $6) \ + RETURNING \ + word_meaning_id, disambiguation, abbreviation, \ + description, created_at, last_modified_at", + new_meaning_id.into_uuid(), + meaning_to_create.disambiguation, + meaning_to_create.abbreviation, + meaning_to_create.description, + new_meaning_created_at, + new_meaning_last_modified_at + ) + .fetch_one(database_connection) + .await?; + + + Ok(internal_slovene_meaning.into_external_model()) + } + + pub async fn update( + database_connection: &mut PgConnection, + slovene_word_meaning_id: SloveneWordMeaningId, + values_to_update: SloveneWordMeaningUpdate, + ) -> QueryResult { + if values_to_update.no_values_to_update() { + return Ok(true); + } + + + let mut update_query_builder = + build_slovene_word_meaning_update_query(slovene_word_meaning_id, values_to_update); + + let query_result = update_query_builder + .build() + .execute(database_connection) + .await?; + + + Ok(query_result.rows_affected() == 1) + } + + pub async fn delete( + database_connection: &mut PgConnection, + slovene_word_meaning_id: SloveneWordMeaningId, + ) -> QueryResult { + let query_result = sqlx::query!( + "DELETE FROM kolomoni.word_slovene_meaning \ + WHERE word_meaning_id = $1", + slovene_word_meaning_id.into_uuid() + ) + .execute(database_connection) + .await?; + + if query_result.rows_affected() > 1 { + return Err(QueryError::database_inconsistency(format!( + "while deleting slovene word meaning {} more than one row was affected ({})", + slovene_word_meaning_id, + query_result.rows_affected() + ))); + } + + Ok(query_result.rows_affected() == 1) + } +} diff --git a/kolomoni_database/src/entities/word_slovene_meaning/query.rs b/kolomoni_database/src/entities/word_slovene_meaning/query.rs new file mode 100644 index 0000000..7a9beea --- /dev/null +++ b/kolomoni_database/src/entities/word_slovene_meaning/query.rs @@ -0,0 +1,222 @@ +use kolomoni_core::id::{SloveneWordId, SloveneWordMeaningId}; +use sqlx::PgConnection; + +use super::SloveneWordMeaningModelWithCategoriesAndTranslations; +use crate::{ + entities::SloveneWordMeaningModelWithWeaklyTypedCategoriesAndTranslations, + IntoExternalModel, + QueryError, + QueryResult, + TryIntoStronglyTypedInternalModel, +}; + +pub struct SloveneWordMeaningQuery; + +impl SloveneWordMeaningQuery { + pub async fn get_all_by_slovene_word_id( + database_connection: &mut PgConnection, + slovene_word_id: SloveneWordId, + ) -> QueryResult> { + let internal_meanings_weak = sqlx::query_as!( + SloveneWordMeaningModelWithWeaklyTypedCategoriesAndTranslations, + "SELECT \ + wsm.word_meaning_id as \"word_meaning_id\", \ + wsm.disambiguation as \"disambiguation\", \ + wsm.abbreviation as \"abbreviation\", \ + wsm.description as \"description\", \ + wsm.created_at as \"created_at\", \ + wsm.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories) \ + FILTER (WHERE categories.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + coalesce( \ + json_agg(translates_into) \ + FILTER (WHERE translates_into.translated_at IS NOT NULL), \ + '[]'::json \ + ) as \"translates_into\" \ + FROM kolomoni.word_slovene_meaning as wsm \ + INNER JOIN kolomoni.word_meaning as wm \ + ON wsm.word_meaning_id = wm.id \ + LEFT JOIN LATERAL ( \ + SELECT wec.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec \ + WHERE wec.word_meaning_id = wsm.word_meaning_id \ + ) categories ON TRUE \ + LEFT JOIN LATERAL ( \ + SELECT \ + wem.word_meaning_id as \"meaning_id\", \ + wem.description as \"description\", \ + wem.disambiguation as \"disambiguation\", \ + wem.abbreviation as \"abbreviation\", \ + wem.created_at as \"created_at\", \ + wem.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories_on_translated) \ + FILTER (WHERE categories_on_translated.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + translated_at, \ + translated_by \ + FROM kolomoni.word_meaning_translation wmt \ + INNER JOIN kolomoni.word_english_meaning as wem \ + ON wmt.english_word_meaning_id = wem.word_meaning_id \ + LEFT JOIN LATERAL ( \ + SELECT wec_t.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec_t \ + WHERE wec_t.word_meaning_id = wem.word_meaning_id \ + ) categories_on_translated ON TRUE \ + WHERE wmt.slovene_word_meaning_id = wm.id \ + GROUP BY \ + wem.word_meaning_id, \ + wem.description, \ + wem.disambiguation, \ + wem.abbreviation, \ + wem.created_at, \ + wem.last_modified_at, \ + wmt.translated_at, \ + wmt.translated_by \ + ) translates_into ON TRUE \ + WHERE wm.word_id = $1 \ + GROUP BY \ + wsm.word_meaning_id, \ + wsm.disambiguation, \ + wsm.abbreviation, \ + wsm.description, \ + wsm.created_at, \ + wsm.last_modified_at", + slovene_word_id.into_uuid() + ) + .fetch_all(database_connection) + .await?; + + + let mut external_meanings = Vec::with_capacity(internal_meanings_weak.len()); + + for weak_internal_meaning in internal_meanings_weak { + let external_meaning = weak_internal_meaning + .try_into_strongly_typed_internal_model() + .map_err(|reason| QueryError::ModelError { reason })? + .into_external_model(); + + external_meanings.push(external_meaning); + } + + + Ok(external_meanings) + } + + + pub async fn get( + database_connection: &mut PgConnection, + slovene_word_id: SloveneWordId, + slovene_word_meaning_id: SloveneWordMeaningId, + ) -> QueryResult> { + let internal_meaning_weak = sqlx::query_as!( + SloveneWordMeaningModelWithWeaklyTypedCategoriesAndTranslations, + "SELECT \ + wsm.word_meaning_id as \"word_meaning_id\", \ + wsm.disambiguation as \"disambiguation\", \ + wsm.abbreviation as \"abbreviation\", \ + wsm.description as \"description\", \ + wsm.created_at as \"created_at\", \ + wsm.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories) \ + FILTER (WHERE categories.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + coalesce( \ + json_agg(translates_into) \ + FILTER (WHERE translates_into.translated_at IS NOT NULL), \ + '[]'::json \ + ) as \"translates_into\" \ + FROM kolomoni.word_slovene_meaning as wsm \ + INNER JOIN kolomoni.word_meaning as wm \ + ON wsm.word_meaning_id = wm.id \ + LEFT JOIN LATERAL ( \ + SELECT wec.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec \ + WHERE wec.word_meaning_id = wsm.word_meaning_id \ + ) categories ON TRUE \ + LEFT JOIN LATERAL ( \ + SELECT \ + wem.word_meaning_id as \"meaning_id\", \ + wem.description as \"description\", \ + wem.disambiguation as \"disambiguation\", \ + wem.abbreviation as \"abbreviation\", \ + wem.created_at as \"created_at\", \ + wem.last_modified_at as \"last_modified_at\", \ + coalesce( \ + json_agg(categories_on_translated) \ + FILTER (WHERE categories_on_translated.category_id IS NOT NULL), \ + '[]'::json \ + ) as \"categories\", \ + translated_at, \ + translated_by \ + FROM kolomoni.word_meaning_translation wmt \ + INNER JOIN kolomoni.word_english_meaning as wem \ + ON wmt.english_word_meaning_id = wem.word_meaning_id \ + LEFT JOIN LATERAL ( \ + SELECT wec_t.category_id as \"category_id\" \ + FROM kolomoni.word_meaning_category wec_t \ + WHERE wec_t.word_meaning_id = wem.word_meaning_id \ + ) categories_on_translated ON TRUE \ + WHERE wmt.slovene_word_meaning_id = wm.id \ + GROUP BY \ + wem.word_meaning_id, \ + wem.description, \ + wem.disambiguation, \ + wem.abbreviation, \ + wem.created_at, \ + wem.last_modified_at, \ + wmt.translated_at, \ + wmt.translated_by \ + ) translates_into ON TRUE \ + WHERE wm.word_id = $1 AND wm.id = $2 \ + GROUP BY \ + wsm.word_meaning_id, \ + wsm.disambiguation, \ + wsm.abbreviation, \ + wsm.description, \ + wsm.created_at, \ + wsm.last_modified_at", + slovene_word_id.into_uuid(), + slovene_word_meaning_id.into_uuid() + ) + .fetch_optional(database_connection) + .await?; + + let Some(internal_meaning_weak) = internal_meaning_weak else { + return Ok(None); + }; + + + Ok(Some( + internal_meaning_weak + .try_into_strongly_typed_internal_model() + .map_err(|reason| QueryError::ModelError { reason })? + .into_external_model(), + )) + } + + + pub async fn exists_by_id( + database_connection: &mut PgConnection, + slovene_word_meaning_id: SloveneWordMeaningId, + ) -> QueryResult { + let exists = sqlx::query_scalar!( + "SELECT EXISTS (\ + SELECT 1 \ + FROM kolomoni.word_slovene_meaning \ + WHERE word_meaning_id = $1 + )", + slovene_word_meaning_id.into_uuid() + ) + .fetch_one(database_connection) + .await?; + + Ok(exists.unwrap_or(false)) + } +} diff --git a/kolomoni_database/src/lib.rs b/kolomoni_database/src/lib.rs index bae6e62..fefc477 100644 --- a/kolomoni_database/src/lib.rs +++ b/kolomoni_database/src/lib.rs @@ -20,20 +20,65 @@ pub enum QueryError { #[error("model error: {}", .reason)] ModelError { reason: Cow<'static, str> }, + + #[error("database inconsistency: {}", .problem)] + DatabaseInconsistencyError { problem: Cow<'static, str> }, +} + +impl QueryError { + pub fn database_inconsistency(problem: R) -> Self + where + R: Into>, + { + Self::DatabaseInconsistencyError { + problem: problem.into(), + } + } } + + pub type QueryResult = Result; -pub(crate) trait IntoModel { - type Model; +pub trait IntoStronglyTypedInternalModel { + type InternalModel; + + fn into_strongly_typed_internal_model(self) -> Self::InternalModel; +} + +pub trait TryIntoStronglyTypedInternalModel { + type InternalModel; + type Error; + + fn try_into_strongly_typed_internal_model(self) -> Result; +} + + + +pub trait IntoExternalModel { + type ExternalModel; + + fn into_external_model(self) -> Self::ExternalModel; +} + +pub trait TryIntoExternalModel { + type ExternalModel; + type Error; + + fn try_into_external_model(self) -> Result; +} + + +pub trait IntoInternalModel { + type InternalModel; - fn into_model(self) -> Self::Model; + fn into_internal_model(self) -> Self::InternalModel; } -pub(crate) trait TryIntoModel { - type Model; +pub trait TryIntoInternalModel { + type InternalModel; type Error; - fn try_into_model(self) -> Result; + fn try_into_internal_model(self) -> Result; } diff --git a/kolomoni_migrations/src/lib.rs b/kolomoni_migrations/src/lib.rs new file mode 100644 index 0000000..9e28598 --- /dev/null +++ b/kolomoni_migrations/src/lib.rs @@ -0,0 +1,5 @@ +pub(crate) mod cli; +pub(crate) mod commands; +pub mod migrations; + +pub use kolomoni_migrations_core as core; diff --git a/kolomoni_migrations/src/main.rs b/kolomoni_migrations/src/main.rs index a0f1a65..29cc16a 100644 --- a/kolomoni_migrations/src/main.rs +++ b/kolomoni_migrations/src/main.rs @@ -1,14 +1,11 @@ use clap::Parser; use cli::{CliArgs, CliCommand}; use commands::{down::cli_down, generate::cli_generate, initialize::cli_initialize, up::cli_up}; -use kolomoni_migrations_macros::embed_migrations; use miette::{Context, IntoDiagnostic, Result}; -pub(crate) mod cli; -pub(crate) mod commands; - - -embed_migrations!("migrations", "..", "../kolomoni_migrations"); +mod cli; +mod commands; +mod migrations; pub fn main() -> Result<()> { @@ -32,7 +29,7 @@ pub fn main() -> Result<()> { // TODO Required CLI commands: // - [DONE, needs a style pass] initialize: creates the migration directory if needed -// - [DONE, needs a style pass] generate: generates a new empty migration (runs initialize automatically if needeed) +// - [DONE, needs a style pass] generate: generates a new empty migration (runs initialize automatically if needed) // - [PENDING, medium priority] fresh: drops all tables from the database and reapplies all migrations // - [PENDING, low priority] refresh: rolls back all migrations, then reapplies all of them // - [PENDING, low priority] reset: rolls back all migrations diff --git a/kolomoni_migrations/src/migrations.rs b/kolomoni_migrations/src/migrations.rs new file mode 100644 index 0000000..c450de1 --- /dev/null +++ b/kolomoni_migrations/src/migrations.rs @@ -0,0 +1,3 @@ +use kolomoni_migrations_macros::embed_migrations; + +embed_migrations!("migrations", "..", "../kolomoni_migrations"); diff --git a/kolomoni_migrations_core/src/lib.rs b/kolomoni_migrations_core/src/lib.rs index 848e424..ebc9647 100644 --- a/kolomoni_migrations_core/src/lib.rs +++ b/kolomoni_migrations_core/src/lib.rs @@ -18,7 +18,7 @@ pub(crate) mod remote; pub mod sha256; -/// Descibes a migration's status: either pending or applied (at some moment in time). +/// Describes a migration's status: either pending or applied (at some moment in time). #[derive(Copy, Clone, Debug, PartialEq, Eq)] pub enum MigrationStatus { /// The migration is pending, which means its details reside in the migrations directory @@ -219,7 +219,7 @@ async fn execute_up_sql_and_update_migrations_table( // Executes the entire up.sql script of the migration. // FIXME A concurrent index can't be created for some reason, even when there is no transaction. - // This is currently avoided by just not creating databases and concurrent indexes, but we should + // This is currently avoided by just not creating databases and concurrent indexes, but we should // probably look into why this is happening. Maybe it could be because `up_sql` is a multiline script? // What would happen if we split at ";" (or something like that)? database_connection diff --git a/kolomoni_migrations_core/src/migrations.rs b/kolomoni_migrations_core/src/migrations.rs index bc2931a..8fc387b 100644 --- a/kolomoni_migrations_core/src/migrations.rs +++ b/kolomoni_migrations_core/src/migrations.rs @@ -113,6 +113,15 @@ impl Default for MigrationsWithStatusOptions { } } +impl MigrationsWithStatusOptions { + pub fn strict() -> Self { + Self { + require_up_hashes_match: true, + require_down_hashes_match: true, + } + } +} + diff --git a/kolomoni_migrations_macros/src/lib.rs b/kolomoni_migrations_macros/src/lib.rs index 58c846d..9c1b896 100644 --- a/kolomoni_migrations_macros/src/lib.rs +++ b/kolomoni_migrations_macros/src/lib.rs @@ -171,11 +171,8 @@ pub fn embed_migrations(input: TokenStream) -> TokenStream { sha256_to_u8_array_token_stream(&rust_up.sha256_hash); - // Because we are actually in a submodule `migrations` that we ourselves emit, - // we need to escape one level more. - let injected_module_path_relative_to_caller = Path::new("..") - .join(&migration_directory_path_relative_to_caller_source_file) - .join("mod.rs"); + let injected_module_path_relative_to_caller = + migration_directory_path_relative_to_caller_source_file.join("mod.rs"); let injected_module_path_relative_to_caller_str = injected_module_path_relative_to_caller @@ -243,11 +240,8 @@ pub fn embed_migrations(input: TokenStream) -> TokenStream { sha256_to_u8_array_token_stream(&rust_down.sha256_hash); - // Because we are actually in a submodule `migrations` that we ourselves emit, - // we need to escape one level more. - let injected_module_path_relative_to_caller = Path::new("..") - .join(&migration_directory_path_relative_to_caller_source_file) - .join("mod.rs"); + let injected_module_path_relative_to_caller = + migration_directory_path_relative_to_caller_source_file.join("mod.rs"); let injected_module_path_relative_to_caller_str = injected_module_path_relative_to_caller @@ -307,22 +301,20 @@ pub fn embed_migrations(input: TokenStream) -> TokenStream { quote! { - pub mod migrations { - #(#code_to_prepend_to_module)* - - use kolomoni_migrations_core::migrations::MigrationManager; - use kolomoni_migrations_core::migrations::EmbeddedMigration; - use kolomoni_migrations_core::migrations::EmbeddedMigrationScript; - use kolomoni_migrations_core::identifier::MigrationIdentifier; - use kolomoni_migrations_core::sha256::Sha256Hash; - - pub fn manager() -> MigrationManager { - MigrationManager::new_embedded( - vec![ - #(#embedded_migration_constructions),* - ] - ) - } + #(#code_to_prepend_to_module)* + + use kolomoni_migrations_core::migrations::MigrationManager; + use kolomoni_migrations_core::migrations::EmbeddedMigration; + use kolomoni_migrations_core::migrations::EmbeddedMigrationScript; + use kolomoni_migrations_core::identifier::MigrationIdentifier; + use kolomoni_migrations_core::sha256::Sha256Hash; + + pub fn manager() -> MigrationManager { + MigrationManager::new_embedded( + vec![ + #(#embedded_migration_constructions),* + ] + ) } } .into() diff --git a/kolomoni_openapi/src/main.rs b/kolomoni_openapi/src/main.rs index 104c90c..6c75d1a 100644 --- a/kolomoni_openapi/src/main.rs +++ b/kolomoni_openapi/src/main.rs @@ -132,7 +132,7 @@ use utoipa_rapidoc::RapiDoc; dictionary::slovene_word::SloveneWordUpdateRequest, // dictionary/english_word.rs - dictionary::english_word::EnglishWord, + dictionary::english_word::EnglishWordWithMeanings, dictionary::english_word::EnglishWordsResponse, dictionary::english_word::EnglishWordFilters, dictionary::english_word::EnglishWordsListRequest, @@ -146,7 +146,7 @@ use utoipa_rapidoc::RapiDoc; dictionary::suggestions::TranslationSuggestionDeletionRequest, // dictionary/translations.rs - dictionary::translations::TranslationRequest, + dictionary::translations::TranslationCreationRequest, dictionary::translations::TranslationDeletionRequest, // dictionary/search.rs diff --git a/kolomoni_test/tests/end_to_end/words.rs b/kolomoni_test/tests/end_to_end/words.rs index 4c4517f..9f8d60c 100644 --- a/kolomoni_test/tests/end_to_end/words.rs +++ b/kolomoni_test/tests/end_to_end/words.rs @@ -28,7 +28,7 @@ use kolomoni::api::v1::dictionary::{ SloveneWordsResponse, }, suggestions::{TranslationSuggestionDeletionRequest, TranslationSuggestionRequest}, - translations::{TranslationDeletionRequest, TranslationRequest}, + translations::{TranslationCreationRequest, TranslationDeletionRequest}, }; use kolomoni_test_util::prelude::*; @@ -731,9 +731,9 @@ async fn word_creation_with_suggestions_and_translations_works() { // Creating a translation should require authentication. server .request(Method::POST, "/api/v1/dictionary/translation") - .with_json_body(TranslationRequest { - english_word_id: word_ability.id.to_string(), - slovene_word_id: word_sposobnost.id.to_string(), + .with_json_body(TranslationCreationRequest { + english_word_meaning_id: word_ability.id.to_string(), + slovene_word_meaning_id: word_sposobnost.id.to_string(), }) .send() .await @@ -742,9 +742,9 @@ async fn word_creation_with_suggestions_and_translations_works() { // The endpoint should require proper permissions. server .request(Method::POST, "/api/v1/dictionary/translation") - .with_json_body(TranslationRequest { - english_word_id: word_ability.id.to_string(), - slovene_word_id: word_sposobnost.id.to_string(), + .with_json_body(TranslationCreationRequest { + english_word_meaning_id: word_ability.id.to_string(), + slovene_word_meaning_id: word_sposobnost.id.to_string(), }) .with_access_token(&normal_user_access_token) .send() @@ -754,9 +754,9 @@ async fn word_creation_with_suggestions_and_translations_works() { // The request should fail if any UUIDs are invalid. server .request(Method::POST, "/api/v1/dictionary/translation") - .with_json_body(TranslationRequest { - english_word_id: "asdo214sdaf".to_string(), - slovene_word_id: word_sposobnost.id.to_string(), + .with_json_body(TranslationCreationRequest { + english_word_meaning_id: "asdo214sdaf".to_string(), + slovene_word_meaning_id: word_sposobnost.id.to_string(), }) .with_access_token(&admin_user_access_token) .send() @@ -766,9 +766,9 @@ async fn word_creation_with_suggestions_and_translations_works() { // The request should fail if any UUIDs don't exist. server .request(Method::POST, "/api/v1/dictionary/translation") - .with_json_body(TranslationRequest { - english_word_id: "018dcd50-8e5f-7e1e-8437-60898a3dc18c".to_string(), - slovene_word_id: word_sposobnost.id.to_string(), + .with_json_body(TranslationCreationRequest { + english_word_meaning_id: "018dcd50-8e5f-7e1e-8437-60898a3dc18c".to_string(), + slovene_word_meaning_id: word_sposobnost.id.to_string(), }) .with_access_token(&admin_user_access_token) .send() @@ -781,9 +781,9 @@ async fn word_creation_with_suggestions_and_translations_works() { let translation_response = server .request(Method::POST, "/api/v1/dictionary/translation") - .with_json_body(TranslationRequest { - english_word_id: word_ability.id.to_string(), - slovene_word_id: word_sposobnost.id.to_string(), + .with_json_body(TranslationCreationRequest { + english_word_meaning_id: word_ability.id.to_string(), + slovene_word_meaning_id: word_sposobnost.id.to_string(), }) .with_access_token(&admin_user_access_token) .send() @@ -830,9 +830,9 @@ async fn word_creation_with_suggestions_and_translations_works() { // Trying to create the same translation again should fail with 409 Conflict. server .request(Method::POST, "/api/v1/dictionary/translation") - .with_json_body(TranslationRequest { - english_word_id: word_ability.id.to_string(), - slovene_word_id: word_sposobnost.id.to_string(), + .with_json_body(TranslationCreationRequest { + english_word_meaning_id: word_ability.id.to_string(), + slovene_word_meaning_id: word_sposobnost.id.to_string(), }) .with_access_token(&admin_user_access_token) .send() diff --git a/kolomoni_test_util/src/sample_words.rs b/kolomoni_test_util/src/sample_words.rs index 869d4a9..bbd0224 100644 --- a/kolomoni_test_util/src/sample_words.rs +++ b/kolomoni_test_util/src/sample_words.rs @@ -1,5 +1,5 @@ use http::{Method, StatusCode}; -use kolomoni::api::v1::dictionary::{english_word::{EnglishWord, EnglishWordCreationRequest, EnglishWordCreationResponse}, slovene_word::{SloveneWord, SloveneWordCreationRequest, SloveneWordCreationResponse}, suggestions::TranslationSuggestionRequest, translations::TranslationRequest}; +use kolomoni::api::v1::dictionary::{english_word::{EnglishWordWithMeanings, EnglishWordCreationRequest, EnglishWordCreationResponse}, slovene_word::{SloveneWord, SloveneWordCreationRequest, SloveneWordCreationResponse}, suggestions::TranslationSuggestionRequest, translations::TranslationCreationRequest}; use uuid::Uuid; use crate::TestServer; @@ -57,7 +57,7 @@ impl SampleEnglishWord { &self, server: &TestServer, access_token: &str, - ) -> EnglishWord { + ) -> EnglishWordWithMeanings { let creation_response = server.request( Method::POST, "/api/v1/dictionary/english", @@ -196,9 +196,9 @@ pub async fn link_word_as_translation( Method::POST, "/api/v1/dictionary/translation" ) - .with_json_body(TranslationRequest { - english_word_id: english_word_id.to_string(), - slovene_word_id: slovene_word_id.to_string(), + .with_json_body(TranslationCreationRequest { + english_word_meaning_id: english_word_id.to_string(), + slovene_word_meaning_id: slovene_word_id.to_string(), }) .with_access_token(access_token) .send()