From eff9461be85e3c1b882310e0c757d5eac817aabd Mon Sep 17 00:00:00 2001 From: Christian Beilschmidt Date: Fri, 7 Aug 2026 18:27:28 +0200 Subject: [PATCH 01/11] refactor: migrate to toasty --- backend/Cargo.lock | 868 ++++++++++++------ backend/Cargo.toml | 13 +- backend/src/config.rs | 11 +- backend/src/db/mod.rs | 205 ++--- backend/src/db/model.rs | 358 ++++---- backend/src/db/schema.rs | 126 --- backend/src/db/tests.rs | 104 +++ backend/src/db/util.rs | 71 ++ backend/src/jobs.rs | 468 +++++----- .../biodiversity_sensitive_areas/mod.rs | 614 +++++++------ backend/src/processes/habitat_distance.rs | 281 +++--- backend/src/processes/parameters/units.rs | 27 - backend/src/server.rs | 4 +- backend/src/util.rs | 24 + 14 files changed, 1797 insertions(+), 1377 deletions(-) delete mode 100644 backend/src/db/schema.rs create mode 100644 backend/src/db/tests.rs create mode 100644 backend/src/db/util.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 40a72e6..8de746a 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -10,9 +10,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -25,9 +25,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -129,6 +129,12 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "array-init" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d62b7694a562cdf5a74227903507c56ab2cc8bdd1f781ed5cb4cf9c9f810bfc" + [[package]] name = "arraydeque" version = "0.5.1" @@ -149,9 +155,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "3976abdc8fe7d1133d43d304afd42abdf5bc3e1319d263d223bde07b5efc4be8" dependencies = [ "compression-codecs", "compression-core", @@ -310,18 +316,6 @@ version = "1.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" -[[package]] -name = "bb8" -version = "0.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "457d7ed3f888dfd2c7af56d4975cade43c622f74bdcddfed6d4352f57acc6310" -dependencies = [ - "futures-util", - "parking_lot", - "portable-atomic", - "tokio", -] - [[package]] name = "biois" version = "0.1.0" @@ -330,13 +324,8 @@ dependencies = [ "approx 0.6.0-rc2", "async-trait", "axum", - "bb8", "chrono", "config", - "diesel", - "diesel-async", - "diesel-derive-enum", - "diesel_migrations", "futures", "geo-types", "geoengine-api-client", @@ -348,10 +337,12 @@ dependencies = [ "ogcapi", "pretty_assertions", "reqwest", - "schemars 1.2.1", + "schemars 1.2.2", "serde", "serde_json", "tempfile", + "toasty", + "toasty-cli", "tokio", "tower", "tracing", @@ -363,6 +354,32 @@ dependencies = [ "wkt", ] +[[package]] +name = "bit-set" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56d87354e4229f54a44f7bf2435906a4656dba36026ab6eaca629a2c436a691c" +dependencies = [ + "bit-vec", + "serde", +] + +[[package]] +name = "bit-vec" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5727b15fa97d4f4fee0a3b7c3d550ed0269f54329207b86388de918604e31269" +dependencies = [ + "borsh", + "serde", +] + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.13.1" @@ -390,6 +407,30 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borsh" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88b7ea17d208c4193f2c1e6de3c35fe71f98c96982d5ced308bdcc749ff6e1f" +dependencies = [ + "borsh-derive", + "bytes", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8f347189c62a579b8cd5f80714efa178f52e461dc2e6d701d264f5ff22e566c" +dependencies = [ + "once_cell", + "proc-macro-crate", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "bs58" version = "0.5.1" @@ -416,6 +457,12 @@ version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" +[[package]] +name = "by_address" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" + [[package]] name = "byteorder" version = "1.5.0" @@ -498,9 +545,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.4" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" dependencies = [ "clap_builder", "clap_derive", @@ -508,9 +555,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.2" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" dependencies = [ "anstream", "anstyle", @@ -524,7 +571,7 @@ version = "4.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" dependencies = [ - "heck 0.5.0", + "heck", "proc-macro2", "quote", "syn 3.0.3", @@ -584,15 +631,6 @@ version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "config" version = "0.15.25" @@ -608,11 +646,23 @@ dependencies = [ "serde-untagged", "serde_core", "serde_json", - "toml 1.1.3+spec-1.1.0", - "winnow 1.0.4", + "toml", + "winnow", "yaml-rust2", ] +[[package]] +name = "console" +version = "0.16.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" +dependencies = [ + "encode_unicode", + "libc", + "unicode-width", + "windows-sys 0.61.2", +] + [[package]] name = "const-oid" version = "0.9.6" @@ -786,38 +836,14 @@ dependencies = [ "cmov", ] -[[package]] -name = "darling" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cdf337090841a411e2a7f3deb9187445851f91b309c0c0a29e05f74a00a48c0" -dependencies = [ - "darling_core 0.21.3", - "darling_macro 0.21.3", -] - [[package]] name = "darling" version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", -] - -[[package]] -name = "darling_core" -version = "0.21.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1247195ecd7e3c85f83c8d2a366e4210d588e802133e1e355180a9870b517ea4" -dependencies = [ - "fnv", - "ident_case", - "proc-macro2", - "quote", - "strsim 0.11.1", - "syn 2.0.119", + "darling_core", + "darling_macro", ] [[package]] @@ -835,134 +861,120 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.21.3" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ - "darling_core 0.21.3", + "darling_core", "quote", "syn 2.0.119", ] [[package]] -name = "darling_macro" -version = "0.23.0" +name = "deadpool" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "883466cb8db62725aee5f4a6011e8a5d42912b42632df32aad57fc91127c6e04" dependencies = [ - "darling_core 0.23.0", - "quote", - "syn 2.0.119", + "deadpool-runtime", + "num_cpus", + "tokio", ] [[package]] -name = "der" -version = "0.7.10" +name = "deadpool-runtime" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" +checksum = "2657f61fb1dd8bf37a8d51093cc7cee4e77125b22f7753f49b289f831bec2bae" dependencies = [ - "const-oid 0.9.6", - "pem-rfc7468", - "zeroize", + "tokio", ] [[package]] -name = "deranged" -version = "0.5.8" +name = "defmt" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" dependencies = [ - "serde_core", + "bitflags 1.3.2", + "defmt-macros", ] [[package]] -name = "derive_arbitrary" -version = "1.4.2" +name = "defmt-macros" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" dependencies = [ + "defmt-parser", "proc-macro2", "quote", "syn 2.0.119", ] [[package]] -name = "diesel" -version = "2.3.11" +name = "defmt-parser" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e54d1f576cd3a3460f212a4615fd12ce1b6303c095b79a44449ffbe627753dc1" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "bitflags", - "byteorder", - "chrono", - "diesel_derives", - "downcast-rs", - "itoa", - "pq-sys", - "r2d2", - "serde_json", - "uuid", + "thiserror 2.0.19", ] [[package]] -name = "diesel-async" -version = "0.9.2" +name = "der" +version = "0.7.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd39af30158d444884f166fe4c58f35dc40ad71ad017bb59408a3448526ff4bd" +checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb" dependencies = [ - "bb8", - "diesel", - "diesel_migrations", - "futures-core", - "futures-util", - "pin-project-lite", - "tokio", - "tokio-postgres", + "const-oid 0.9.6", + "der_derive", + "flagset", + "pem-rfc7468", + "zeroize", ] [[package]] -name = "diesel-derive-enum" -version = "3.0.0-beta.1" +name = "der_derive" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50a8c082045d01debc8589f8a0db9f2855a37c99c9b031325c856b5b98e1625f" +checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" dependencies = [ - "heck 0.4.1", "proc-macro2", "quote", "syn 2.0.119", ] [[package]] -name = "diesel_derives" -version = "2.3.9" +name = "deranged" +version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1817b7f4279b947fc4cafddec12b0e5f8727141706561ce3ac94a60bddd1cf5" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "diesel_table_macro_syntax", - "dsl_auto_type", - "proc-macro2", - "quote", - "syn 2.0.119", + "serde_core", ] [[package]] -name = "diesel_migrations" -version = "2.3.2" +name = "derive_arbitrary" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28d0f4a98124ba6d4ca75da535f65984badec16a003b6e2f94a01e31a79490b8" +checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" dependencies = [ - "diesel", - "migrations_internals", - "migrations_macros", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] -name = "diesel_table_macro_syntax" -version = "0.3.0" +name = "dialoguer" +version = "0.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe2444076b48641147115697648dc743c2c00b61adade0f01ce67133c7babe8c" +checksum = "25f104b501bf2364e78d0d3974cbc774f738f5865306ed128e1e0d7499c0ad96" dependencies = [ - "syn 2.0.119", + "console", + "shell-words", + "tempfile", + "zeroize", ] [[package]] @@ -997,13 +1009,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -1021,26 +1033,6 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" -[[package]] -name = "downcast-rs" -version = "2.0.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" - -[[package]] -name = "dsl_auto_type" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd122633e4bef06db27737f21d3738fb89c8f6d5360d6d9d7635dda142a7757e" -dependencies = [ - "darling 0.21.3", - "either", - "heck 0.5.0", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "dunce" version = "1.0.5" @@ -1062,6 +1054,12 @@ dependencies = [ "serde", ] +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.35" @@ -1111,11 +1109,10 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -1138,6 +1135,12 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flagset" +version = "0.4.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7ac824320a75a52197e8f2d787f6a38b6718bb6897a35142d749af3c0e8f4fe" + [[package]] name = "flate2" version = "1.1.9" @@ -1346,18 +1349,20 @@ dependencies = [ [[package]] name = "geo-types" -version = "0.7.19" +version = "0.7.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94776032c45f950d30a13af6113c2ad5625316c9abfbccee4dd5a6695f8fe0f5" +checksum = "777d18aa0f12f8b285331cd867133ee14422b3f023f6d388034c47d43e28786a" dependencies = [ "approx 0.5.1", "num-traits", "rstar 0.10.0", "rstar 0.11.0", "rstar 0.12.2", + "rstar 0.13.0", "rstar 0.8.4", "rstar 0.9.3", "serde", + "thiserror 2.0.19", ] [[package]] @@ -1511,6 +1516,11 @@ name = "hashbrown" version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] [[package]] name = "hashlink" @@ -1591,15 +1601,15 @@ dependencies = [ [[package]] name = "heck" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] -name = "heck" -version = "0.5.0" +name = "hermit-abi" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" [[package]] name = "hex" @@ -1645,9 +1655,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1714,9 +1724,9 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.13" +version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" dependencies = [ "typenum", ] @@ -1932,6 +1942,12 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "index_vec" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44faf5bb8861a9c72e20d3fb0fdbd59233e43056e2b80475ab0aacdc2e781355" + [[package]] name = "indexmap" version = "1.9.3" @@ -1964,11 +1980,20 @@ dependencies = [ "rustversion", ] +[[package]] +name = "inventory" +version = "0.3.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b" +dependencies = [ + "rustversion", +] + [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "is_terminal_polyfill" @@ -1982,6 +2007,59 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7feca88439efe53da3754500c1851dedf3cb36c524dd5cf8225cc0794de95d09" +dependencies = [ + "defmt", +] + +[[package]] +name = "jiff-static" +version = "0.2.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "jni" version = "0.22.4" @@ -2086,14 +2164,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.18" +version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" +checksum = "2026a5056764a10b2bf5d56488cba40da507f5493a6a429340e2004d9ed085fa" dependencies = [ - "bitflags", + "bitflags 2.13.1", "libc", "plain", - "redox_syscall 0.9.0", + "redox_syscall 0.9.1", ] [[package]] @@ -2133,6 +2211,15 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lru" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d2f2f9b4ba7e6b24d95e7e899329d35be83bcded72c8540cdd5368932d1d90a" +dependencies = [ + "hashbrown 0.17.1", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2189,27 +2276,6 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" -[[package]] -name = "migrations_internals" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36c791ecdf977c99f45f23280405d7723727470f6689a5e6dbf513ac547ae10d" -dependencies = [ - "serde", - "toml 0.9.12+spec-1.1.0", -] - -[[package]] -name = "migrations_macros" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36fc5ac76be324cfd2d3f2cf0fdf5d5d3c4f14ed8aaebadb09e304ba42282703" -dependencies = [ - "migrations_internals", - "proc-macro2", - "quote", -] - [[package]] name = "mime" version = "0.3.17" @@ -2373,6 +2439,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" +dependencies = [ + "hermit-abi", + "libc", +] + [[package]] name = "o2o" version = "0.5.5" @@ -2410,7 +2486,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -2475,7 +2551,7 @@ dependencies = [ "async-trait", "dyn-clone", "ogcapi-types", - "schemars 1.2.1", + "schemars 1.2.2", "serde", "serde_json", "tokio", @@ -2501,7 +2577,7 @@ dependencies = [ "ogcapi-processes", "ogcapi-types", "openapiv3", - "schemars 1.2.1", + "schemars 1.2.2", "serde", "serde_json", "serde_qs", @@ -2564,7 +2640,7 @@ version = "0.10.81" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" dependencies = [ - "bitflags", + "bitflags 2.13.1", "cfg-if", "foreign-types", "libc", @@ -2782,12 +2858,31 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" +[[package]] +name = "pluralizer" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b3eba432a00a1f6c16f39147847a870e94e2e9b992759b503e330efec778cbe" +dependencies = [ + "once_cell", + "regex", +] + [[package]] name = "portable-atomic" version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "postgres-protocol" version = "0.6.12" @@ -2812,9 +2907,11 @@ version = "0.2.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "851ca9db4932932d69f3ea811b1abe63087a0f740a47692619dd40d4899b68be" dependencies = [ + "array-init", "bytes", "fallible-iterator", "postgres-protocol", + "uuid", ] [[package]] @@ -2841,17 +2938,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "pq-sys" -version = "0.7.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "574ddd6a267294433f140b02a726b0640c43cf7c6f717084684aaa3b285aba61" -dependencies = [ - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "pretty_assertions" version = "1.4.1" @@ -2952,17 +3038,6 @@ version = "6.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" -[[package]] -name = "r2d2" -version = "0.8.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" -dependencies = [ - "log", - "parking_lot", - "scheduled-thread-pool", -] - [[package]] name = "rand" version = "0.8.7" @@ -3025,16 +3100,16 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] name = "redox_syscall" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" +checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" dependencies = [ - "bitflags", + "bitflags 2.13.1", ] [[package]] @@ -3071,9 +3146,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -3172,7 +3247,7 @@ version = "0.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81116b9531d61eabc41aeb228e4b6b2435bcca3233b98cf3b3077d4e6e9debb3" dependencies = [ - "bitflags", + "bitflags 2.13.1", "once_cell", "serde", "serde_derive", @@ -3261,6 +3336,18 @@ dependencies = [ "smallvec", ] +[[package]] +name = "rstar" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5912b862fa5ffb462607bfd1e35036c458c537921f508c8235a83d5f3987edfe" +dependencies = [ + "heapless 0.8.0", + "num-traits", + "serde", + "smallvec", +] + [[package]] name = "rust-embed" version = "8.12.0" @@ -3327,7 +3414,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys", @@ -3336,9 +3423,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "aws-lc-rs", "once_cell", @@ -3361,6 +3448,15 @@ dependencies = [ "security-framework", ] +[[package]] +name = "rustls-pemfile" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dce314e5fee3f39953d46bb63bb8a46d40c2f8fb7cc5a3b6cab2bde9721d6e50" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "rustls-pki-types" version = "1.15.1" @@ -3440,15 +3536,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "scheduled-thread-pool" -version = "0.2.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" -dependencies = [ - "parking_lot", -] - [[package]] name = "schemars" version = "0.9.0" @@ -3463,9 +3550,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -3476,14 +3563,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -3498,7 +3585,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation 0.10.1", "core-foundation-sys", "libc", @@ -3565,13 +3652,13 @@ dependencies = [ [[package]] name = "serde_derive_internals" -version = "0.29.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -3655,7 +3742,7 @@ dependencies = [ "indexmap 1.9.3", "indexmap 2.14.0", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -3668,7 +3755,7 @@ version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ - "darling 0.23.0", + "darling", "proc-macro2", "quote", "syn 2.0.119", @@ -3729,6 +3816,12 @@ dependencies = [ "lazy_static", ] +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + [[package]] name = "shlex" version = "2.0.1" @@ -3897,7 +3990,7 @@ checksum = "19a9c1841124ac5a61741f96e1d9e2ec77424bf323962dd894bdb93f37d5219b" dependencies = [ "dotenvy", "either", - "heck 0.5.0", + "heck", "hex", "once_cell", "proc-macro2", @@ -3922,7 +4015,7 @@ checksum = "aa003f0038df784eb8fecbbac13affe3da23b45194bd57dba231c8f48199c526" dependencies = [ "atoi", "base64", - "bitflags", + "bitflags 2.13.1", "byteorder", "bytes", "crc", @@ -3964,7 +4057,7 @@ checksum = "db58fcd5a53cf07c184b154801ff91347e4c30d17a3562a635ff028ad5deda46" dependencies = [ "atoi", "base64", - "bitflags", + "bitflags 2.13.1", "byteorder", "crc", "dotenvy", @@ -4111,7 +4204,7 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" dependencies = [ - "bitflags", + "bitflags 2.13.1", "core-foundation 0.9.4", "system-configuration-sys", ] @@ -4190,9 +4283,9 @@ dependencies = [ [[package]] name = "time" -version = "0.3.54" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", "num-conv", @@ -4253,6 +4346,145 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +[[package]] +name = "tls_codec" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b" +dependencies = [ + "tls_codec_derive", + "zeroize", +] + +[[package]] +name = "tls_codec_derive" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "toasty" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2abf272caf351f58e03758745b6185576fcfe7a513b007683c2f8eec698eed2" +dependencies = [ + "async-trait", + "bit-set", + "by_address", + "deadpool", + "hashbrown 0.17.1", + "index_vec", + "indexmap 2.14.0", + "inventory", + "serde", + "serde_core", + "serde_json", + "toasty-core", + "toasty-driver-postgresql", + "toasty-macros", + "tokio", + "tokio-stream", + "toml", + "toml_edit", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "toasty-cli" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81a49aa74c23053c0e0e526214a5f060f86c1e8261f0170516c11ab63dc55487" +dependencies = [ + "anyhow", + "clap", + "console", + "dialoguer", + "hashbrown 0.17.1", + "jiff", + "rand 0.10.2", + "serde", + "toasty", + "toml", + "url", +] + +[[package]] +name = "toasty-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1650657e3784097859a1c31cf91b48e337f85684757e0c6ad44e887f0e009f" +dependencies = [ + "async-trait", + "bit-set", + "hashbrown 0.17.1", + "heck", + "indexmap 2.14.0", + "serde", + "tokio-stream", + "tracing", + "uuid", +] + +[[package]] +name = "toasty-driver-postgresql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7bc49e29cd2521b4835aa342adb99fe362a0b38083dda31112218c2762dfe09b" +dependencies = [ + "async-trait", + "fallible-iterator", + "hashbrown 0.17.1", + "lru", + "percent-encoding", + "postgres-protocol", + "postgres-types", + "rustls", + "rustls-pemfile", + "rustls-platform-verifier", + "rustls-webpki", + "toasty-core", + "toasty-sql", + "tokio", + "tokio-postgres", + "tokio-postgres-rustls", + "tracing", + "url", + "uuid", +] + +[[package]] +name = "toasty-macros" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16966e54a422be54959b0ad12bdbdcffc101c77aeca7d49fefff33142c709a29" +dependencies = [ + "hashbrown 0.17.1", + "heck", + "pluralizer", + "proc-macro2", + "quote", + "syn 3.0.3", + "toasty-core", +] + +[[package]] +name = "toasty-sql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b19948205b682d1750a8cf331b0b7fa666baffe2a3a2c0a05d5e94103c0fcc08" +dependencies = [ + "serde", + "serde_json", + "toasty-core", +] + [[package]] name = "tokio" version = "1.53.1" @@ -4272,13 +4504,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -4317,6 +4549,20 @@ dependencies = [ "whoami 2.1.2", ] +[[package]] +name = "tokio-postgres-rustls" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c2ad44aa0ae96db89c4742212ed41645b2f597311ff6e1945542a4d9fadc2fb" +dependencies = [ + "rustls", + "sha2 0.11.0", + "tokio", + "tokio-postgres", + "tokio-rustls", + "x509-cert", +] + [[package]] name = "tokio-rustls" version = "0.26.4" @@ -4354,37 +4600,17 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.12+spec-1.1.0" +version = "1.1.4+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "serde_core", - "serde_spanned", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "winnow 0.7.15", -] - -[[package]] -name = "toml" -version = "1.1.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53c96ecdfa941c8fc4fcaed14f99ada8ebed502eef533015095a07e3301d4c3c" +checksum = "3aace63f4bbcdfc2c965b059de67119c89c4017a70d633be6c104910f67056f5" dependencies = [ + "indexmap 2.14.0", "serde_core", "serde_spanned", - "toml_datetime 1.1.1+spec-1.1.0", + "toml_datetime", "toml_parser", - "winnow 1.0.4", -] - -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", + "toml_writer", + "winnow", ] [[package]] @@ -4403,20 +4629,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6975367e4d2ef766d86af01ffad14b622fecc8d4357a998fbc4deb6e9bacaf9b" dependencies = [ "indexmap 2.14.0", - "toml_datetime 1.1.1+spec-1.1.0", + "serde_core", + "serde_spanned", + "toml_datetime", "toml_parser", - "winnow 1.0.4", + "toml_writer", + "winnow", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.4", + "winnow", ] +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "tower" version = "0.5.3" @@ -4440,7 +4675,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags", + "bitflags 2.13.1", "bytes", "futures-core", "futures-util", @@ -4595,6 +4830,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -4697,6 +4938,7 @@ checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" dependencies = [ "getrandom 0.4.3", "js-sys", + "rand 0.10.2", "serde_core", "wasm-bindgen", ] @@ -5142,12 +5384,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "winnow" -version = "0.7.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" - [[package]] name = "winnow" version = "1.0.4" @@ -5182,6 +5418,18 @@ version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +[[package]] +name = "x509-cert" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" +dependencies = [ + "const-oid 0.9.6", + "der", + "spki", + "tls_codec", +] + [[package]] name = "yaml-rust2" version = "0.11.0" @@ -5268,6 +5516,20 @@ name = "zeroize" version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] [[package]] name = "zerotrie" @@ -5318,9 +5580,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.6" +version = "0.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" +checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zmij" diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 53c4ac5..b00ef22 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -52,19 +52,8 @@ anyhow = "1.0" approx = { version = "0.6.0-rc2", features = ["derive", "vec_impl"] } async-trait = "0.1" axum = { version = "0.8", features = ["multipart"] } -bb8 = "0.9" # wrt. `diesel-async` chrono = { version = "0.4", features = ["serde"] } config = "0.15" -diesel = { version = "2.3", features = [ - "postgres", - "chrono", - "uuid", - "r2d2", - "serde_json", -] } -diesel_migrations = "2.3" -diesel-async = { version = "0.9", features = ["migrations", "postgres", "bb8"] } -diesel-derive-enum = { version = "3.0.0-beta.1", features = ["postgres"] } futures = "0.3" geo-types = "0.7" geoengine-api-client = "0.9.3" @@ -85,6 +74,7 @@ schemars = "1.2" serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" tempfile = "3.27" +toasty = { version = "0.9", features = ["postgresql", "serde"] } tokio = { version = "1.49", features = ["rt-multi-thread", "macros"] } tower = "0.5" tracing = "0.1" @@ -97,3 +87,4 @@ wkt = "0.14" [dev-dependencies] httptest = "0.16" +toasty-cli = "0.9" diff --git a/backend/src/config.rs b/backend/src/config.rs index 0f5bdbc..083b2db 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -40,15 +40,20 @@ pub struct Database { pub database: String, pub schema: String, pub user: String, - pub password: String, + pub password: Secret, pub clear_database_on_start: bool, } impl Database { pub fn connection_string(&self) -> String { format!( - "postgresql://{}:{}@{}:{}/{}", - self.user, self.password, self.host, self.port, self.database + "postgresql://{user}:{password}@{host}:{port}/{database}?options=-c%20search_path%3D{schema}", + user = self.user, + password = self.password.expose(), + host = self.host, + port = self.port, + database = self.database, + schema = self.schema ) } } diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index b273c33..55f3554 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -1,145 +1,122 @@ -use std::pin::Pin; - use anyhow::{Context, Result}; -use bb8::CustomizeConnection; -use diesel_async::{ - AsyncConnection, AsyncMigrationHarness, AsyncPgConnection, SimpleAsyncConnection, - pooled_connection::{ - AsyncDieselConnectionManager, PoolError, - bb8::{Pool, PooledConnection as Bb8PooledConnection}, - }, +use std::{ + ops::{Deref, DerefMut}, + time::Duration, }; -use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; -use tracing::info; +use tracing::{info, instrument}; pub mod model; -pub mod schema; // auto-generated by diesel +#[cfg(test)] +pub mod tests; +pub mod util; + +/// A handle to a database connection, including the schema name. +#[derive(Clone, Debug)] +pub struct DbHandle { + db: toasty::Db, + schema: String, +} -pub type DbPool = Pool; -pub type PooledConnection<'c> = Bb8PooledConnection<'c, AsyncPgConnection>; +impl Deref for DbHandle { + type Target = toasty::Db; -const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations"); + fn deref(&self) -> &Self::Target { + &self.db + } +} -fn run_migrations(connection: PooledConnection<'static>) -> Result<()> { - let mut harness = AsyncMigrationHarness::new(connection); +impl DerefMut for DbHandle { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.db + } +} - let versions = harness - .run_pending_migrations(MIGRATIONS) - .map_err(anyhow::Error::from_boxed) - .context("Failed to run database migrations")?; +impl AsMut for DbHandle { + fn as_mut(&mut self) -> &mut toasty::Db { + &mut self.db + } +} - for version in versions { - info!("Applied migration: {version}"); +impl DbHandle { + /// Returns the name of the schema used by this database handle. + pub fn schema_name(&self) -> &str { + &self.schema } - Ok(()) + pub fn db(&self) -> toasty::Db { + self.db.clone() + } } -/// Create a database pool and run migrations -pub async fn setup_db(config: &crate::config::Database) -> Result { - let manager = - AsyncDieselConnectionManager::::new(config.connection_string()); - - let db_pool_builder = Pool::builder(); - - let db_pool_builder = if cfg!(test) { - db_pool_builder - .max_size(1) // Use a single connection for tests - .connection_customizer(Box::new(TestCustomizer { - schema: config.schema.clone(), - })) - } else { - db_pool_builder - .max_size(8) // TODO: investigate a good size - .connection_customizer(Box::new(SchemaSettingConnectionCustomizer { - schema: config.schema.clone(), - })) - }; - - let db_pool = db_pool_builder - .build(manager) +/// Create a database connection and initialize the schema +#[instrument()] +pub async fn setup_db(config: &crate::config::Database) -> Result { + let mut db = toasty::Db::builder() + .models(toasty::models!(model::Job)) + // .table_name_prefix(&format!("{}.", config.schema)) + .max_pool_size(if cfg!(test) { 1 } else { 8 }) + .pool_wait_timeout(Some(Duration::from_secs(5))) + .pool_create_timeout(Some(Duration::from_secs(10))) + .pool_health_check_interval(Some(Duration::from_secs(60))) + .connect(&config.connection_string()) .await - .context("Failed to create db pool.")?; + .context("Failed to connect to database")?; - on_startup( - db_pool.get_owned().await?, - &config.schema, - config.clear_database_on_start, - ) - .await?; + on_startup(&mut db, config).await?; - Ok(db_pool) + Ok(DbHandle { + db, + schema: config.schema.clone(), + }) } -async fn on_startup( - mut db_connection: PooledConnection<'static>, - schema: &str, - clear_database_on_start: bool, -) -> Result<()> { - if clear_database_on_start { - // TODO: prohibit deletion in production environments, - // e.g., not deleting when `clear_database_on_start` was previously `false` +async fn on_startup(db: &mut toasty::Db, config: &crate::config::Database) -> Result<()> { + // For development, use push_schema to quickly set up tables + // For production, migrations should be applied separately via CLI - info!("Clearing database schema '{schema}'"); + if cfg!(test) || config.clear_database_on_start { + if config.clear_database_on_start { + info!("Clearing database schema '{}'", config.schema); + // Drop and recreate schema + toasty::sql::statement(format!("DROP SCHEMA IF EXISTS {} CASCADE;", &config.schema)) + .exec(db) + .await + .context("Failed to clear database schema")?; + } - db_connection - .batch_execute(&format!("DROP SCHEMA IF EXISTS {schema} CASCADE;")) + info!("Creating database schema '{}'", config.schema); + toasty::sql::statement(format!("CREATE SCHEMA IF NOT EXISTS {};", &config.schema)) + .exec(db) .await - .context("Failed to clear database schema")?; - } + .context("Failed to create database schema")?; - db_connection - .batch_execute(&format!("CREATE SCHEMA IF NOT EXISTS {schema};")) + toasty::sql::statement(&format!( + r#"SET search_path TO "{schema_name}", public;"#, + schema_name = &config.schema + )) + .exec(db) .await - .context("Failed to create database schema")?; + .context("Failed to set search_path for database schema")?; - run_migrations(db_connection) -} + if cfg!(test) { + let mut connection = db.connection().await?; -/// A connection customizer that sets the `search_path` to a specific schema upon acquiring a connection. -#[derive(Debug)] -struct SchemaSettingConnectionCustomizer { - schema: String, -} + toasty::sql::statement(&format!( + r#"SET search_path TO "{schema_name}", public;"#, + schema_name = &config.schema + )) + .exec(&mut connection) + .await + .context("Failed to set search_path for database schema")?; -impl CustomizeConnection for SchemaSettingConnectionCustomizer { - fn on_acquire<'a>( - &'a self, - conn: &'a mut AsyncPgConnection, - ) -> Pin> + Send + 'a>> { - let schema = self.schema.clone(); - Box::pin(async move { - conn.batch_execute(&format!("SET search_path TO {schema},public;")) + connection + .push_schema() .await - .map_err(PoolError::QueryError) - }) + .context("Failed to push schema to database")?; + } } -} - -/// A connection customizer designed for use in tests. Implements -/// [`CustomizeConnection`] in a way that ensures transactions -/// in a pool customized by it are never committed. -/// -/// Built after [`diesel::r2d2::TestCustomizer`] -#[derive(Debug)] -struct TestCustomizer { - schema: String, -} -impl CustomizeConnection for TestCustomizer { - fn on_acquire<'a>( - &'a self, - conn: &'a mut AsyncPgConnection, - ) -> Pin> + Send + 'a>> { - let schema = self.schema.clone(); - Box::pin(async move { - conn.batch_execute(&format!("SET search_path TO {schema},public;")) - .await - .map_err(PoolError::QueryError)?; + // TODO: In production, use proper migrations - conn.begin_test_transaction() - .await - .map_err(PoolError::QueryError) - }) - } + Ok(()) } diff --git a/backend/src/db/model.rs b/backend/src/db/model.rs index 86b14b3..8c3399a 100644 --- a/backend/src/db/model.rs +++ b/backend/src/db/model.rs @@ -1,198 +1,238 @@ -use super::schema::{jobs, sql_types}; use chrono::{DateTime, Utc}; -use diesel::{ - deserialize::{FromSql, FromSqlRow}, - expression::AsExpression, - pg::{Pg, PgValue}, - prelude::*, - serialize::{Output, ToSql, WriteTuple}, - sql_types::{BigInt, Nullable, SqlType, Text}, -}; -use diesel_derive_enum::DbEnum; use o2o::o2o; -use serde::Deserialize; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; -#[derive(Debug, Deserialize, Insertable)] -#[diesel(table_name = jobs)] -pub struct NewJob<'a> { - pub job_id: &'a str, - pub process_id: Option<&'a str>, - pub status: StatusCode, - pub message: Option<&'a str>, - pub job_type: JobType, - pub created: DateTime, - pub updated: DateTime, - pub progress: Option, - pub links: Vec, - pub response: Response, - pub user_id: uuid::Uuid, +/// Job status code enumeration +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, toasty::Embed)] +#[serde(rename_all = "PascalCase")] +pub enum StatusCode { + Accepted, + Running, + Successful, + Failed, + Dismissed, } -#[derive(Debug, Deserialize, AsChangeset)] -#[diesel(table_name = jobs)] -pub struct UpdateJob<'a> { - pub status: StatusCode, - pub message: Option<&'a str>, - pub updated: DateTime, - pub progress: Option, - pub links: Vec, +impl From for ogcapi::types::processes::StatusCode { + fn from(val: StatusCode) -> Self { + match val { + StatusCode::Accepted => ogcapi::types::processes::StatusCode::Accepted, + StatusCode::Running => ogcapi::types::processes::StatusCode::Running, + StatusCode::Successful => ogcapi::types::processes::StatusCode::Successful, + StatusCode::Failed => ogcapi::types::processes::StatusCode::Failed, + StatusCode::Dismissed => ogcapi::types::processes::StatusCode::Dismissed, + } + } } -#[derive(Debug, Deserialize, AsChangeset)] -#[diesel(table_name = jobs)] -pub struct UpdateJobStatus<'a> { - pub status: StatusCode, - pub message: Option<&'a str>, - pub updated: DateTime, +impl From for StatusCode { + fn from(val: ogcapi::types::processes::StatusCode) -> Self { + match val { + ogcapi::types::processes::StatusCode::Accepted => StatusCode::Accepted, + ogcapi::types::processes::StatusCode::Running => StatusCode::Running, + ogcapi::types::processes::StatusCode::Successful => StatusCode::Successful, + ogcapi::types::processes::StatusCode::Failed => StatusCode::Failed, + ogcapi::types::processes::StatusCode::Dismissed => StatusCode::Dismissed, + } + } } -#[derive(Debug, Deserialize, AsChangeset)] -#[diesel(table_name = jobs)] -pub struct FinishJob<'a> { - pub status: StatusCode, - pub message: Option<&'a str>, - pub updated: DateTime, - pub finished: DateTime, - pub progress: Option, - pub links: Vec, - pub results: Option, +/// Job type enumeration +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, toasty::Embed)] +#[serde(rename_all = "PascalCase")] +pub enum JobType { + Process, } -#[derive(Debug, Deserialize, AsChangeset)] -#[diesel(table_name = jobs)] -pub struct DismissJob<'a> { - pub status: StatusCode, - pub message: Option<&'a str>, - pub updated: DateTime, +impl From for ogcapi::types::processes::JobType { + fn from(val: JobType) -> Self { + match val { + JobType::Process => ogcapi::types::processes::JobType::Process, + } + } } -#[derive(Debug, Deserialize, HasQuery, o2o)] -#[owned_into(ogcapi::types::processes::StatusInfo)] -#[diesel(table_name = jobs)] -pub struct StatusInfo { - pub job_id: String, - pub process_id: Option, - #[map(~.into())] - pub status: StatusCode, - pub message: Option, - #[map(r#type, ~.into())] - pub job_type: JobType, - #[map(~.into())] - pub created: DateTime, - #[map(~.into())] - pub updated: DateTime, - pub finished: Option>, - #[map(~.map(|p| p as u8))] - pub progress: Option, - #[map(~.into_iter().filter_map(|l| l.map(Into::into)).collect())] - pub links: Vec>, +impl From for JobType { + fn from(val: ogcapi::types::processes::JobType) -> Self { + match val { + ogcapi::types::processes::JobType::Process => JobType::Process, + } + } } -#[derive(Debug, Deserialize, DbEnum, SqlType, o2o)] -#[from_owned(ogcapi::types::processes::JobType)] -#[owned_into(ogcapi::types::processes::JobType)] -#[db_enum(existing_type_path = "crate::db::schema::sql_types::JobType")] -pub enum JobType { - Process, +/// Response type enumeration +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, toasty::Embed)] +#[serde(rename_all = "PascalCase")] +pub enum Response { + Raw, + Document, } -#[derive(Debug, Deserialize, DbEnum, SqlType, o2o)] -#[from_owned(ogcapi::types::processes::StatusCode)] -#[owned_into(ogcapi::types::processes::StatusCode)] -#[db_enum(existing_type_path = "crate::db::schema::sql_types::StatusCode")] -pub enum StatusCode { - Accepted, - Running, - Successful, - Failed, - Dismissed, +impl From for ogcapi::types::processes::Response { + fn from(val: Response) -> Self { + match val { + Response::Raw => ogcapi::types::processes::Response::Raw, + Response::Document => ogcapi::types::processes::Response::Document, + } + } +} + +impl From for Response { + fn from(val: ogcapi::types::processes::Response) -> Self { + match val { + ogcapi::types::processes::Response::Raw => Response::Raw, + ogcapi::types::processes::Response::Document => Response::Document, + } + } } -#[derive(Debug, Deserialize, AsExpression, FromSqlRow, o2o)] +/// Link reference (stored as JSONB) +#[derive(Debug, Clone, Serialize, Deserialize, o2o, toasty::Embed)] #[from_owned(ogcapi::types::common::Link)] #[owned_into(ogcapi::types::common::Link)] #[ghosts(templated: None, var_base: None)] -#[diesel(sql_type = sql_types::Link, postgres_type(name = "Link"))] pub struct Link { pub href: String, pub rel: String, + #[serde(skip_serializing_if = "Option::is_none")] pub r#type: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub hreflang: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub title: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub length: Option, } -impl ToSql for Link { - fn to_sql<'b>(&'b self, out: &mut Output<'b, '_, Pg>) -> diesel::serialize::Result { - // Write the fields in the order: href TEXT, rel TEXT, type TEXT, hreflang TEXT, title TEXT, length BIGINT - WriteTuple::<( - Text, - Text, - Nullable, - Nullable, - Nullable, - Nullable, - )>::write_tuple( - &( - &self.href, - &self.rel, - self.r#type.as_ref(), - self.hreflang.as_ref(), - self.title.as_ref(), - self.length.as_ref(), - ), - &mut out.reborrow(), - ) - } +/// Job database model +#[derive(Debug, Clone, Serialize, Deserialize, toasty::Model)] +pub struct Job { + /// Primary key: job ID + #[key] + #[auto(uuid(v7))] + pub job_id: uuid::Uuid, + + /// Referenced process ID + pub process_id: Option, + + /// Job type + pub job_type: JobType, + + /// Current status + pub status: StatusCode, + + /// Status message + pub message: Option, + + /// Job creation timestamp (stored as Unix timestamp in milliseconds) + pub created: i64, + + /// Job completion timestamp (stored as Unix timestamp in milliseconds) + pub finished: Option, + + /// Last update timestamp (stored as Unix timestamp in milliseconds) + pub updated: i64, + + /// Progress percentage (0-100) + pub progress: Option, + + /// Links associated with the job (stored as JSONB) + pub links: Vec, + + /// Response type + pub response: Response, + + /// Job results (JSONB) + #[column(type = "jsonb")] + pub results: Option, + + /// User ID who created the job + pub user_id: uuid::Uuid, + + #[has_many] + pub credits: toasty::Deferred>, } -impl FromSql for Link { - fn from_sql(bytes: PgValue<'_>) -> diesel::deserialize::Result { - // Use the tuple implementation to extract the fields - let (href, rel, r#type, hreflang, title, length): ( - String, - String, - Option, - Option, - Option, - Option, - ) = <( - String, - String, - Option, - Option, - Option, - Option, - ) as FromSql< - diesel::sql_types::Record<( - Text, - Text, - Nullable, - Nullable, - Nullable, - Nullable, - )>, - Pg, - >>::from_sql(bytes)?; - - Ok(Link { - href, - rel, - r#type, - hreflang, - title, - length, - }) +impl Job { + /// Convert created timestamp to DateTime + pub fn created_at(&self) -> DateTime { + DateTime::::from_timestamp_millis(self.created).unwrap_or_else(|| Utc::now()) + } + + /// Convert finished timestamp to DateTime + pub fn finished_at(&self) -> Option> { + self.finished + .and_then(|ts| DateTime::::from_timestamp_millis(ts)) + } + + /// Convert updated timestamp to DateTime + pub fn updated_at(&self) -> DateTime { + DateTime::::from_timestamp_millis(self.updated).unwrap_or_else(|| Utc::now()) + } + + /// Set created from DateTime + pub fn with_created(mut self, dt: DateTime) -> Self { + self.created = dt.timestamp_millis(); + self + } + + /// Set finished from DateTime + pub fn with_finished(mut self, dt: Option>) -> Self { + self.finished = dt.map(|d| d.timestamp_millis()); + self + } + + /// Set updated from DateTime + pub fn with_updated(mut self, dt: DateTime) -> Self { + self.updated = dt.timestamp_millis(); + self } } -#[derive(Debug, Deserialize, SqlType, DbEnum, o2o)] -#[from_owned(ogcapi::types::processes::Response)] -#[owned_into(ogcapi::types::processes::Response)] -#[db_enum(existing_type_path = "crate::db::schema::sql_types::Response")] -pub enum Response { - Raw, - Document, +/// Credits database model +#[derive(Debug, Clone, Serialize, Deserialize, toasty::Model)] +#[key(job_id, compute_id)] // Note: `toasty` requires a primary key, but we would omit this normally. +pub struct Credits { + pub timestamp: i64, + + /// Referenced job ID + #[index] + pub job_id: uuid::Uuid, + + /// Referenced job + #[belongs_to(key = job_id, references = job_id)] + pub job: toasty::Deferred, + + /// Geo Engine compute ID (if applicable) + /// + /// Note: Not stored as nullable because `toasty` requires a primary key. + pub compute_id: ComputeId, + + /// Credits used; empty if not yet determined (e.g., job still running) + pub credits: Option, +} + +/// An optional compute ID for Geo Engine jobs, stored as a string. +/// This is used to track the compute resources used by a job in the Geo Engine system. +#[derive(Debug, Clone, Serialize, Deserialize, toasty::Embed)] +pub struct ComputeId(Uuid); + +impl ComputeId { + pub fn some(id: Uuid) -> Self { + Self(id) + } + + pub fn none() -> Self { + Self(Uuid::nil()) + } + + pub fn get(&self) -> Option { + if self.0.is_nil() { + return None; + } + Some(self.0) + } } #[cfg(test)] diff --git a/backend/src/db/schema.rs b/backend/src/db/schema.rs deleted file mode 100644 index 422e2df..0000000 --- a/backend/src/db/schema.rs +++ /dev/null @@ -1,126 +0,0 @@ -// @generated automatically by Diesel CLI. - -/// A module containing custom SQL type definitions -/// -/// (Automatically generated by Diesel.) -pub mod sql_types { - /// The `JobType` SQL type - /// - /// (Automatically generated by Diesel.) - #[derive(Debug, diesel::sql_types::SqlType)] - #[diesel(postgres_type(name = "JobType"))] - pub struct JobType; - - /// The `Link` SQL type - /// - /// (Automatically generated by Diesel.) - #[derive(Debug, diesel::sql_types::SqlType)] - #[diesel(postgres_type(name = "Link"))] - pub struct Link; - - /// The `Response` SQL type - /// - /// (Automatically generated by Diesel.) - #[derive(Debug, diesel::sql_types::SqlType)] - #[diesel(postgres_type(name = "Response"))] - pub struct Response; - - /// The `StatusCode` SQL type - /// - /// (Automatically generated by Diesel.) - #[derive(Debug, diesel::sql_types::SqlType)] - #[diesel(postgres_type(name = "StatusCode"))] - pub struct StatusCode; -} - -diesel::table! { - use diesel::sql_types::*; - use super::sql_types::JobType; - use super::sql_types::StatusCode; - use super::sql_types::Link; - use super::sql_types::Response; - - /// Representation of the `jobs` table. - /// - /// (Automatically generated by Diesel.) - jobs (job_id) { - /// The `job_id` column of the `jobs` table. - /// - /// Its SQL type is `Text`. - /// - /// (Automatically generated by Diesel.) - job_id -> Text, - /// The `process_id` column of the `jobs` table. - /// - /// Its SQL type is `Nullable`. - /// - /// (Automatically generated by Diesel.) - process_id -> Nullable, - /// The `job_type` column of the `jobs` table. - /// - /// Its SQL type is `JobType`. - /// - /// (Automatically generated by Diesel.) - job_type -> JobType, - /// The `status` column of the `jobs` table. - /// - /// Its SQL type is `StatusCode`. - /// - /// (Automatically generated by Diesel.) - status -> StatusCode, - /// The `message` column of the `jobs` table. - /// - /// Its SQL type is `Nullable`. - /// - /// (Automatically generated by Diesel.) - message -> Nullable, - /// The `created` column of the `jobs` table. - /// - /// Its SQL type is `Timestamptz`. - /// - /// (Automatically generated by Diesel.) - created -> Timestamptz, - /// The `finished` column of the `jobs` table. - /// - /// Its SQL type is `Nullable`. - /// - /// (Automatically generated by Diesel.) - finished -> Nullable, - /// The `updated` column of the `jobs` table. - /// - /// Its SQL type is `Timestamptz`. - /// - /// (Automatically generated by Diesel.) - updated -> Timestamptz, - /// The `progress` column of the `jobs` table. - /// - /// Its SQL type is `Nullable`. - /// - /// (Automatically generated by Diesel.) - progress -> Nullable, - /// The `links` column of the `jobs` table. - /// - /// Its SQL type is `Array>`. - /// - /// (Automatically generated by Diesel.) - links -> Array>, - /// The `response` column of the `jobs` table. - /// - /// Its SQL type is `Response`. - /// - /// (Automatically generated by Diesel.) - response -> Response, - /// The `results` column of the `jobs` table. - /// - /// Its SQL type is `Nullable`. - /// - /// (Automatically generated by Diesel.) - results -> Nullable, - /// The `user_id` column of the `jobs` table. - /// - /// Its SQL type is `Uuid`. - /// - /// (Automatically generated by Diesel.) - user_id -> Uuid, - } -} diff --git a/backend/src/db/tests.rs b/backend/src/db/tests.rs new file mode 100644 index 0000000..c7a45b4 --- /dev/null +++ b/backend/src/db/tests.rs @@ -0,0 +1,104 @@ +use crate::{ + CONFIG, + db::{DbHandle, setup_db}, +}; +use std::{ + panic::AssertUnwindSafe, + sync::{Arc, OnceLock}, +}; +use tokio::{ + runtime::Handle, + sync::{OwnedSemaphorePermit, Semaphore}, +}; +use uuid::Uuid; + +/// Execute a test function with a temporary database schema. +/// It will be cleaned up afterwards. +/// +/// # Panics +/// +/// Panics if the database setup fails or if the test function panics. +/// +pub async fn with_temp_db(f: F) -> R +where + F: FnOnce(DbHandle) -> Fut + std::panic::UnwindSafe + Send + 'static, + Fut: Future, +{ + let (permit, db_handle) = setup_test_db().await; + + // catch all panics and clean up first… + let executed_fn = { + let db_handle = db_handle.clone(); + std::panic::catch_unwind( + AssertUnwindSafe /* fingers crossed for DbHandle */ (move || { + tokio::task::block_in_place(move || { + Handle::current().block_on(async move { f(db_handle).await }) + }) + }), + ) + }; + + tear_down_db(db_handle).await; + drop(permit); + + match executed_fn { + Ok(res) => res, + Err(err) => std::panic::resume_unwind(err), + } +} + +/// configure the number of concurrently running tests that use the database +/// Keep at 1 to avoid PostgreSQL type OID caching issues when creating types +/// in parallel across different schemas. While each schema has its own isolated +/// types (for testing isolation), parallel type creation causes operator resolution +/// failures when Toasty generates SQL across different test schema OIDs. +const CONCURRENT_DB_TESTS: usize = 1; +static DB_SYNC: OnceLock> = OnceLock::new(); + +/// Setup database schema and return its name. +pub(crate) async fn setup_test_db() -> (OwnedSemaphorePermit, DbHandle) { + // acquire a permit from the semaphore that limits the number of concurrently running tests that use the database + let permit = DB_SYNC + .get_or_init(|| Arc::new(Semaphore::new(CONCURRENT_DB_TESTS))) + .clone() + .acquire_owned() + .await + .unwrap(); + + let mut db_config = CONFIG.database.clone(); + db_config.schema = test_schema(); + let db_pool = setup_db(&db_config).await.unwrap(); + + (permit, db_pool) +} + +/// Tear down database schema. +pub(crate) async fn tear_down_db(mut db_pool: DbHandle) { + let schema = &db_pool.schema_name(); + toasty::sql::statement(format!("DROP SCHEMA IF EXISTS {schema} CASCADE;")) + .exec(db_pool.as_mut()) + .await + .unwrap(); +} + +/// Generate random temp schema name for testing +fn test_schema() -> String { + format!("geoengine_test_{}", Uuid::now_v7().as_simple()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn it_generates_test_schema_names() { + let test_schema_name = test_schema(); + assert!(test_schema_name.starts_with("geoengine_test_")); + assert!( + test_schema_name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_'), + "{test_schema_name} contains invalid characters" + ); + } +} diff --git a/backend/src/db/util.rs b/backend/src/db/util.rs new file mode 100644 index 0000000..8f2bb1d --- /dev/null +++ b/backend/src/db/util.rs @@ -0,0 +1,71 @@ +use toasty::stmt::Value; + +pub fn get_string(value: &Value, idx: usize) -> Option { + let record = value.as_record()?; + match record.get(idx) { + Some(Value::String(s)) => Some(s.clone()), + _ => None, + } +} + +pub fn get_string_list(value: &Value, idx: usize) -> Option> { + let record = value.as_record()?; + match record.get(idx) { + Some(Value::List(list)) => { + let mut result = Vec::new(); + for item in list { + if let Value::String(s) = item { + result.push(s.clone()); + } else { + return None; + } + } + Some(result) + } + _ => None, + } +} + +pub fn get_string_list_option(value: &Value, idx: usize) -> Option>> { + let record = value.as_record()?; + match record.get(idx) { + Some(Value::List(list)) => { + let mut result = Vec::new(); + for item in list { + if let Value::String(s) = item { + result.push(s.clone()); + } else { + return None; + } + } + Some(Some(result)) + } + Some(Value::Null) => Some(None), + _ => None, + } +} + +pub fn get_number(value: &Value, idx: usize) -> Option { + let record = value.as_record()?; + match record.get(idx) { + Some(Value::F64(n)) => Some(*n), + _ => None, + } +} + +pub fn get_number_option(value: &Value, idx: usize) -> Option> { + let record = value.as_record()?; + match record.get(idx) { + Some(Value::F64(n)) => Some(Some(*n)), + Some(Value::Null) => Some(None), + _ => None, + } +} + +pub fn get_bool(value: &Value, idx: usize) -> Option { + let record = value.as_record()?; + match record.get(idx) { + Some(Value::Bool(b)) => Some(*b), + _ => None, + } +} diff --git a/backend/src/jobs.rs b/backend/src/jobs.rs index bb68f8f..e3f348b 100644 --- a/backend/src/jobs.rs +++ b/backend/src/jobs.rs @@ -1,15 +1,9 @@ use crate::{ - db::{ - DbPool, PooledConnection, - model::{self, DismissJob, NewJob, UpdateJob, UpdateJobStatus}, - schema::jobs, - }, + db::{DbHandle, model}, state::USER, }; use anyhow::Context; use chrono::Utc; -use diesel::{ExpressionMethods, HasQuery, OptionalExtension, QueryDsl, SelectableHelper}; -use diesel_async::RunQueryDsl; use ogcapi::{ drivers::ProcessResult, types::{ @@ -17,42 +11,46 @@ use ogcapi::{ processes::{ExecuteResults, Response, StatusCode, StatusInfo}, }, }; +use tracing::instrument; +use uuid::Uuid; pub struct JobHandler { - connection: DbPool, + db: DbHandle, } impl JobHandler { - pub async fn new(connection: DbPool) -> anyhow::Result { - let this = Self { connection }; + pub async fn new(db: DbHandle) -> anyhow::Result { + let mut this = Self { db }; this.clean_running_jobs_from_previous_sessions().await?; Ok(this) } - async fn connection(&self) -> anyhow::Result> { - self.connection - .get() - .await - .context("could not get db connection from pool") - } - /// Clean up jobs that were in `Running` state from previous server sessions. /// Set them to `Failed` with appropriate message. - async fn clean_running_jobs_from_previous_sessions(&self) -> anyhow::Result<()> { - let update = UpdateJobStatus { - status: model::StatusCode::Failed, - message: "Server restarted during job execution".into(), - updated: Utc::now(), - }; + #[instrument(skip(self), level = "debug")] + async fn clean_running_jobs_from_previous_sessions(&mut self) -> anyhow::Result<()> { + let now_ms = Utc::now().timestamp_millis(); + let schema = self.db.schema_name().to_string(); - diesel::update(jobs::table) - .filter(jobs::status.eq(model::StatusCode::Running)) - .set(update) - .execute(&mut self.connection().await?) + let mut tx = self.db.as_mut().transaction().await?; + + toasty::sql::statement(format!("SET LOCAL search_path TO {schema}, public;")) + .exec(&mut tx) .await - .context("Failed to update job in database")?; + .context("Failed to set search path")?; - Ok(()) + toasty::update!(model::Job::filter( + model::Job::fields().status().eq(model::StatusCode::Running) + ) { + status: model::StatusCode::Failed, + message: Some("Server restarted during job execution".to_string()), + updated: now_ms, + }) + .exec(&mut tx) + .await + .context("Failed to clean up running jobs")?; + + tx.commit().await.context("Failed to commit transaction") } } @@ -61,90 +59,101 @@ impl ogcapi::drivers::JobHandler for JobHandler { async fn register(&self, job: &StatusInfo, response_mode: Response) -> anyhow::Result { let user = USER.try_get().context("missing authenticated user")?; - let job_id = if job.job_id.is_empty() { - uuid::Uuid::now_v7().to_string() - } else { - job.job_id.clone() + if !dbg!(&job.job_id).is_empty() { + anyhow::bail!("Job ID must be empty when registering a new job"); }; - let new_job = NewJob { - job_id: &job_id, - process_id: job.process_id.as_deref(), - status: job.status.clone().into(), - message: job.message.as_deref(), - job_type: job.r#type.clone().into(), - created: job.created.unwrap_or_else(Utc::now), - updated: job.updated.unwrap_or_else(Utc::now), + let now_ms = Utc::now().timestamp_millis(); + let created_ms = job + .created + .map(|dt| dt.timestamp_millis()) + .unwrap_or(now_ms); + let updated_ms = job + .updated + .map(|dt| dt.timestamp_millis()) + .unwrap_or(now_ms); + + let job = toasty::create!(model::Job { + process_id: job.process_id.clone(), + job_type: model::JobType::from(job.r#type.clone()), + status: model::StatusCode::from(job.status.clone()), + message: job.message.clone(), + created: created_ms, + finished: None, + updated: updated_ms, progress: job.progress.map(Into::into), - links: job.links.iter().map(|l| l.clone().into()).collect(), - response: response_mode.into(), + links: job + .links + .iter() + .cloned() + .map(Into::into) + .collect::>(), + response: model::Response::from(response_mode), + results: None, user_id: user.id, - }; - - let job_id: String = diesel::insert_into(jobs::table) - .values(new_job) - .returning(jobs::job_id) - .get_result(&mut self.connection().await?) - .await - .context("Failed to insert job into database")?; + }) + .exec(&mut self.db.db()) + .await + .context("Failed to insert job into database")?; - Ok(job_id) + Ok(job.job_id.to_string()) } + #[instrument(skip(self, job), level = "debug")] async fn update(&self, job: &StatusInfo) -> anyhow::Result<()> { - let update = UpdateJob { - status: job.status.clone().into(), - message: job.message.as_deref(), - updated: job.updated.unwrap_or_else(Utc::now), - progress: job.progress.map(Into::into), - links: job.links.iter().map(|l| l.clone().into()).collect(), - }; - - diesel::update(jobs::table) - .filter( - jobs::job_id.eq(&job.job_id), // .filter(jobs::user_id.eq(user.id) - ) - .set(update) - .execute(&mut self.connection().await?) - .await - .context("Failed to update job in database")?; + let job_id = Uuid::parse_str(&job.job_id).context("Invalid job ID format")?; + let now_ms = Utc::now().timestamp_millis(); + let updated_ms = job + .updated + .map(|dt| dt.timestamp_millis()) + .unwrap_or(now_ms); + let progress = job.progress.map(|p| p as i16).unwrap_or(0); + + toasty::update!(model::Job::filter_by_job_id(&job_id) { + status: model::StatusCode::from(job.status.clone()), + message: job.message.clone(), + updated: updated_ms, + progress: Some(progress), + }) + .exec(&mut self.db.db()) + .await + .context("Failed to update job in database")?; Ok(()) } + #[instrument(skip(self), level = "debug")] async fn status_list(&self, offset: usize, limit: usize) -> anyhow::Result> { let user = USER.try_get().context("missing authenticated user")?; - let query = model::StatusInfo::query() - .filter(jobs::user_id.eq(user.id)) - .order(jobs::updated.desc()) - .offset(offset as i64) - .limit(limit as i64); + let results: Vec = + model::Job::filter(model::Job::fields().user_id().eq(user.id)) + .order_by(model::Job::fields().updated().desc()) + .limit(limit) + .offset(offset) + .exec(&mut self.db.db()) + .await + .context("Failed to query job status list from database")?; - let result = query - .load::(&mut self.connection().await?) - .await - .context("Failed to query job status list from database")?; - - Ok(result - .into_iter() - .map(Into::into) - .collect::>()) + Ok(results.into_iter().map(job_to_status_info).collect()) } + #[instrument(skip(self), level = "debug")] async fn status(&self, id: &str) -> anyhow::Result> { let user = USER.try_get().context("missing authenticated user")?; + let id = Uuid::parse_str(id).context("Invalid job ID format")?; - model::StatusInfo::query() - .filter(jobs::job_id.eq(id)) - .filter(jobs::user_id.eq(user.id)) - .first(&mut self.connection().await?) + let result: Option = model::Job::filter_by_job_id(id) + .filter(model::Job::fields().user_id().eq(user.id)) + .first() + .exec(&mut self.db.db()) .await - .optional() - .map(|s| s.map(Into::into)) - .context("Failed to query job status from database") + .context("Failed to query job status from database")?; + + Ok(result.map(job_to_status_info)) } + #[instrument(skip(self, message, links, results), level = "debug")] async fn finish( &self, job_id: &str, @@ -153,68 +162,84 @@ impl ogcapi::drivers::JobHandler for JobHandler { links: Vec, results: Option, ) -> anyhow::Result<()> { - let finish = crate::db::model::FinishJob { - status: status.clone().into(), - message: message.as_deref(), - updated: Utc::now(), - finished: Utc::now(), - progress: Some(100), - links: links.iter().map(|l| l.clone().into()).collect(), - results: results.map(serde_json::to_value).transpose()?, - }; - - diesel::update( - jobs::table.filter(jobs::job_id.eq(job_id)), // .filter(jobs::user_id.eq(user.id)), - ) - .set(finish) - .execute(&mut self.connection().await?) + let now_ms = Utc::now().timestamp_millis(); + let job_id = Uuid::parse_str(job_id).context("Invalid job ID format")?; + let results_json = results.map(serde_json::to_value).transpose()?; + + toasty::update!(model::Job::filter_by_job_id(&job_id) { + status: model::StatusCode::from(status.clone()), + message, + updated: now_ms, + finished: Some(now_ms), + progress: Some(100i16), + links: links.iter().cloned().map(Into::into).collect::>(), + results: results_json, + }) + .exec(&mut self.db.db()) .await - .context("Failed write finished job to database")?; + .context("Failed to finish job in database")?; Ok(()) } + #[instrument(skip(self), level = "debug")] async fn dismiss(&self, id: &str) -> anyhow::Result> { let user = USER.try_get().context("missing authenticated user")?; - - let returned: Option = diesel::update(jobs::table) - .filter(jobs::job_id.eq(id)) - .filter(jobs::user_id.eq(user.id)) - .set(DismissJob { - status: StatusCode::Dismissed.into(), - message: Some("Job dismissed by user"), - updated: Utc::now(), - }) - .returning(model::StatusInfo::as_returning()) - .get_result(&mut self.connection().await?) + let now_ms = Utc::now().timestamp_millis(); + let id = Uuid::parse_str(id).context("Invalid job ID format")?; + + // First, get the current job to return + let current_job: Option = model::Job::filter_by_job_id(&id) + .filter(model::Job::fields().user_id().eq(user.id)) + .first() + .exec(&mut self.db.db()) .await - .optional() - .context("Failed to dismiss job in database")?; + .context("Failed to query current job before dismiss")?; - Ok(returned.map(Into::into)) + // Update the job to dismissed status + toasty::update!(model::Job::filter( + model::Job::fields().job_id().eq(id) + ) + .filter(model::Job::fields().user_id().eq(user.id)) { + status: model::StatusCode::Dismissed, + message: Some("Job dismissed by user".to_string()), + updated: now_ms, + }) + .exec(&mut self.db.db()) + .await + .context("Failed to dismiss job in database")?; + + // Return the updated job info + Ok(current_job.map(|mut job| { + job.status = model::StatusCode::Dismissed; + job.message = Some("Job dismissed by user".to_string()); + job.updated = now_ms; + job_to_status_info(job) + })) } + #[instrument(skip(self), level = "debug")] async fn results(&self, id: &str) -> anyhow::Result { let user = USER.try_get().context("missing authenticated user")?; + let id = Uuid::parse_str(id).context("Invalid job ID format")?; - let results: Option<(Option, model::Response)> = jobs::table - .select((jobs::results, jobs::response)) - .filter(jobs::job_id.eq(id)) - .filter(jobs::user_id.eq(user.id)) - .first(&mut self.connection().await?) + let result: Option = model::Job::filter_by_job_id(id) + .filter(model::Job::fields().user_id().eq(user.id)) + .first() + .exec(&mut self.db.db()) .await - .optional() .context("Failed to query job results from database")?; - let Some((results, response_mode)) = results else { + let Some(job) = result else { return Ok(ProcessResult::NoSuchJob); }; - let Some(results) = results else { + let Some(results_json) = job.results else { return Ok(ProcessResult::NotReady); }; - let results: ExecuteResults = serde_json::from_value(results)?; + let results: ExecuteResults = serde_json::from_value(results_json)?; + let response_mode = job.response; Ok(ProcessResult::Results { results, @@ -223,16 +248,33 @@ impl ogcapi::drivers::JobHandler for JobHandler { } } +/// Convert a Job model to StatusInfo for OGC API responses +fn job_to_status_info(job: model::Job) -> StatusInfo { + let created = job.created_at(); + let updated = job.updated_at(); + let finished = job.finished_at(); + // let links: Vec = serde_json::from_value(job.links).unwrap_or_default(); + + StatusInfo { + job_id: job.job_id.to_string(), + process_id: job.process_id, + status: job.status.into(), + message: job.message.clone(), + r#type: job.job_type.into(), + created: Some(created), + updated: Some(updated), + finished, + progress: job.progress.map(|p| p as u8), + links: job.links.into_iter().map(Into::into).collect(), + } +} + #[cfg(test)] mod tests { use super::*; - use crate::{auth::User, config::CONFIG, db::setup_db}; + use crate::{auth::User, db::tests::with_temp_db}; use ogcapi::drivers::JobHandler as _; - async fn mock_db_pool() -> DbPool { - setup_db(&CONFIG.database).await.unwrap() - } - fn mock_user() -> User { User { id: uuid::Uuid::from_u128(0xabcd_efab_cdef_abcd_efab_cdef_abcd_efab), @@ -240,9 +282,9 @@ mod tests { } } - fn mock_status_info(job_id: &str) -> StatusInfo { + fn mock_status_info() -> StatusInfo { StatusInfo { - job_id: job_id.to_string(), + job_id: String::new(), // job_id must be empty when registering a new job process_id: Some("proc".to_string()), status: StatusCode::Accepted, message: Some("msg".to_string()), @@ -257,112 +299,128 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn test_register() { - let pool = mock_db_pool().await; - let handler = JobHandler::new(pool).await.unwrap(); - USER.scope(mock_user(), async move { - let status_info = mock_status_info(""); - let result = handler.register(&status_info, Response::Raw).await; - assert!(result.is_ok()); - let job_id = result.unwrap(); - assert!(!job_id.is_empty()); + with_temp_db(|db_pool| async move { + let handler = JobHandler::new(db_pool).await.unwrap(); + USER.scope(mock_user(), async move { + let status_info = mock_status_info(); + let result = handler.register(&status_info, Response::Raw).await; + assert!(result.is_ok()); + let job_id = result.unwrap(); + assert!(!job_id.is_empty()); + }) + .await; }) .await; } #[tokio::test(flavor = "multi_thread")] async fn test_update() { - let pool = mock_db_pool().await; - let handler = JobHandler::new(pool).await.unwrap(); - USER.scope(mock_user(), async move { - let status_info = mock_status_info("job1"); - // Register first - handler.register(&status_info, Response::Raw).await.unwrap(); - // Update - let mut updated_info = status_info.clone(); - updated_info.status = StatusCode::Running; - let result = handler.update(&updated_info).await; - assert!(result.is_ok()); + with_temp_db(|db_pool| async move { + let handler = JobHandler::new(db_pool).await.unwrap(); + USER.scope(mock_user(), async move { + let status_info = mock_status_info(); + // Register first + let job_id = handler.register(&status_info, Response::Raw).await.unwrap(); + // Update + let mut updated_info = status_info.clone(); + updated_info.job_id = job_id; + updated_info.status = StatusCode::Running; + let result = handler.update(&updated_info).await; + assert!(result.is_ok()); + }) + .await; }) .await; } #[tokio::test(flavor = "multi_thread")] async fn test_status_list() { - let pool = mock_db_pool().await; - let handler = JobHandler::new(pool).await.unwrap(); - USER.scope(mock_user(), async move { - // Register a job - let status_info = mock_status_info("job2"); - handler.register(&status_info, Response::Raw).await.unwrap(); - // List - let result = handler.status_list(0, 10).await; - assert!(result.is_ok()); - let list = result.unwrap(); - assert!(!list.is_empty()); + with_temp_db(|db_pool| async move { + let handler = JobHandler::new(db_pool).await.unwrap(); + USER.scope(mock_user(), async move { + // Register a job + let status_info = mock_status_info(); + handler.register(&status_info, Response::Raw).await.unwrap(); + // List + let result = handler.status_list(0, 10).await; + assert!(result.is_ok()); + let list = result.unwrap(); + assert!(!list.is_empty()); + }) + .await; }) .await; } #[tokio::test(flavor = "multi_thread")] async fn test_status() { - let pool = mock_db_pool().await; - let handler = JobHandler::new(pool).await.unwrap(); - USER.scope(mock_user(), async move { - let status_info = mock_status_info("job3"); - handler.register(&status_info, Response::Raw).await.unwrap(); - let result = handler.status("job3").await; - assert!(result.is_ok()); - let status = result.unwrap(); - assert!(status.is_some()); - assert_eq!(status.unwrap().job_id, "job3"); + with_temp_db(|db_pool| async move { + let handler = JobHandler::new(db_pool).await.unwrap(); + USER.scope(mock_user(), async move { + let status_info = mock_status_info(); + let job_id = handler.register(&status_info, Response::Raw).await.unwrap(); + let result = handler.status(&job_id).await; + assert!(result.is_ok()); + let status = result.unwrap(); + assert!(status.is_some()); + assert_eq!(status.unwrap().job_id, job_id); + }) + .await; }) .await; } #[tokio::test(flavor = "multi_thread")] async fn test_finish() { - let pool = mock_db_pool().await; - let handler = JobHandler::new(pool).await.unwrap(); - USER.scope(mock_user(), async move { - let status_info = mock_status_info("job4"); - handler.register(&status_info, Response::Raw).await.unwrap(); - let result = handler - .finish( - "job4", - &StatusCode::Successful, - Some("done".to_string()), - vec![], - None, - ) - .await; - assert!(result.is_ok()); + with_temp_db(|db_pool| async move { + let handler = JobHandler::new(db_pool).await.unwrap(); + USER.scope(mock_user(), async move { + let status_info = mock_status_info(); + let job_id = handler.register(&status_info, Response::Raw).await.unwrap(); + let result = handler + .finish( + &job_id, + &StatusCode::Successful, + Some("done".to_string()), + vec![], + None, + ) + .await; + assert!(result.is_ok()); + }) + .await; }) .await; } #[tokio::test(flavor = "multi_thread")] async fn test_dismiss() { - let pool = mock_db_pool().await; - let handler = JobHandler::new(pool).await.unwrap(); - USER.scope(mock_user(), async move { - let status_info = mock_status_info("job5"); - handler.register(&status_info, Response::Raw).await.unwrap(); - let result = handler.dismiss("job5").await; - assert!(result.is_ok()); - let dismissed = result.unwrap(); - assert!(dismissed.is_some()); - assert_eq!(dismissed.unwrap().status, StatusCode::Dismissed); + with_temp_db(|db_pool| async move { + let handler = JobHandler::new(db_pool).await.unwrap(); + USER.scope(mock_user(), async move { + let status_info = mock_status_info(); + let job_id = handler.register(&status_info, Response::Raw).await.unwrap(); + let result = handler.dismiss(&job_id).await; + assert!(result.is_ok()); + let dismissed = result.unwrap(); + assert!(dismissed.is_some()); + assert_eq!(dismissed.unwrap().status, StatusCode::Dismissed); + }) + .await; }) .await; } #[tokio::test(flavor = "multi_thread")] async fn test_results_no_job() { - let pool = mock_db_pool().await; - let handler = JobHandler::new(pool).await.unwrap(); - USER.scope(mock_user(), async move { - let result = handler.results("no_such_job").await; - assert!(matches!(result.unwrap(), ProcessResult::NoSuchJob)); + with_temp_db(|db_pool| async move { + let handler = JobHandler::new(db_pool).await.unwrap(); + USER.scope(mock_user(), async move { + let non_existent_id = "00000000-0000-0000-0000-000000000000"; + let result = handler.results(non_existent_id).await; + assert!(matches!(result.unwrap(), ProcessResult::NoSuchJob)); + }) + .await; }) .await; } diff --git a/backend/src/processes/biodiversity_sensitive_areas/mod.rs b/backend/src/processes/biodiversity_sensitive_areas/mod.rs index 1ac6d63..a25d122 100644 --- a/backend/src/processes/biodiversity_sensitive_areas/mod.rs +++ b/backend/src/processes/biodiversity_sensitive_areas/mod.rs @@ -1,6 +1,13 @@ use crate::{ - db::{DbPool, PooledConnection}, + db::{ + DbHandle, + util::{ + get_bool, get_number, get_number_option, get_string, get_string_list, + get_string_list_option, + }, + }, processes::{ + habitat_distance::natura2000_exists, parameters::{ Area, DataResource, DataResourceSchema, DocumentationSource, FeatureCollectionGeoJsonInput, Fields, Kilometers, RelativeJsonPointer, SquareMeter, @@ -12,8 +19,6 @@ use crate::{ }; use anyhow::{Context, Result}; use approx::AbsDiffEq; -use diesel::{deserialize::QueryableByName, sql_query, sql_types}; -use diesel_async::RunQueryDsl; use indoc::formatdoc; use ogcapi::{ processes::Processor, @@ -26,6 +31,7 @@ use ogcapi::{ use schemars::{JsonSchema, generate::SchemaSettings}; use serde::{Deserialize, Serialize}; use std::{collections::HashMap, str::FromStr}; +use toasty::stmt::Type as StmtType; use tracing::instrument; use utoipa::ToSchema; use wkt::ToWkt; @@ -87,7 +93,7 @@ impl FromStr for SiteSpecification { #[doc = include_str!("description.md")] #[derive(Debug, Clone)] pub struct BiodiversitySensitiveAreasProcess { - connection: DbPool, + connection: DbHandle, natura2000_schema: &'static str, } @@ -372,7 +378,7 @@ impl Processor for BiodiversitySensitiveAreasProcess { || execute.outputs.contains_key(output_keys::ERRORS) { let (site_table, errors) = compute_biodiversity_sensitive_areas( - self.connection().await?, + &mut self.connection.clone(), self.natura2000_schema, inputs.sites, inputs.location_name_field.as_ref(), @@ -399,51 +405,18 @@ impl Processor for BiodiversitySensitiveAreasProcess { impl BiodiversitySensitiveAreasProcess { pub const ID: &'static str = "biodiversity-sensitive-areas"; - pub async fn new(connection: DbPool, natura2000_schema: &'static str) -> Result { - let this = Self { - connection, - natura2000_schema, - }; - - let mut conn = this.connection().await?; - let table: Natura2000Exists = sql_query(formatdoc! {" - SELECT EXISTS ( - SELECT 1 - FROM information_schema.tables - WHERE table_schema = '{natura2000_schema}' - AND table_name = 'naturasite_polygon' - ) as exists - "}) - .get_result(&mut *conn) - .await - .context(format!( - "Failed to check if {natura2000_schema}.naturasite_polygon exists" - ))?; - - if !table.exists { + pub async fn new(mut db: DbHandle, natura2000_schema: &'static str) -> Result { + if !natura2000_exists(&mut db, natura2000_schema).await? { anyhow::bail!("Table {natura2000_schema}.naturasite_polygon does not exist"); } - // drop the pooled connection before moving `this` out - drop(conn); - - Ok(this) - } - - async fn connection(&self) -> anyhow::Result> { - self.connection - .get() - .await - .context("could not get db connection from pool") + Ok(Self { + connection: db, + natura2000_schema, + }) } } -#[derive(QueryableByName)] -struct Natura2000Exists { - #[diesel(sql_type = sql_types::Bool)] - exists: bool, -} - fn json_format() -> Format { Format { media_type: Some("application/json".to_string()), @@ -665,37 +638,81 @@ fn was_point(feature: &geojson::Feature) -> bool { ) } -#[derive(Deserialize, Serialize, Debug, PartialEq, QueryableByName)] +#[derive(Deserialize, Serialize, Debug, PartialEq, JsonSchema, ToSchema)] #[serde(rename_all = "camelCase")] pub struct SiteRow { - #[diesel(sql_type = sql_types::Text)] pub location: String, - - #[diesel(sql_type = sql_types::Nullable)] pub area_m2: Option, - - #[diesel(sql_type = sql_types::Bool)] pub site_in_biodiversity_sensitive_area: bool, - - #[diesel(sql_type = sql_types::Bool)] pub site_near_biodiversity_sensitive_area: bool, - - #[diesel(sql_type = sql_types::Double)] pub biodiversity_sensitive_area_m2: SquareMeter, - - #[diesel(sql_type = sql_types::Nullable>)] pub intersecting_biodiversity_sensitive_areas: Option>, - - #[diesel(sql_type = sql_types::Array)] pub nearby_biodiversity_sensitive_areas: Vec, - - #[diesel(sql_type = sql_types::Text)] pub site_type: String, - - #[diesel(sql_type = sql_types::Double)] pub buffer_distance_km: Kilometers, } +impl TryFrom for SiteRow { + type Error = anyhow::Error; + + fn try_from(value: toasty::stmt::Value) -> Result { + //0 r.location, + //1 r.buffer_distance_km as buffer_distance_km, + //2 r.specification AS site_type, + //3 NULLIF(r.area_m2, 0) AS area_m2, + //4 COALESCE(ST_AREA(ST_INTERSECTION(s_in.geom, r.geom)), 0) > 0 AS site_in_biodiversity_sensitive_area, + //5 ST_AREA(ST_INTERSECTION(s_out.geom, r.buffered_geom)) > 0 AS site_near_biodiversity_sensitive_area, + //6 ST_AREA(ST_INTERSECTION(s_out.geom, r.buffered_geom)) AS biodiversity_sensitive_area_m2, + //7 s_in.site_list AS intersecting_biodiversity_sensitive_areas, + //8 s_out.site_list AS nearby_biodiversity_sensitive_areas + const LOCATION_IDX: usize = 0; + const BUFFER_DISTANCE_KM_IDX: usize = 1; + const SITE_TYPE_IDX: usize = 2; + const AREA_M2_IDX: usize = 3; + const SITE_IN_BIODIVERSITY_SENSITIVE_AREA_IDX: usize = 4; + const SITE_NEAR_BIODIVERSITY_SENSITIVE_AREA_IDX: usize = 5; + const BIODIVERSITY_SENSITIVE_AREA_M2_IDX: usize = 6; + const INTERSECTING_BIODIVERSITY_SENSITIVE_AREAS_IDX: usize = 7; + const NEARBY_BIODIVERSITY_SENSITIVE_AREAS_IDX: usize = 8; + + Ok(SiteRow { + location: get_string(&value, LOCATION_IDX).context("Invalid location type")?, + area_m2: get_number_option(&value, AREA_M2_IDX) + .context("Invalid area_m2 type")? + .map(SquareMeter), + site_in_biodiversity_sensitive_area: get_bool( + &value, + SITE_IN_BIODIVERSITY_SENSITIVE_AREA_IDX, + ) + .context("Invalid site_in_biodiversity_sensitive_area type")?, + site_near_biodiversity_sensitive_area: get_bool( + &value, + SITE_NEAR_BIODIVERSITY_SENSITIVE_AREA_IDX, + ) + .context("Invalid site_near_biodiversity_sensitive_area type")?, + biodiversity_sensitive_area_m2: SquareMeter( + get_number(&value, BIODIVERSITY_SENSITIVE_AREA_M2_IDX) + .context("Invalid biodiversity_sensitive_area_m2 type")?, + ), + intersecting_biodiversity_sensitive_areas: get_string_list_option( + &value, + INTERSECTING_BIODIVERSITY_SENSITIVE_AREAS_IDX, + ) + .context("Invalid intersecting_biodiversity_sensitive_areas type")?, + nearby_biodiversity_sensitive_areas: get_string_list( + &value, + NEARBY_BIODIVERSITY_SENSITIVE_AREAS_IDX, + ) + .context("Invalid nearby_biodiversity_sensitive_areas type")?, + site_type: get_string(&value, SITE_TYPE_IDX).context("Invalid site_type type")?, + buffer_distance_km: Kilometers( + get_number(&value, BUFFER_DISTANCE_KM_IDX) + .context("Invalid buffer_distance_km type")?, + ), + }) + } +} + #[derive(Serialize, Debug, PartialEq, AbsDiffEq, JsonSchema, ToSchema)] #[serde(rename_all = "camelCase")] #[approx(epsilon_type = f64)] @@ -814,10 +831,10 @@ fn site_row_into_output( /// to any provided `workflow_refs` (if they are HTTP URLs) and includes their responses /// in the `sources` output for auditing. The spatial computations are placeholders and /// should be replaced by Geo Engine workflow calls in future. -#[instrument(skip(connection), err(Debug))] +#[instrument(skip(db), err(Debug))] pub async fn compute_biodiversity_sensitive_areas( - mut connection: PooledConnection<'_>, - natura2000_schema: &'static str, + db: &mut DbHandle, + natura2000_schema: &str, sites: FeatureCollectionGeoJsonInput, location_property: &str, site_type_property: &str, @@ -859,7 +876,7 @@ pub async fn compute_biodiversity_sensitive_areas( }); } - let site_table: Vec = sql_query(formatdoc! {r#" + let site_table: Vec = toasty::sql::query(&formatdoc!(r#" WITH reference AS ( SELECT v.location, @@ -902,11 +919,28 @@ pub async fn compute_biodiversity_sensitive_areas( GROUP BY r.location ) as s_out USING (location) ORDER BY biodiversity_sensitive_area_m2 DESC; - "#}) - .bind::(serde_json::to_value(&site_inputs)?) - .get_results(&mut *connection) + "#)) + .bind(serde_json::to_string(&site_inputs)?) + .column_types([ + StmtType::String, + StmtType::F64, + StmtType::String, + StmtType::F64, + StmtType::Bool, + StmtType::F64, + StmtType::F64, + StmtType::list(StmtType::String), + StmtType::list(StmtType::String), + ]) + .exec(db.as_mut()) .await - .context(format!("Failed to query {natura2000_schema}.naturasite_polygon"))?; + .context(format!( + "Failed to query {natura2000_schema}.naturasite_polygon" + ))? + .into_iter() + .map(SiteRow::try_from) + .collect::, _>>() + .context("Failed to parse site row results")?; Ok((site_table, errors)) } @@ -915,49 +949,53 @@ pub async fn compute_biodiversity_sensitive_areas( mod tests { use super::*; use crate::{ - CONFIG, - db::setup_db, + db::tests::with_temp_db, processes::parameters::{GeoJsonInputMediaType, Hectare}, }; use approx::abs_diff_ne; - use diesel_async::SimpleAsyncConnection; use geojson::FeatureCollection; use ogcapi::types::processes::Input; use pretty_assertions::assert_eq; use serde_json::json; + use toasty::schema::db::Type as DbType; - async fn mock_db_pool() -> DbPool { - setup_db(&CONFIG.database).await.unwrap() - } + async fn create_schema_and_insert_test_site(db: &mut DbHandle) { + let schema = db.schema_name().to_string(); - async fn create_schema_and_insert_test_site( - connection: &mut impl SimpleAsyncConnection, - schema: &str, - ) { - connection - .batch_execute(&formatdoc! {r#" + toasty::sql::statement(formatdoc! {r#" CREATE TABLE "{schema}".naturasite_polygon ( sitecode TEXT, sitename TEXT, geom geometry ); + "#}) + .exec(db.as_mut()) + .await + .unwrap(); + toasty::sql::statement(formatdoc! {r#" INSERT INTO "{schema}".naturasite_polygon (sitecode, sitename, geom) VALUES ( 'DE5118301', 'Dammelsberg und Köhlersgrund', - ST_GeomFromText('{wkt1}', 3035) + ST_GeomFromText($1, 3035) ), ( 'DE5317307', 'Fohnbach und Gleibach', - ST_GeomFromText('{wkt2}', 3035) + ST_GeomFromText($2, 3035) ); - "#, - wkt1 = include_str!("../../../test-data/DE5118301.wkt"), - wkt2 = include_str!("../../../test-data/DE5317307.wkt"), - }) - .await - .unwrap(); + "#}) + .bind_typed( + include_str!("../../../test-data/DE5118301.wkt"), + DbType::Text, + ) + .bind_typed( + include_str!("../../../test-data/DE5317307.wkt"), + DbType::Text, + ) + .exec(db.as_mut()) + .await + .unwrap(); } #[test] @@ -1027,55 +1065,57 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn process_summary_has_expected_inputs_and_outputs() { - let pool = mock_db_pool().await; - create_schema_and_insert_test_site(&mut pool.get().await.unwrap(), &CONFIG.database.schema) - .await; + with_temp_db(|mut db| async move { + create_schema_and_insert_test_site(&mut db).await; + + let schema = db.schema_name().to_string(); + let p = BiodiversitySensitiveAreasProcess::new(db, schema.leak()) + .await + .unwrap(); + let process = p.process().expect("to produce process description"); + + // summary id / version + assert_eq!(process.summary.id, "biodiversity-sensitive-areas"); + assert_eq!(process.summary.version, "0.1.0"); + + // job control options contain sync and async execute + let mut has_sync = false; + let mut has_async = false; + for opt in &process.summary.job_control_options { + match opt { + JobControlOptions::SyncExecute => has_sync = true, + JobControlOptions::AsyncExecute => has_async = true, + JobControlOptions::Dismiss => todo!(), + } + } + assert!(has_sync, "expected SyncExecute in job_control_options"); + assert!(has_async, "expected AsyncExecute in job_control_options"); - let p = BiodiversitySensitiveAreasProcess::new(pool, &CONFIG.database.schema) - .await - .unwrap(); - let process = p.process().expect("to produce process description"); - - // summary id / version - assert_eq!(process.summary.id, "biodiversity-sensitive-areas"); - assert_eq!(process.summary.version, "0.1.0"); - - // job control options contain sync and async execute - let mut has_sync = false; - let mut has_async = false; - for opt in &process.summary.job_control_options { - match opt { - JobControlOptions::SyncExecute => has_sync = true, - JobControlOptions::AsyncExecute => has_async = true, - JobControlOptions::Dismiss => todo!(), + for key in [ + input_keys::SITES, + input_keys::LOCATION_NAME_FIELD, + input_keys::SITE_TYPE_FIELD, + input_keys::UNIT_FOR_AREA, + ] { + assert!( + process.inputs.contains_key(key), + "expected input key `{key}` in process inputs" + ); } - } - assert!(has_sync, "expected SyncExecute in job_control_options"); - assert!(has_async, "expected AsyncExecute in job_control_options"); - - for key in [ - input_keys::SITES, - input_keys::LOCATION_NAME_FIELD, - input_keys::SITE_TYPE_FIELD, - input_keys::UNIT_FOR_AREA, - ] { - assert!( - process.inputs.contains_key(key), - "expected input key `{key}` in process inputs" - ); - } - for key in [ - output_keys::BIODIVERSITY_SENSITIVE_AREAS, - output_keys::INPUTS, - output_keys::ERRORS, - output_keys::DOCUMENTATION_SOURCES, - ] { - assert!( - process.outputs.contains_key(key), - "expected output key `{key}` in process outputs" - ); - } + for key in [ + output_keys::BIODIVERSITY_SENSITIVE_AREAS, + output_keys::INPUTS, + output_keys::ERRORS, + output_keys::DOCUMENTATION_SOURCES, + ] { + assert!( + process.outputs.contains_key(key), + "expected output key `{key}` in process outputs" + ); + } + }) + .await } #[tokio::test(flavor = "multi_thread")] @@ -1084,157 +1124,163 @@ mod tests { reason = "This test is verbose due to the detailed assertions on the process outputs." )] async fn it_computes_biodiversity_sensitive_areas() { - let pool = mock_db_pool().await; - let mut connection = pool.get().await.unwrap(); - create_schema_and_insert_test_site(&mut connection, &CONFIG.database.schema).await; - - // crate::util::setup_tracing( - // crate::config::Logging { - // level: crate::config::LogLevel::Debug, - // } - // .into(), - // ); - - let inputs = BiodiversitySensitiveAreasProcessInputs { - sites: FeatureCollectionGeoJsonInput { - value: json!({ - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [8.773_665_480_497_84, 50.803_270_291_022_386], - [8.773_649_409_958_182, 50.802_437_463_615_604], - [8.774_613_642_351_255, 50.802_412_072_303_04], - [8.774_597_571_811_597, 50.803_255_056_507_936], - [8.773_665_480_497_84, 50.803_270_291_022_386] + with_temp_db(|mut db| async move { + create_schema_and_insert_test_site(&mut db).await; + + // crate::util::setup_tracing( + // level: crate::config::LogLevel::Debug, + // } + // .into(), + // ); + + let inputs = BiodiversitySensitiveAreasProcessInputs { + sites: FeatureCollectionGeoJsonInput { + value: json!({ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [8.773_665_480_497_84, 50.803_270_291_022_386], + [8.773_649_409_958_182, 50.802_437_463_615_604], + [8.774_613_642_351_255, 50.802_412_072_303_04], + [8.774_597_571_811_597, 50.803_255_056_507_936], + [8.773_665_480_497_84, 50.803_270_291_022_386] + ] ] - ] - }, - "properties": { - "location": "Marburger Unistadion", - "siteType": "office" - } - }, - { - "type": "Feature", - "geometry": { - "type": "Point", - "coordinates": [8.770_273_718_309_227, 50.807_468_318_244_67] - }, - "properties": { - "location": "Garten des Gedenkens", - "siteType": "other" - } - }, - { - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [8.754_139_485_384, 50.809_101_655_468], - [8.754_266_459_025, 50.808_497_270_648], - [8.755_374_371_521, 50.809_035_957_506], - [8.754_139_485_384, 50.809_101_655_468] - ] - ] - }, - "properties": { - "location": "Auf dem Dammelsberg", - "siteType": "office" - } - }, - { - "type": "Feature", - "geometry": { - "type": "Point", - "coordinates": [8.770_273_718_309_227, 50.807_468_318_244_67] - }, - "id": "the-error-feature", - "properties": { - "siteType": "other" - } - } - ] - }) - .to_string() - .as_str() - .parse::() - .unwrap() - .into(), - media_type: GeoJsonInputMediaType::GeoJson, - }, - location_name_field: "location".into(), - site_type_field: "siteType".into(), - unit_for_area: UnitForArea::Hectare, - }; - - let (site_rows, errors) = compute_biodiversity_sensitive_areas( - connection, - CONFIG.database.schema.as_str(), - inputs.sites, - inputs.location_name_field.as_ref(), - inputs.site_type_field.as_ref(), - inputs.unit_for_area, - ) - .await - .unwrap(); + }, + "properties": { + "location": "Marburger Unistadion", + "siteType": "office" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [8.770_273_718_309_227, 50.807_468_318_244_67] + }, + "properties": { + "location": "Garten des Gedenkens", + "siteType": "other" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [8.754_139_485_384, 50.809_101_655_468], + [8.754_266_459_025, 50.808_497_270_648], + [8.755_374_371_521, 50.809_035_957_506], + [8.754_139_485_384, 50.809_101_655_468] + ] + ] + }, + "properties": { + "location": "Auf dem Dammelsberg", + "siteType": "office" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [8.770_273_718_309_227, 50.807_468_318_244_67] + }, + "id": "the-error-feature", + "properties": { + "siteType": "other" + } + } + ] + }) + .to_string() + .as_str() + .parse::() + .unwrap() + .into(), + media_type: GeoJsonInputMediaType::GeoJson, + }, + location_name_field: "location".into(), + site_type_field: "siteType".into(), + unit_for_area: UnitForArea::Hectare, + }; + + let schema = db.schema_name().to_string(); + let (site_rows, errors) = compute_biodiversity_sensitive_areas( + &mut db, + &schema, + inputs.sites, + inputs.location_name_field.as_ref(), + inputs.site_type_field.as_ref(), + inputs.unit_for_area, + ) + .await + .unwrap(); - let site_row_outputs = site_row_into_output(site_rows, inputs.unit_for_area); - - let expected = vec![ - SiteRowOutput { - location: "Garten des Gedenkens".into(), - area: None, - site_in_biodiversity_sensitive_area: false, - site_near_biodiversity_sensitive_area: true, - biodiversity_sensitive_area: Area::Hectare(Hectare(36.307_653_383_984_075)), - specification: "Type \"Other\" with buffer distance of 20 km".into(), - intersecting_biodiversity_sensitive_areas: vec![], - nearby_biodiversity_sensitive_areas: vec![ - "Dammelsberg und Köhlersgrund (DE5118301)".into(), - "Fohnbach und Gleibach (DE5317307)".into(), - ], - }, - SiteRowOutput { - location: "Auf dem Dammelsberg".into(), - area: Some(Area::Hectare(Hectare(0.289_339_552_615_731_47))), - site_in_biodiversity_sensitive_area: true, - site_near_biodiversity_sensitive_area: true, - biodiversity_sensitive_area: Area::Hectare(Hectare(21.794_987_588_368_837)), - specification: "Type \"Office\" (was point) with buffer distance of 5 km".into(), - intersecting_biodiversity_sensitive_areas: vec![ - "Dammelsberg und Köhlersgrund (DE5118301)".into(), - ], - nearby_biodiversity_sensitive_areas: vec![ - "Dammelsberg und Köhlersgrund (DE5118301)".into(), - ], - }, - SiteRowOutput { - location: "Marburger Unistadion".into(), - area: Some(Area::Hectare(Hectare(0.623_035_886_691_041_7))), - site_in_biodiversity_sensitive_area: false, - site_near_biodiversity_sensitive_area: true, - biodiversity_sensitive_area: Area::Hectare(Hectare(21.794_987_588_368_837)), - specification: "Type \"Office\" (was point) with buffer distance of 5 km".into(), - intersecting_biodiversity_sensitive_areas: vec![], - nearby_biodiversity_sensitive_areas: vec![ - "Dammelsberg und Köhlersgrund (DE5118301)".into(), - ], - }, - ]; + let site_row_outputs = site_row_into_output(site_rows, inputs.unit_for_area); + + let expected = vec![ + SiteRowOutput { + location: "Garten des Gedenkens".into(), + area: None, + site_in_biodiversity_sensitive_area: false, + site_near_biodiversity_sensitive_area: true, + biodiversity_sensitive_area: Area::Hectare(Hectare(36.307_653_383_984_075)), + specification: "Type \"Other\" with buffer distance of 20 km".into(), + intersecting_biodiversity_sensitive_areas: vec![], + nearby_biodiversity_sensitive_areas: vec![ + "Dammelsberg und Köhlersgrund (DE5118301)".into(), + "Fohnbach und Gleibach (DE5317307)".into(), + ], + }, + SiteRowOutput { + location: "Auf dem Dammelsberg".into(), + area: Some(Area::Hectare(Hectare(0.289_339_552_615_731_47))), + site_in_biodiversity_sensitive_area: true, + site_near_biodiversity_sensitive_area: true, + biodiversity_sensitive_area: Area::Hectare(Hectare(21.794_987_588_368_837)), + specification: "Type \"Office\" (was point) with buffer distance of 5 km" + .into(), + intersecting_biodiversity_sensitive_areas: vec![ + "Dammelsberg und Köhlersgrund (DE5118301)".into(), + ], + nearby_biodiversity_sensitive_areas: vec![ + "Dammelsberg und Köhlersgrund (DE5118301)".into(), + ], + }, + SiteRowOutput { + location: "Marburger Unistadion".into(), + area: Some(Area::Hectare(Hectare(0.623_035_886_691_041_7))), + site_in_biodiversity_sensitive_area: false, + site_near_biodiversity_sensitive_area: true, + biodiversity_sensitive_area: Area::Hectare(Hectare(21.794_987_588_368_837)), + specification: "Type \"Office\" (was point) with buffer distance of 5 km" + .into(), + intersecting_biodiversity_sensitive_areas: vec![], + nearby_biodiversity_sensitive_areas: vec![ + "Dammelsberg und Köhlersgrund (DE5118301)".into(), + ], + }, + ]; - if abs_diff_ne!(site_row_outputs.data, expected, epsilon = 1e-6,) { - assert_eq!(site_row_outputs.data, expected); // pretty assertions - } + if abs_diff_ne!(site_row_outputs.data, expected, epsilon = 1e-6,) { + assert_eq!(site_row_outputs.data, expected); // pretty assertions + } - assert_eq!( - errors, - vec!["Feature `the-error-feature` is missing location property `location`".to_string(),] - ); + assert_eq!( + errors, + vec![ + "Feature `the-error-feature` is missing location property `location`" + .to_string(), + ] + ); + }) + .await; } } diff --git a/backend/src/processes/habitat_distance.rs b/backend/src/processes/habitat_distance.rs index 1188004..2194393 100644 --- a/backend/src/processes/habitat_distance.rs +++ b/backend/src/processes/habitat_distance.rs @@ -1,13 +1,7 @@ -use crate::{ - db::{DbPool, PooledConnection}, - processes::parameters::PointGeoJsonInput, -}; +use crate::db::util::{get_bool, get_number}; +use crate::db::{DbHandle, util::get_string}; +use crate::processes::parameters::PointGeoJsonInput; use anyhow::{Context, Result}; -use diesel::{ - sql_query, - sql_types::{Double, Text}, -}; -use diesel_async::RunQueryDsl; use geojson::PointType; use indoc::formatdoc; use ogcapi::{ @@ -24,65 +18,55 @@ use ogcapi::{ use schemars::{JsonSchema, generate::SchemaSettings}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; +use toasty::Db; +use toasty::{schema::db::Type as DbType, stmt::Type as StmtType}; use tracing::instrument; use utoipa::ToSchema; /// Calculates the distance to the nearest habitat of interest based on the provided coordinate input. #[derive(Debug, Clone)] pub struct HabitatDistanceProcess { - connection: DbPool, + connection: DbHandle, natura2000_schema: &'static str, } -use diesel::QueryableByName; -use diesel::sql_types::Bool; - -#[derive(QueryableByName)] -struct Natura2000Exists { - #[diesel(sql_type = Bool)] - exists: bool, -} - impl HabitatDistanceProcess { pub const ID: &'static str = "habitatDistance"; - pub async fn new(connection: DbPool, natura2000_schema: &'static str) -> Result { - let this = Self { - connection, + pub async fn new(mut db: DbHandle, natura2000_schema: &'static str) -> Result { + if !natura2000_exists(&mut db, natura2000_schema).await? { + anyhow::bail!("Table {natura2000_schema}.naturasite_polygon does not exist"); + } + + Ok(Self { + connection: db, natura2000_schema, - }; + }) + } +} - let mut conn = this.connection().await?; - let table: Natura2000Exists = sql_query(formatdoc! {" +/// Check if the Natura2000 table exists +pub async fn natura2000_exists(db: &mut DbHandle, natura2000_schema: &'static str) -> Result { + let result = toasty::sql::query(formatdoc! {" SELECT EXISTS ( SELECT 1 FROM information_schema.tables - WHERE table_schema = '{natura2000_schema}' + WHERE table_schema = $1 AND table_name = 'naturasite_polygon' ) as exists "}) - .get_result(&mut *conn) - .await - .context(format!( - "Failed to check if {natura2000_schema}.naturasite_polygon exists" - ))?; - - if !table.exists { - anyhow::bail!("Table {natura2000_schema}.naturasite_polygon does not exist"); - } - - // drop the pooled connection before moving `this` out - drop(conn); - - Ok(this) - } + .bind_typed(natura2000_schema, DbType::Text) + .column_types([StmtType::Bool]) + .exec(db.as_mut()) + .await + .context(format!( + "Failed to check if {natura2000_schema}.naturasite_polygon exists" + ))? + .into_iter() + .next() + .context("No result returned from existence check")?; - async fn connection(&self) -> anyhow::Result> { - self.connection - .get() - .await - .context("could not get db connection from pool") - } + get_bool(&result, 0).context("Invalid exists type") } #[derive(Deserialize, Serialize, Debug, JsonSchema, ToSchema)] @@ -266,7 +250,7 @@ impl Processor for HabitatDistanceProcess { let inputs: HabitatDistanceProcessInputs = serde_json::from_value(value)?; match compute_habitat_distance( - self.connection().await?, + &mut self.connection.db(), self.natura2000_schema, &inputs.coordinate.value.coordinates, ) @@ -280,20 +264,28 @@ impl Processor for HabitatDistanceProcess { } } -#[derive(QueryableByName)] struct Natura2000NearestHabitat { - #[diesel(sql_type = Text)] sitecode: String, - #[diesel(sql_type = Text)] sitename: String, - #[diesel(sql_type = Double)] distance_m: f64, } -#[instrument(skip(connection), err(Debug))] +impl TryFrom for Natura2000NearestHabitat { + type Error = anyhow::Error; + + fn try_from(value: toasty::stmt::Value) -> Result { + Ok(Natura2000NearestHabitat { + sitecode: get_string(&value, 0).context("Invalid sitecode type")?, + sitename: get_string(&value, 1).context("Invalid sitename type")?, + distance_m: get_number(&value, 2).context("Invalid distance type")?, + }) + } +} + +#[instrument(skip(db), level = "debug", err(Debug))] async fn compute_habitat_distance( - mut connection: PooledConnection<'_>, - natura2000_schema: &'static str, + db: &mut Db, + natura2000_schema: &str, coordinate: &PointType, ) -> Result { let [lon, lat] = coordinate.as_slice() else { @@ -301,23 +293,30 @@ async fn compute_habitat_distance( return Err(anyhow::anyhow!("Invalid coordinate")); }; let point_geometry = format!("SRID=4326;POINT({lon} {lat})"); - let table: Natura2000NearestHabitat = sql_query(formatdoc! {r#" - WITH reference AS ( + + let table: Natura2000NearestHabitat = toasty::sql::query(&formatdoc!( + r#"WITH reference AS ( SELECT ST_Transform($1::geometry, 3035) AS point ) - SELECT s.sitecode, + SELECT + s.sitecode, s.sitename, - ST_Distance(s.geom, reference.point) AS distance_m + CAST(ST_Distance(s.geom, reference.point) AS DOUBLE PRECISION) AS distance_m FROM "{natura2000_schema}".naturasite_polygon s, reference ORDER BY s.geom <-> reference.point - LIMIT 1 - "#}) - .bind::(point_geometry) - .get_result(&mut *connection) + LIMIT 1"# + )) + .bind_typed(point_geometry, DbType::Text) + .column_types([StmtType::String, StmtType::String, StmtType::F64]) + .exec(db) .await .context(format!( "Failed to query {natura2000_schema}.naturasite_polygon" - ))?; + ))? + .into_iter() + .next() + .context("No nearest habitat found for the given coordinate")? + .try_into()?; Ok(HabitatDistanceProcessOutputs { habitat_code: Some(table.sitecode), @@ -329,38 +328,36 @@ async fn compute_habitat_distance( #[cfg(test)] mod tests { use super::*; - use crate::CONFIG; - use crate::db::setup_db; - use diesel_async::SimpleAsyncConnection; + use crate::db::tests::with_temp_db; use ogcapi::types::processes::Input; - async fn mock_db_pool() -> DbPool { - setup_db(&CONFIG.database).await.unwrap() - } + async fn create_schema_and_insert_test_site(db: &mut DbHandle) { + let schema = dbg!(db.schema_name().to_string()); + let wkt = include_str!("../../test-data/DE5417402.wkt"); - async fn create_schema_and_insert_test_site( - connection: &mut impl SimpleAsyncConnection, - schema: &str, - ) { - connection - .batch_execute(&formatdoc! {r#" + toasty::sql::statement(formatdoc! {r#" CREATE TABLE "{schema}".naturasite_polygon ( sitecode TEXT, sitename TEXT, geom geometry - ); + ) + "#}) + .exec(db.as_mut()) + .await + .unwrap(); - INSERT INTO "{schema}".naturasite_polygon (sitecode, sitename, geom) + toasty::sql::statement(formatdoc!( + r#"INSERT INTO "{schema}".naturasite_polygon (sitecode, sitename, geom) VALUES ( 'DE5417402', 'Feldflur bei Hüttenberg und Schöffengrund', - ST_GeomFromText('{wkt}', 3035) - ); - "#, - wkt = include_str!("../../test-data/DE5417402.wkt"), - }) - .await - .unwrap(); + ST_GeomFromText($1, 3035) + )"#, + )) + .bind_typed(wkt, DbType::Text) + .exec(db.as_mut()) + .await + .unwrap(); } #[test] @@ -384,71 +381,69 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn it_computes_the_nearest_habitat() { - let pool = mock_db_pool().await; - - // create schema / table and insert a test site - let mut conn = pool.get().await.unwrap(); - create_schema_and_insert_test_site(&mut *conn, &CONFIG.database.schema).await; - - // consume the same connection in the computation (transaction stays open for test cleanup) - let outputs = compute_habitat_distance( - conn, - &CONFIG.database.schema, - &PointType::from((8.46, 50.49)), - ) - .await - .unwrap(); - - assert_eq!(outputs.habitat_code.unwrap(), "DE5417402"); - assert_eq!( - outputs.habitat_name.unwrap(), - "Feldflur bei Hüttenberg und Schöffengrund" - ); - // distance should be very small (point exactly matches) - assert_eq!(outputs.distance_m.unwrap(), 1415); + // crate::util::setup_tracing_for_tests(); + with_temp_db(|mut db| async move { + // create schema / table and insert a test site + create_schema_and_insert_test_site(&mut db).await; + + // compute the habitat distance + let schema = db.schema_name().to_string(); + let outputs = + compute_habitat_distance(&mut db, &schema, &PointType::from((8.46, 50.49))) + .await + .unwrap(); + + assert_eq!(outputs.habitat_code.unwrap(), "DE5417402"); + assert_eq!( + outputs.habitat_name.unwrap(), + "Feldflur bei Hüttenberg und Schöffengrund" + ); + // distance should be very small (point exactly matches) + assert_eq!(outputs.distance_m.unwrap(), 1415); + }) + .await; } #[tokio::test(flavor = "multi_thread")] async fn process_summary_has_expected_inputs_and_outputs() { - let pool = mock_db_pool().await; - create_schema_and_insert_test_site( - &mut *pool.get().await.unwrap(), - &CONFIG.database.schema, - ) - .await; - - let p = HabitatDistanceProcess::new(pool, &CONFIG.database.schema) - .await - .unwrap(); - let process = p.process().expect("to produce process description"); - - // summary id / version - assert_eq!(process.summary.id, "habitatDistance"); - assert_eq!(process.summary.version, "0.1.0"); - - // job control options contain sync and async execute - let mut has_sync = false; - let mut has_async = false; - for opt in &process.summary.job_control_options { - match opt { - JobControlOptions::SyncExecute => has_sync = true, - JobControlOptions::AsyncExecute => has_async = true, - JobControlOptions::Dismiss => todo!(), + with_temp_db(|mut db| async move { + create_schema_and_insert_test_site(&mut db).await; + + let schema = db.schema_name().to_string(); + let p = HabitatDistanceProcess::new(db, schema.leak()) + .await + .unwrap(); + let process = p.process().expect("to produce process description"); + + // summary id / version + assert_eq!(process.summary.id, "habitatDistance"); + assert_eq!(process.summary.version, "0.1.0"); + + // job control options contain sync and async execute + let mut has_sync = false; + let mut has_async = false; + for opt in &process.summary.job_control_options { + match opt { + JobControlOptions::SyncExecute => has_sync = true, + JobControlOptions::AsyncExecute => has_async = true, + JobControlOptions::Dismiss => todo!(), + } } - } - assert!(has_sync, "expected SyncExecute in job_control_options"); - assert!(has_async, "expected AsyncExecute in job_control_options"); + assert!(has_sync, "expected SyncExecute in job_control_options"); + assert!(has_async, "expected AsyncExecute in job_control_options"); - // inputs contain only coordinate - assert!(process.inputs.contains_key("coordinate")); + // inputs contain only coordinate + assert!(process.inputs.contains_key("coordinate")); - // outputs contain habitatCode, habitatName and habitatDistance - assert!(process.outputs.contains_key("habitatCode")); - assert!(process.outputs.contains_key("habitatName")); - assert!(process.outputs.contains_key("habitatDistance")); + // outputs contain habitatCode, habitatName and habitatDistance + assert!(process.outputs.contains_key("habitatCode")); + assert!(process.outputs.contains_key("habitatName")); + assert!(process.outputs.contains_key("habitatDistance")); - // some basic checks for descriptions and schema presence - let habitat_distance_output = &process.outputs["habitatDistance"]; - assert!(habitat_distance_output.schema.is_object()); + // some basic checks for descriptions and schema presence + let habitat_distance_output = &process.outputs["habitatDistance"]; + assert!(habitat_distance_output.schema.is_object()); + }) + .await; } } diff --git a/backend/src/processes/parameters/units.rs b/backend/src/processes/parameters/units.rs index 6ffefb8..186a945 100644 --- a/backend/src/processes/parameters/units.rs +++ b/backend/src/processes/parameters/units.rs @@ -299,33 +299,6 @@ impl HasTableSchemaType for Percentage { } } -mod db { - use super::*; - use diesel::{ - deserialize::{self, FromSql}, - pg::{Pg, PgValue}, - sql_types::Double, - }; - - impl FromSql for Hectare { - fn from_sql(value: PgValue<'_>) -> deserialize::Result { - f64::from_sql(value).map(Hectare) - } - } - - impl FromSql for SquareMeter { - fn from_sql(value: PgValue<'_>) -> deserialize::Result { - f64::from_sql(value).map(SquareMeter) - } - } - - impl FromSql for Kilometers { - fn from_sql(value: PgValue<'_>) -> deserialize::Result { - f64::from_sql(value).map(Kilometers) - } - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/backend/src/server.rs b/backend/src/server.rs index 38342ce..5846cd2 100644 --- a/backend/src/server.rs +++ b/backend/src/server.rs @@ -125,7 +125,7 @@ impl Modify for ResultsSchemaModifier { async fn add_habitat_distance_process( processors: &mut Vec>, - db_pool: crate::db::DbPool, + db_pool: crate::db::DbHandle, ) { match HabitatDistanceProcess::new(db_pool, "Natura2000").await { Ok(habitat_distance_process) => { @@ -145,7 +145,7 @@ async fn add_habitat_distance_process( async fn add_biodiversity_sensitive_areas_process( processors: &mut Vec>, - db_pool: crate::db::DbPool, + db_pool: crate::db::DbHandle, ) { match BiodiversitySensitiveAreasProcess::new(db_pool, "Natura2000").await { Ok(biodiversity_sensitive_areas_process) => { diff --git a/backend/src/util.rs b/backend/src/util.rs index 24573d2..28f441e 100644 --- a/backend/src/util.rs +++ b/backend/src/util.rs @@ -2,6 +2,7 @@ use geoengine_api_client::models::{ RasterOperator, TypedOperator, TypedRasterOperator, TypedVectorOperator, VectorOperator, Workflow, typed_raster_operator::Type as RasterType, typed_vector_operator::Type as VectorType, }; +use serde::Deserialize; use std::ops::Deref; use tokio::task::JoinHandle; use tracing::error; @@ -70,6 +71,13 @@ pub(crate) fn write_lock(mutex: &std::sync::RwLock) -> std::sync::RwLockWr /// A wrapper type to hide sensitive information in Debug implementations. pub struct Secret(pub T); +impl Secret { + /// Returns the inner string. + pub fn expose(&self) -> &str { + &self.0 + } +} + impl std::fmt::Debug for Secret { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "********") @@ -102,6 +110,16 @@ impl From for Secret { } } +impl<'de> Deserialize<'de> for Secret { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + Ok(Secret(s)) + } +} + /// Extracts the heading from a markdown string, if it starts with a level 1 heading (`# `). pub fn md_heading(s: &str) -> &str { let mut lines = s.lines(); @@ -142,6 +160,12 @@ pub fn setup_tracing(log_level: Directive) { .init(); } +#[cfg(test)] +#[allow(dead_code, reason = "Used in tests to setup tracing")] +pub fn setup_tracing_for_tests() { + setup_tracing("info".parse().unwrap()); +} + /// A macro to concatenate two static strings (`&'static str`) at compile time. /// /// Do not confuse this with the `concat!` macro, which concatenates string literals at compile time. From 533b42d65db4f26949c90c22486df896e73166c6 Mon Sep 17 00:00:00 2001 From: Christian Beilschmidt Date: Sat, 8 Aug 2026 11:00:18 +0200 Subject: [PATCH 02/11] feat: update dependencies and improve database schema handling --- backend/Cargo.lock | 382 ++++++++++++++++-- backend/Cargo.toml | 7 +- backend/src/bin/openapi.rs | 3 +- backend/src/config.rs | 4 +- backend/src/db/mod.rs | 53 +-- backend/src/db/model.rs | 92 +---- backend/src/db/tests.rs | 25 +- backend/src/db/util.rs | 121 +++--- backend/src/jobs.rs | 44 +- backend/src/main.rs | 2 +- .../biodiversity_sensitive_areas/mod.rs | 61 ++- backend/src/processes/habitat_distance.rs | 19 +- backend/src/util.rs | 18 +- 13 files changed, 536 insertions(+), 295 deletions(-) diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 8de746a..42bba9b 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -304,6 +304,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base16ct" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd307490d624467aa6f74b0eabb77633d1f758a7b25f12bceb0b22e08d9726f6" + [[package]] name = "base64" version = "0.22.1" @@ -341,11 +347,12 @@ dependencies = [ "serde", "serde_json", "tempfile", - "toasty", + "toasty 0.9.0 (git+https://github.com/tokio-rs/toasty?branch=main)", "toasty-cli", "tokio", "tower", "tracing", + "tracing-appender", "tracing-subscriber", "url", "utoipa", @@ -407,6 +414,12 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + [[package]] name = "borsh" version = "1.8.0" @@ -730,6 +743,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -808,6 +827,20 @@ version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" +[[package]] +name = "crypto-bigint" +version = "0.7.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a52aa3fcda4e6302a9f48734f234d35d4721b96f8fe07d073f07ce9df4f0271" +dependencies = [ + "cpubits", + "ctutils", + "num-traits", + "rand_core 0.10.1", + "serdect", + "zeroize", +] + [[package]] name = "crypto-common" version = "0.1.7" @@ -827,6 +860,16 @@ dependencies = [ "hybrid-array", ] +[[package]] +name = "crypto-primes" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3633a51a39c69ebbaa4feaa694bd83d241e4093901c84a0963b19d9bb3f0cf8f" +dependencies = [ + "crypto-bigint", + "rand_core 0.10.1", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -930,7 +973,18 @@ dependencies = [ "const-oid 0.9.6", "der_derive", "flagset", - "pem-rfc7468", + "pem-rfc7468 0.7.0", + "zeroize", +] + +[[package]] +name = "der" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69dedd701da44b0536442edf09c81a64b0ab97a7a4a5e3d1971f00027cbc63d" +dependencies = [ + "const-oid 0.10.2", + "pem-rfc7468 1.0.0", "zeroize", ] @@ -1152,6 +1206,17 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "fluent-uri" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e" +dependencies = [ + "borrow-or-share", + "ref-cast", + "serde", +] + [[package]] name = "flume" version = "0.11.1" @@ -1508,6 +1573,8 @@ version = "0.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.2.0", ] @@ -1552,7 +1619,7 @@ dependencies = [ "http", "httpdate", "mime", - "sha1", + "sha1 0.10.7", ] [[package]] @@ -2752,6 +2819,15 @@ dependencies = [ "base64ct", ] +[[package]] +name = "pem-rfc7468" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" +dependencies = [ + "base64ct", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2831,9 +2907,19 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f" dependencies = [ - "der", - "pkcs8", - "spki", + "der 0.7.10", + "pkcs8 0.10.2", + "spki 0.7.3", +] + +[[package]] +name = "pkcs1" +version = "0.8.0-rc.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "986d2e952779af96ea048f160fd9194e1751b4faea78bcf3ceb456efe008088e" +dependencies = [ + "der 0.8.1", + "spki 0.8.0", ] [[package]] @@ -2842,8 +2928,18 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7" dependencies = [ - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", +] + +[[package]] +name = "pkcs8" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7" +dependencies = [ + "der 0.8.1", + "spki 0.8.0", ] [[package]] @@ -3266,15 +3362,33 @@ dependencies = [ "num-bigint-dig", "num-integer", "num-traits", - "pkcs1", - "pkcs8", + "pkcs1 0.7.5", + "pkcs8 0.10.2", "rand_core 0.6.4", - "signature", - "spki", + "signature 2.2.0", + "spki 0.7.3", "subtle", "zeroize", ] +[[package]] +name = "rsa" +version = "0.10.0-rc.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30b2aa4ba0d89f73d1e332df05be0eeab8840351c36ca5654341dfdb57bb3caf" +dependencies = [ + "const-oid 0.10.2", + "crypto-bigint", + "crypto-primes", + "digest 0.11.3", + "pkcs1 0.8.0-rc.4", + "pkcs8 0.11.0", + "rand_core 0.10.1", + "signature 3.0.0", + "spki 0.8.0", + "zeroize", +] + [[package]] name = "rstar" version = "0.8.4" @@ -3774,6 +3888,16 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serdect" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66cf8fedced2fcf12406bcb34223dffb92eaf34908ede12fed414c82b7f00b3e" +dependencies = [ + "base16ct", + "serde", +] + [[package]] name = "sha1" version = "0.10.7" @@ -3785,6 +3909,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha1" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "digest 0.11.3", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3848,6 +3983,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "signature" +version = "3.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5" +dependencies = [ + "digest 0.11.3", + "rand_core 0.10.1", +] + [[package]] name = "simd-adler32" version = "0.3.10" @@ -3917,7 +4062,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d" dependencies = [ "base64ct", - "der", + "der 0.7.10", +] + +[[package]] +name = "spki" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d9efca8738c78ee9484207732f728b1ef517bbb1833d6fc0879ca898a522f6f" +dependencies = [ + "base64ct", + "der 0.8.1", ] [[package]] @@ -3926,9 +4081,9 @@ version = "0.8.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fefb893899429669dcdd979aff487bd78f4064e5e7907e4269081e0ef7d97dc" dependencies = [ - "sqlx-core", + "sqlx-core 0.8.6", "sqlx-macros", - "sqlx-mysql", + "sqlx-mysql 0.8.6", "sqlx-postgres", "sqlx-sqlite", ] @@ -3969,6 +4124,39 @@ dependencies = [ "webpki-roots 0.26.11", ] +[[package]] +name = "sqlx-core" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" +dependencies = [ + "base64", + "bytes", + "cfg-if", + "crossbeam-queue", + "either", + "event-listener", + "futures-core", + "futures-intrusive", + "futures-io", + "futures-util", + "hashbrown 0.16.1", + "hashlink 0.11.1", + "indexmap 2.14.0", + "log", + "memchr", + "percent-encoding", + "rustls", + "smallvec", + "thiserror 2.0.19", + "time", + "tokio", + "tokio-stream", + "tracing", + "url", + "webpki-roots 1.0.9", +] + [[package]] name = "sqlx-macros" version = "0.8.6" @@ -3977,7 +4165,7 @@ checksum = "a2d452988ccaacfbf5e0bdbc348fb91d7c8af5bee192173ac3636b5fb6e6715d" dependencies = [ "proc-macro2", "quote", - "sqlx-core", + "sqlx-core 0.8.6", "sqlx-macros-core", "syn 2.0.119", ] @@ -3998,8 +4186,8 @@ dependencies = [ "serde", "serde_json", "sha2 0.10.9", - "sqlx-core", - "sqlx-mysql", + "sqlx-core 0.8.6", + "sqlx-mysql 0.8.6", "sqlx-postgres", "sqlx-sqlite", "syn 2.0.119", @@ -4037,18 +4225,45 @@ dependencies = [ "once_cell", "percent-encoding", "rand 0.8.7", - "rsa", + "rsa 0.9.10", "serde", - "sha1", + "sha1 0.10.7", "sha2 0.10.9", "smallvec", - "sqlx-core", + "sqlx-core 0.8.6", "stringprep", "thiserror 2.0.19", "tracing", "whoami 1.6.1", ] +[[package]] +name = "sqlx-mysql" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90b8020fe17c5f2c245bfa2505d7ef59c5604839527c740266ad2214acebea27" +dependencies = [ + "bitflags 2.13.1", + "byteorder", + "bytes", + "digest 0.11.3", + "dotenvy", + "either", + "futures-core", + "futures-util", + "generic-array 0.14.7", + "log", + "percent-encoding", + "rand 0.10.2", + "rsa 0.10.0-rc.18", + "sha1 0.11.0", + "sha2 0.11.0", + "sqlx-core 0.9.0", + "thiserror 2.0.19", + "time", + "tracing", +] + [[package]] name = "sqlx-postgres" version = "0.8.6" @@ -4079,7 +4294,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "sqlx-core", + "sqlx-core 0.8.6", "stringprep", "thiserror 2.0.19", "tracing", @@ -4104,7 +4319,7 @@ dependencies = [ "percent-encoding", "serde", "serde_urlencoded", - "sqlx-core", + "sqlx-core 0.8.6", "thiserror 2.0.19", "tracing", "url", @@ -4145,6 +4360,12 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + [[package]] name = "syn" version = "1.0.109" @@ -4382,11 +4603,8 @@ dependencies = [ "indexmap 2.14.0", "inventory", "serde", - "serde_core", - "serde_json", - "toasty-core", - "toasty-driver-postgresql", - "toasty-macros", + "toasty-core 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", + "toasty-macros 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", "tokio", "tokio-stream", "toml", @@ -4396,6 +4614,31 @@ dependencies = [ "uuid", ] +[[package]] +name = "toasty" +version = "0.9.0" +source = "git+https://github.com/tokio-rs/toasty?branch=main#19c834a127c0a32c87f9958748cba82abc462d20" +dependencies = [ + "async-trait", + "bit-set", + "by_address", + "deadpool", + "hashbrown 0.17.1", + "index_vec", + "indexmap 2.14.0", + "inventory", + "serde_core", + "serde_json", + "toasty-core 0.9.0 (git+https://github.com/tokio-rs/toasty?branch=main)", + "toasty-driver-mysql", + "toasty-driver-postgresql", + "toasty-macros 0.9.0 (git+https://github.com/tokio-rs/toasty?branch=main)", + "tokio", + "tokio-stream", + "tracing", + "uuid", +] + [[package]] name = "toasty-cli" version = "0.9.0" @@ -4410,7 +4653,7 @@ dependencies = [ "jiff", "rand 0.10.2", "serde", - "toasty", + "toasty 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", "toml", "url", ] @@ -4432,30 +4675,63 @@ dependencies = [ "uuid", ] +[[package]] +name = "toasty-core" +version = "0.9.0" +source = "git+https://github.com/tokio-rs/toasty?branch=main#19c834a127c0a32c87f9958748cba82abc462d20" +dependencies = [ + "async-trait", + "bit-set", + "fluent-uri", + "form_urlencoded", + "hashbrown 0.17.1", + "heck", + "indexmap 2.14.0", + "percent-encoding", + "serde", + "tokio-stream", + "toml", + "tracing", + "uuid", +] + +[[package]] +name = "toasty-driver-mysql" +version = "0.9.0" +source = "git+https://github.com/tokio-rs/toasty?branch=main#19c834a127c0a32c87f9958748cba82abc462d20" +dependencies = [ + "async-trait", + "sqlx-core 0.9.0", + "sqlx-mysql 0.9.0", + "time", + "toasty-core 0.9.0 (git+https://github.com/tokio-rs/toasty?branch=main)", + "toasty-sql", + "tokio", + "tracing", + "uuid", +] + [[package]] name = "toasty-driver-postgresql" version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7bc49e29cd2521b4835aa342adb99fe362a0b38083dda31112218c2762dfe09b" +source = "git+https://github.com/tokio-rs/toasty?branch=main#19c834a127c0a32c87f9958748cba82abc462d20" dependencies = [ "async-trait", "fallible-iterator", "hashbrown 0.17.1", "lru", - "percent-encoding", "postgres-protocol", "postgres-types", "rustls", "rustls-pemfile", "rustls-platform-verifier", "rustls-webpki", - "toasty-core", + "toasty-core 0.9.0 (git+https://github.com/tokio-rs/toasty?branch=main)", "toasty-sql", "tokio", "tokio-postgres", "tokio-postgres-rustls", "tracing", - "url", "uuid", ] @@ -4471,18 +4747,31 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.3", - "toasty-core", + "toasty-core 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", +] + +[[package]] +name = "toasty-macros" +version = "0.9.0" +source = "git+https://github.com/tokio-rs/toasty?branch=main#19c834a127c0a32c87f9958748cba82abc462d20" +dependencies = [ + "hashbrown 0.17.1", + "heck", + "pluralizer", + "proc-macro2", + "quote", + "syn 3.0.3", + "toasty-core 0.9.0 (git+https://github.com/tokio-rs/toasty?branch=main)", ] [[package]] name = "toasty-sql" version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b19948205b682d1750a8cf331b0b7fa666baffe2a3a2c0a05d5e94103c0fcc08" +source = "git+https://github.com/tokio-rs/toasty?branch=main#19c834a127c0a32c87f9958748cba82abc462d20" dependencies = [ "serde", "serde_json", - "toasty-core", + "toasty-core 0.9.0 (git+https://github.com/tokio-rs/toasty?branch=main)", ] [[package]] @@ -4717,6 +5006,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror 2.0.19", + "time", + "tracing-subscriber", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -5425,8 +5727,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1301e935010a701ae5f8655edc0ad17c44bad3ac5ce8c39185f75453b720ae94" dependencies = [ "const-oid 0.9.6", - "der", - "spki", + "der 0.7.10", + "spki 0.7.3", "tls_codec", ] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index b00ef22..72b13a8 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -74,10 +74,15 @@ schemars = "1.2" serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" tempfile = "3.27" -toasty = { version = "0.9", features = ["postgresql", "serde"] } +# toasty = { version = "0.9", features = ["postgresql", "serde"] } +toasty = { git = "https://github.com/tokio-rs/toasty", branch = "main", features = [ + "postgresql", + "serde", +] } tokio = { version = "1.49", features = ["rt-multi-thread", "macros"] } tower = "0.5" tracing = "0.1" +tracing-appender = "0.2" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } url = "2.5" utoipa = { version = "5.5", features = ["uuid"] } diff --git a/backend/src/bin/openapi.rs b/backend/src/bin/openapi.rs index 48b7bd5..c8229c8 100644 --- a/backend/src/bin/openapi.rs +++ b/backend/src/bin/openapi.rs @@ -5,10 +5,11 @@ )] use anyhow::Context; -use biois::server; +use biois::{CONFIG, server, setup_tracing}; #[tokio::main] async fn main() -> anyhow::Result<()> { + let _tracing_guard = setup_tracing(CONFIG.logging.clone().into()); println!("{}", openapi_json().await?); Ok(()) diff --git a/backend/src/config.rs b/backend/src/config.rs index 083b2db..5bab653 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -47,13 +47,13 @@ pub struct Database { impl Database { pub fn connection_string(&self) -> String { format!( - "postgresql://{user}:{password}@{host}:{port}/{database}?options=-c%20search_path%3D{schema}", + "postgresql://{user}:{password}@{host}:{port}/{database}?options=-c%20search_path%3D{schema},public", user = self.user, password = self.password.expose(), host = self.host, port = self.port, database = self.database, - schema = self.schema + schema = self.schema, ) } } diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index 55f3554..ae720fb 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -14,6 +14,7 @@ pub mod util; #[derive(Clone, Debug)] pub struct DbHandle { db: toasty::Db, + #[allow(unused, reason = "used at least in tests")] schema: String, } @@ -39,6 +40,7 @@ impl AsMut for DbHandle { impl DbHandle { /// Returns the name of the schema used by this database handle. + #[cfg(test)] pub fn schema_name(&self) -> &str { &self.schema } @@ -57,7 +59,7 @@ pub async fn setup_db(config: &crate::config::Database) -> Result { .max_pool_size(if cfg!(test) { 1 } else { 8 }) .pool_wait_timeout(Some(Duration::from_secs(5))) .pool_create_timeout(Some(Duration::from_secs(10))) - .pool_health_check_interval(Some(Duration::from_secs(60))) + .pool_health_check_interval(Some(Duration::from_mins(1))) .connect(&config.connection_string()) .await .context("Failed to connect to database")?; @@ -75,45 +77,30 @@ async fn on_startup(db: &mut toasty::Db, config: &crate::config::Database) -> Re // For production, migrations should be applied separately via CLI if cfg!(test) || config.clear_database_on_start { - if config.clear_database_on_start { - info!("Clearing database schema '{}'", config.schema); - // Drop and recreate schema - toasty::sql::statement(format!("DROP SCHEMA IF EXISTS {} CASCADE;", &config.schema)) - .exec(db) - .await - .context("Failed to clear database schema")?; - } + // TODO: do not do this if `clear_database_on_start` was false before restarting the server. - info!("Creating database schema '{}'", config.schema); - toasty::sql::statement(format!("CREATE SCHEMA IF NOT EXISTS {};", &config.schema)) - .exec(db) - .await - .context("Failed to create database schema")?; - - toasty::sql::statement(&format!( - r#"SET search_path TO "{schema_name}", public;"#, - schema_name = &config.schema + info!("Clearing database schema '{}'", config.schema); + // Drop and recreate schema + toasty::sql::statement(format!( + "DROP SCHEMA IF EXISTS {schema} CASCADE;", + schema = config.schema )) .exec(db) .await - .context("Failed to set search_path for database schema")?; + .context("Failed to clear database schema")?; - if cfg!(test) { - let mut connection = db.connection().await?; + info!("Creating database schema '{}'", config.schema); + toasty::sql::statement(format!( + "CREATE SCHEMA IF NOT EXISTS {schema};", + schema = config.schema + )) + .exec(db) + .await + .context("Failed to create database schema")?; - toasty::sql::statement(&format!( - r#"SET search_path TO "{schema_name}", public;"#, - schema_name = &config.schema - )) - .exec(&mut connection) + db.push_schema() .await - .context("Failed to set search_path for database schema")?; - - connection - .push_schema() - .await - .context("Failed to push schema to database")?; - } + .context("Failed to push schema to database")?; } // TODO: In production, use proper migrations diff --git a/backend/src/db/model.rs b/backend/src/db/model.rs index 8c3399a..a9e7078 100644 --- a/backend/src/db/model.rs +++ b/backend/src/db/model.rs @@ -4,8 +4,11 @@ use serde::{Deserialize, Serialize}; use uuid::Uuid; /// Job status code enumeration -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, toasty::Embed)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, toasty::Embed, o2o)] #[serde(rename_all = "PascalCase")] +#[from_owned(ogcapi::types::processes::StatusCode)] +#[owned_into(ogcapi::types::processes::StatusCode)] +#[column(type = text)] pub enum StatusCode { Accepted, Running, @@ -14,79 +17,25 @@ pub enum StatusCode { Dismissed, } -impl From for ogcapi::types::processes::StatusCode { - fn from(val: StatusCode) -> Self { - match val { - StatusCode::Accepted => ogcapi::types::processes::StatusCode::Accepted, - StatusCode::Running => ogcapi::types::processes::StatusCode::Running, - StatusCode::Successful => ogcapi::types::processes::StatusCode::Successful, - StatusCode::Failed => ogcapi::types::processes::StatusCode::Failed, - StatusCode::Dismissed => ogcapi::types::processes::StatusCode::Dismissed, - } - } -} - -impl From for StatusCode { - fn from(val: ogcapi::types::processes::StatusCode) -> Self { - match val { - ogcapi::types::processes::StatusCode::Accepted => StatusCode::Accepted, - ogcapi::types::processes::StatusCode::Running => StatusCode::Running, - ogcapi::types::processes::StatusCode::Successful => StatusCode::Successful, - ogcapi::types::processes::StatusCode::Failed => StatusCode::Failed, - ogcapi::types::processes::StatusCode::Dismissed => StatusCode::Dismissed, - } - } -} - /// Job type enumeration -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, toasty::Embed)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, toasty::Embed, o2o)] #[serde(rename_all = "PascalCase")] +#[from_owned(ogcapi::types::processes::JobType)] +#[owned_into(ogcapi::types::processes::JobType)] pub enum JobType { Process, } -impl From for ogcapi::types::processes::JobType { - fn from(val: JobType) -> Self { - match val { - JobType::Process => ogcapi::types::processes::JobType::Process, - } - } -} - -impl From for JobType { - fn from(val: ogcapi::types::processes::JobType) -> Self { - match val { - ogcapi::types::processes::JobType::Process => JobType::Process, - } - } -} - /// Response type enumeration -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, toasty::Embed)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, toasty::Embed, o2o)] #[serde(rename_all = "PascalCase")] +#[from_owned(ogcapi::types::processes::Response)] +#[owned_into(ogcapi::types::processes::Response)] pub enum Response { Raw, Document, } -impl From for ogcapi::types::processes::Response { - fn from(val: Response) -> Self { - match val { - Response::Raw => ogcapi::types::processes::Response::Raw, - Response::Document => ogcapi::types::processes::Response::Document, - } - } -} - -impl From for Response { - fn from(val: ogcapi::types::processes::Response) -> Self { - match val { - ogcapi::types::processes::Response::Raw => Response::Raw, - ogcapi::types::processes::Response::Document => Response::Document, - } - } -} - /// Link reference (stored as JSONB) #[derive(Debug, Clone, Serialize, Deserialize, o2o, toasty::Embed)] #[from_owned(ogcapi::types::common::Link)] @@ -111,12 +60,14 @@ pub struct Job { /// Primary key: job ID #[key] #[auto(uuid(v7))] + #[allow(clippy::struct_field_names, reason = "Database field name")] pub job_id: uuid::Uuid, /// Referenced process ID pub process_id: Option, /// Job type + #[allow(clippy::struct_field_names, reason = "Database field name")] pub job_type: JobType, /// Current status @@ -155,35 +106,35 @@ pub struct Job { } impl Job { - /// Convert created timestamp to DateTime + /// Convert created timestamp to `DateTime` pub fn created_at(&self) -> DateTime { - DateTime::::from_timestamp_millis(self.created).unwrap_or_else(|| Utc::now()) + DateTime::::from_timestamp_millis(self.created).unwrap_or_else(Utc::now) } - /// Convert finished timestamp to DateTime + /// Convert finished timestamp to `DateTime` pub fn finished_at(&self) -> Option> { self.finished - .and_then(|ts| DateTime::::from_timestamp_millis(ts)) + .and_then(DateTime::::from_timestamp_millis) } - /// Convert updated timestamp to DateTime + /// Convert updated timestamp to `DateTime` pub fn updated_at(&self) -> DateTime { - DateTime::::from_timestamp_millis(self.updated).unwrap_or_else(|| Utc::now()) + DateTime::::from_timestamp_millis(self.updated).unwrap_or_else(Utc::now) } - /// Set created from DateTime + /// Set created from `DateTime` pub fn with_created(mut self, dt: DateTime) -> Self { self.created = dt.timestamp_millis(); self } - /// Set finished from DateTime + /// Set finished from `DateTime` pub fn with_finished(mut self, dt: Option>) -> Self { self.finished = dt.map(|d| d.timestamp_millis()); self } - /// Set updated from DateTime + /// Set updated from `DateTime` pub fn with_updated(mut self, dt: DateTime) -> Self { self.updated = dt.timestamp_millis(); self @@ -210,6 +161,7 @@ pub struct Credits { pub compute_id: ComputeId, /// Credits used; empty if not yet determined (e.g., job still running) + #[allow(clippy::struct_field_names, reason = "Database field name")] pub credits: Option, } diff --git a/backend/src/db/tests.rs b/backend/src/db/tests.rs index c7a45b4..f1f05e3 100644 --- a/backend/src/db/tests.rs +++ b/backend/src/db/tests.rs @@ -86,19 +86,14 @@ fn test_schema() -> String { format!("geoengine_test_{}", Uuid::now_v7().as_simple()) } -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn it_generates_test_schema_names() { - let test_schema_name = test_schema(); - assert!(test_schema_name.starts_with("geoengine_test_")); - assert!( - test_schema_name - .chars() - .all(|c| c.is_ascii_alphanumeric() || c == '_'), - "{test_schema_name} contains invalid characters" - ); - } +#[test] +fn it_generates_test_schema_names() { + let test_schema_name = test_schema(); + assert!(test_schema_name.starts_with("geoengine_test_")); + assert!( + test_schema_name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '_'), + "{test_schema_name} contains invalid characters" + ); } diff --git a/backend/src/db/util.rs b/backend/src/db/util.rs index 8f2bb1d..d2ba715 100644 --- a/backend/src/db/util.rs +++ b/backend/src/db/util.rs @@ -1,71 +1,88 @@ +use anyhow::Context; +use anyhow::Result; use toasty::stmt::Value; -pub fn get_string(value: &Value, idx: usize) -> Option { - let record = value.as_record()?; - match record.get(idx) { - Some(Value::String(s)) => Some(s.clone()), - _ => None, - } +pub trait RecordValueExt { + fn get_string(&self, idx: usize) -> Result; + fn get_string_list(&self, idx: usize) -> Result>; + fn get_string_list_option(&self, idx: usize) -> Result>>; + fn get_number(&self, idx: usize) -> Result; + fn get_number_option(&self, idx: usize) -> Result>; + fn get_bool(&self, idx: usize) -> Result; } -pub fn get_string_list(value: &Value, idx: usize) -> Option> { - let record = value.as_record()?; - match record.get(idx) { - Some(Value::List(list)) => { - let mut result = Vec::new(); - for item in list { - if let Value::String(s) = item { - result.push(s.clone()); - } else { - return None; - } - } - Some(result) +impl RecordValueExt for Value { + fn get_string(&self, idx: usize) -> Result { + value_to_string(record_value_at_index(self, idx)?) + } + + fn get_string_list(&self, idx: usize) -> Result> { + list_value_to_string_list(record_value_at_index(self, idx)?) + } + + fn get_string_list_option(&self, idx: usize) -> Result>> { + let value = record_value_at_index(self, idx)?; + if value.is_null() { + return Ok(None); } - _ => None, + + list_value_to_string_list(value).map(Some) } -} -pub fn get_string_list_option(value: &Value, idx: usize) -> Option>> { - let record = value.as_record()?; - match record.get(idx) { - Some(Value::List(list)) => { - let mut result = Vec::new(); - for item in list { - if let Value::String(s) = item { - result.push(s.clone()); - } else { - return None; - } - } - Some(Some(result)) + fn get_number(&self, idx: usize) -> Result { + value_to_number(record_value_at_index(self, idx)?) + } + + fn get_number_option(&self, idx: usize) -> Result> { + let value = record_value_at_index(self, idx)?; + if value.is_null() { + return Ok(None); + } + value_to_number(value).map(Some) + } + + fn get_bool(&self, idx: usize) -> Result { + match record_value_at_index(self, idx)? { + Value::Bool(b) => Ok(*b), + _ => anyhow::bail!("expected bool value at index {idx}"), } - Some(Value::Null) => Some(None), - _ => None, } } -pub fn get_number(value: &Value, idx: usize) -> Option { - let record = value.as_record()?; - match record.get(idx) { - Some(Value::F64(n)) => Some(*n), - _ => None, +#[inline] +fn value_to_string(value: &Value) -> Result { + match value { + Value::String(s) => Ok(s.clone()), + _ => anyhow::bail!("expected string value"), } } -pub fn get_number_option(value: &Value, idx: usize) -> Option> { - let record = value.as_record()?; - match record.get(idx) { - Some(Value::F64(n)) => Some(Some(*n)), - Some(Value::Null) => Some(None), - _ => None, +#[inline] +fn list_value_to_string_list(value: &Value) -> Result> { + let Value::List(list) = value else { + anyhow::bail!("expected list value"); + }; + + let mut result = Vec::new(); + for item in list { + result.push(value_to_string(item)?); } + Ok(result) } -pub fn get_bool(value: &Value, idx: usize) -> Option { - let record = value.as_record()?; - match record.get(idx) { - Some(Value::Bool(b)) => Some(*b), - _ => None, +#[inline] +fn value_to_number(value: &Value) -> Result { + match value { + Value::F64(n) => Ok(*n), + _ => anyhow::bail!("expected number value"), } } + +#[inline] +fn record_value_at_index(value: &Value, idx: usize) -> Result<&Value> { + let record = value.as_record().context("expected record value")?; + let Some(value) = record.get(idx) else { + anyhow::bail!("expected value at index {idx}"); + }; + Ok(value) +} diff --git a/backend/src/jobs.rs b/backend/src/jobs.rs index e3f348b..563188d 100644 --- a/backend/src/jobs.rs +++ b/backend/src/jobs.rs @@ -30,14 +30,6 @@ impl JobHandler { #[instrument(skip(self), level = "debug")] async fn clean_running_jobs_from_previous_sessions(&mut self) -> anyhow::Result<()> { let now_ms = Utc::now().timestamp_millis(); - let schema = self.db.schema_name().to_string(); - - let mut tx = self.db.as_mut().transaction().await?; - - toasty::sql::statement(format!("SET LOCAL search_path TO {schema}, public;")) - .exec(&mut tx) - .await - .context("Failed to set search path")?; toasty::update!(model::Job::filter( model::Job::fields().status().eq(model::StatusCode::Running) @@ -46,11 +38,9 @@ impl JobHandler { message: Some("Server restarted during job execution".to_string()), updated: now_ms, }) - .exec(&mut tx) + .exec(&mut self.db.db()) .await - .context("Failed to clean up running jobs")?; - - tx.commit().await.context("Failed to commit transaction") + .context("Failed to clean up running jobs") } } @@ -59,19 +49,13 @@ impl ogcapi::drivers::JobHandler for JobHandler { async fn register(&self, job: &StatusInfo, response_mode: Response) -> anyhow::Result { let user = USER.try_get().context("missing authenticated user")?; - if !dbg!(&job.job_id).is_empty() { + if job.job_id.is_empty() { anyhow::bail!("Job ID must be empty when registering a new job"); - }; + } let now_ms = Utc::now().timestamp_millis(); - let created_ms = job - .created - .map(|dt| dt.timestamp_millis()) - .unwrap_or(now_ms); - let updated_ms = job - .updated - .map(|dt| dt.timestamp_millis()) - .unwrap_or(now_ms); + let created_ms = job.created.map_or(now_ms, |dt| dt.timestamp_millis()); + let updated_ms = job.updated.map_or(now_ms, |dt| dt.timestamp_millis()); let job = toasty::create!(model::Job { process_id: job.process_id.clone(), @@ -103,13 +87,10 @@ impl ogcapi::drivers::JobHandler for JobHandler { async fn update(&self, job: &StatusInfo) -> anyhow::Result<()> { let job_id = Uuid::parse_str(&job.job_id).context("Invalid job ID format")?; let now_ms = Utc::now().timestamp_millis(); - let updated_ms = job - .updated - .map(|dt| dt.timestamp_millis()) - .unwrap_or(now_ms); - let progress = job.progress.map(|p| p as i16).unwrap_or(0); + let updated_ms = job.updated.map_or(now_ms, |dt| dt.timestamp_millis()); + let progress = job.progress.map_or(0, i16::from); - toasty::update!(model::Job::filter_by_job_id(&job_id) { + toasty::update!(model::Job::filter_by_job_id(job_id) { status: model::StatusCode::from(job.status.clone()), message: job.message.clone(), updated: updated_ms, @@ -166,7 +147,7 @@ impl ogcapi::drivers::JobHandler for JobHandler { let job_id = Uuid::parse_str(job_id).context("Invalid job ID format")?; let results_json = results.map(serde_json::to_value).transpose()?; - toasty::update!(model::Job::filter_by_job_id(&job_id) { + toasty::update!(model::Job::filter_by_job_id(job_id) { status: model::StatusCode::from(status.clone()), message, updated: now_ms, @@ -189,7 +170,7 @@ impl ogcapi::drivers::JobHandler for JobHandler { let id = Uuid::parse_str(id).context("Invalid job ID format")?; // First, get the current job to return - let current_job: Option = model::Job::filter_by_job_id(&id) + let current_job: Option = model::Job::filter_by_job_id(id) .filter(model::Job::fields().user_id().eq(user.id)) .first() .exec(&mut self.db.db()) @@ -248,12 +229,11 @@ impl ogcapi::drivers::JobHandler for JobHandler { } } -/// Convert a Job model to StatusInfo for OGC API responses +/// Convert a Job model to `StatusInfo` for OGC API responses fn job_to_status_info(job: model::Job) -> StatusInfo { let created = job.created_at(); let updated = job.updated_at(); let finished = job.finished_at(); - // let links: Vec = serde_json::from_value(job.links).unwrap_or_default(); StatusInfo { job_id: job.job_id.to_string(), diff --git a/backend/src/main.rs b/backend/src/main.rs index 7e9d3e6..510075c 100644 --- a/backend/src/main.rs +++ b/backend/src/main.rs @@ -2,7 +2,7 @@ use biois::{CONFIG, server, setup_tracing}; #[tokio::main] async fn main() -> anyhow::Result<()> { - setup_tracing(CONFIG.logging.clone().into()); + let _tracing_guard = setup_tracing(CONFIG.logging.clone().into()); let service = server().await?; service.serve().await diff --git a/backend/src/processes/biodiversity_sensitive_areas/mod.rs b/backend/src/processes/biodiversity_sensitive_areas/mod.rs index a25d122..b1c2255 100644 --- a/backend/src/processes/biodiversity_sensitive_areas/mod.rs +++ b/backend/src/processes/biodiversity_sensitive_areas/mod.rs @@ -1,11 +1,5 @@ use crate::{ - db::{ - DbHandle, - util::{ - get_bool, get_number, get_number_option, get_string, get_string_list, - get_string_list_option, - }, - }, + db::{DbHandle, util::RecordValueExt}, processes::{ habitat_distance::natura2000_exists, parameters::{ @@ -676,37 +670,36 @@ impl TryFrom for SiteRow { const NEARBY_BIODIVERSITY_SENSITIVE_AREAS_IDX: usize = 8; Ok(SiteRow { - location: get_string(&value, LOCATION_IDX).context("Invalid location type")?, - area_m2: get_number_option(&value, AREA_M2_IDX) + location: value + .get_string(LOCATION_IDX) + .context("Invalid location type")?, + area_m2: value + .get_number_option(AREA_M2_IDX) .context("Invalid area_m2 type")? .map(SquareMeter), - site_in_biodiversity_sensitive_area: get_bool( - &value, - SITE_IN_BIODIVERSITY_SENSITIVE_AREA_IDX, - ) - .context("Invalid site_in_biodiversity_sensitive_area type")?, - site_near_biodiversity_sensitive_area: get_bool( - &value, - SITE_NEAR_BIODIVERSITY_SENSITIVE_AREA_IDX, - ) - .context("Invalid site_near_biodiversity_sensitive_area type")?, + site_in_biodiversity_sensitive_area: value + .get_bool(SITE_IN_BIODIVERSITY_SENSITIVE_AREA_IDX) + .context("Invalid site_in_biodiversity_sensitive_area type")?, + site_near_biodiversity_sensitive_area: value + .get_bool(SITE_NEAR_BIODIVERSITY_SENSITIVE_AREA_IDX) + .context("Invalid site_near_biodiversity_sensitive_area type")?, biodiversity_sensitive_area_m2: SquareMeter( - get_number(&value, BIODIVERSITY_SENSITIVE_AREA_M2_IDX) + value + .get_number(BIODIVERSITY_SENSITIVE_AREA_M2_IDX) .context("Invalid biodiversity_sensitive_area_m2 type")?, ), - intersecting_biodiversity_sensitive_areas: get_string_list_option( - &value, - INTERSECTING_BIODIVERSITY_SENSITIVE_AREAS_IDX, - ) - .context("Invalid intersecting_biodiversity_sensitive_areas type")?, - nearby_biodiversity_sensitive_areas: get_string_list( - &value, - NEARBY_BIODIVERSITY_SENSITIVE_AREAS_IDX, - ) - .context("Invalid nearby_biodiversity_sensitive_areas type")?, - site_type: get_string(&value, SITE_TYPE_IDX).context("Invalid site_type type")?, + intersecting_biodiversity_sensitive_areas: value + .get_string_list_option(INTERSECTING_BIODIVERSITY_SENSITIVE_AREAS_IDX) + .context("Invalid intersecting_biodiversity_sensitive_areas type")?, + nearby_biodiversity_sensitive_areas: value + .get_string_list(NEARBY_BIODIVERSITY_SENSITIVE_AREAS_IDX) + .context("Invalid nearby_biodiversity_sensitive_areas type")?, + site_type: value + .get_string(SITE_TYPE_IDX) + .context("Invalid site_type type")?, buffer_distance_km: Kilometers( - get_number(&value, BUFFER_DISTANCE_KM_IDX) + value + .get_number(BUFFER_DISTANCE_KM_IDX) .context("Invalid buffer_distance_km type")?, ), }) @@ -876,7 +869,7 @@ pub async fn compute_biodiversity_sensitive_areas( }); } - let site_table: Vec = toasty::sql::query(&formatdoc!(r#" + let site_table: Vec = toasty::sql::query(formatdoc!(r#" WITH reference AS ( SELECT v.location, @@ -1115,7 +1108,7 @@ mod tests { ); } }) - .await + .await; } #[tokio::test(flavor = "multi_thread")] diff --git a/backend/src/processes/habitat_distance.rs b/backend/src/processes/habitat_distance.rs index 2194393..f6eb97e 100644 --- a/backend/src/processes/habitat_distance.rs +++ b/backend/src/processes/habitat_distance.rs @@ -1,6 +1,7 @@ -use crate::db::util::{get_bool, get_number}; -use crate::db::{DbHandle, util::get_string}; -use crate::processes::parameters::PointGeoJsonInput; +use crate::{ + db::{DbHandle, util::RecordValueExt}, + processes::parameters::PointGeoJsonInput, +}; use anyhow::{Context, Result}; use geojson::PointType; use indoc::formatdoc; @@ -66,7 +67,7 @@ pub async fn natura2000_exists(db: &mut DbHandle, natura2000_schema: &'static st .next() .context("No result returned from existence check")?; - get_bool(&result, 0).context("Invalid exists type") + result.get_bool(0).context("Invalid exists type") } #[derive(Deserialize, Serialize, Debug, JsonSchema, ToSchema)] @@ -275,9 +276,9 @@ impl TryFrom for Natura2000NearestHabitat { fn try_from(value: toasty::stmt::Value) -> Result { Ok(Natura2000NearestHabitat { - sitecode: get_string(&value, 0).context("Invalid sitecode type")?, - sitename: get_string(&value, 1).context("Invalid sitename type")?, - distance_m: get_number(&value, 2).context("Invalid distance type")?, + sitecode: value.get_string(0).context("Invalid sitecode type")?, + sitename: value.get_string(1).context("Invalid sitename type")?, + distance_m: value.get_number(2).context("Invalid distance type")?, }) } } @@ -294,7 +295,7 @@ async fn compute_habitat_distance( }; let point_geometry = format!("SRID=4326;POINT({lon} {lat})"); - let table: Natura2000NearestHabitat = toasty::sql::query(&formatdoc!( + let table: Natura2000NearestHabitat = toasty::sql::query(formatdoc!( r#"WITH reference AS ( SELECT ST_Transform($1::geometry, 3035) AS point ) @@ -332,7 +333,7 @@ mod tests { use ogcapi::types::processes::Input; async fn create_schema_and_insert_test_site(db: &mut DbHandle) { - let schema = dbg!(db.schema_name().to_string()); + let schema = db.schema_name().to_string(); let wkt = include_str!("../../test-data/DE5417402.wkt"); toasty::sql::statement(formatdoc! {r#" diff --git a/backend/src/util.rs b/backend/src/util.rs index 28f441e..2b67f32 100644 --- a/backend/src/util.rs +++ b/backend/src/util.rs @@ -149,21 +149,29 @@ pub fn md_content(s: &str) -> &str { s[first_content_index..].trim() } -pub fn setup_tracing(log_level: Directive) { +pub fn setup_tracing(log_level: Directive) -> tracing_appender::non_blocking::WorkerGuard { + let (non_blocking, guard) = tracing_appender::non_blocking(std::io::stderr()); + tracing_subscriber::registry() .with( EnvFilter::builder() .with_default_directive(log_level) .from_env_lossy(), ) - .with(tracing_subscriber::fmt::layer().pretty()) + .with( + tracing_subscriber::fmt::layer() + .pretty() + .with_writer(non_blocking), + ) .init(); + + guard } #[cfg(test)] #[allow(dead_code, reason = "Used in tests to setup tracing")] -pub fn setup_tracing_for_tests() { - setup_tracing("info".parse().unwrap()); +pub fn setup_tracing_for_tests() -> tracing_appender::non_blocking::WorkerGuard { + setup_tracing("debug".parse().unwrap()) } /// A macro to concatenate two static strings (`&'static str`) at compile time. @@ -430,7 +438,7 @@ mod tests { } let result = std::panic::catch_unwind(|| { - setup_tracing("info".parse().unwrap()); + let _guard = setup_tracing("info".parse().unwrap()); tracing::info!("tracing initialized"); }); From 6c3867b8521a2b1fb7cd793f175776c29fa26887 Mon Sep 17 00:00:00 2001 From: Christian Beilschmidt Date: Tue, 11 Aug 2026 08:40:58 +0200 Subject: [PATCH 03/11] feat: add credits management and refactor processes to track computation IDs --- backend/Cargo.lock | 15 +- backend/Cargo.toml | 3 +- backend/conf/default.toml | 3 + backend/examples/query-geo-engine.rs | 4 + backend/src/auth.rs | 10 +- backend/src/config.rs | 14 + backend/src/credits.rs | 177 +++++++++ backend/src/db/model.rs | 103 +++--- backend/src/handler.rs | 31 +- backend/src/jobs.rs | 349 ++++++++++-------- backend/src/lib.rs | 1 + .../biodiversity_sensitive_areas/mod.rs | 74 ++-- .../processes/land_use_sealed_area/compute.rs | 35 +- .../src/processes/land_use_sealed_area/mod.rs | 171 +++++---- backend/src/processes/ndvi.rs | 138 ++++--- backend/src/server.rs | 18 +- backend/src/state.rs | 86 ++++- backend/src/util.rs | 2 - 18 files changed, 820 insertions(+), 414 deletions(-) create mode 100644 backend/src/credits.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 42bba9b..6643006 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1433,8 +1433,7 @@ dependencies = [ [[package]] name = "geoengine-api-client" version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1042b3cd1f58af298728be1b101fa78b4bf558828fe770c1a8d1fb7740142c9d" +source = "git+https://github.com/geo-engine/geoengine?branch=fix%2Frust-api-wfs-computation-id#3ffa70e64ac8374435d7b31fba0b095e2f512325" dependencies = [ "reqwest", "serde", @@ -4617,7 +4616,7 @@ dependencies = [ [[package]] name = "toasty" version = "0.9.0" -source = "git+https://github.com/tokio-rs/toasty?branch=main#19c834a127c0a32c87f9958748cba82abc462d20" +source = "git+https://github.com/tokio-rs/toasty?branch=main#eb220ca08079716a82feeeaafa07e15f9ab864e7" dependencies = [ "async-trait", "bit-set", @@ -4678,7 +4677,7 @@ dependencies = [ [[package]] name = "toasty-core" version = "0.9.0" -source = "git+https://github.com/tokio-rs/toasty?branch=main#19c834a127c0a32c87f9958748cba82abc462d20" +source = "git+https://github.com/tokio-rs/toasty?branch=main#eb220ca08079716a82feeeaafa07e15f9ab864e7" dependencies = [ "async-trait", "bit-set", @@ -4698,7 +4697,7 @@ dependencies = [ [[package]] name = "toasty-driver-mysql" version = "0.9.0" -source = "git+https://github.com/tokio-rs/toasty?branch=main#19c834a127c0a32c87f9958748cba82abc462d20" +source = "git+https://github.com/tokio-rs/toasty?branch=main#eb220ca08079716a82feeeaafa07e15f9ab864e7" dependencies = [ "async-trait", "sqlx-core 0.9.0", @@ -4714,7 +4713,7 @@ dependencies = [ [[package]] name = "toasty-driver-postgresql" version = "0.9.0" -source = "git+https://github.com/tokio-rs/toasty?branch=main#19c834a127c0a32c87f9958748cba82abc462d20" +source = "git+https://github.com/tokio-rs/toasty?branch=main#eb220ca08079716a82feeeaafa07e15f9ab864e7" dependencies = [ "async-trait", "fallible-iterator", @@ -4753,7 +4752,7 @@ dependencies = [ [[package]] name = "toasty-macros" version = "0.9.0" -source = "git+https://github.com/tokio-rs/toasty?branch=main#19c834a127c0a32c87f9958748cba82abc462d20" +source = "git+https://github.com/tokio-rs/toasty?branch=main#eb220ca08079716a82feeeaafa07e15f9ab864e7" dependencies = [ "hashbrown 0.17.1", "heck", @@ -4767,7 +4766,7 @@ dependencies = [ [[package]] name = "toasty-sql" version = "0.9.0" -source = "git+https://github.com/tokio-rs/toasty?branch=main#19c834a127c0a32c87f9958748cba82abc462d20" +source = "git+https://github.com/tokio-rs/toasty?branch=main#eb220ca08079716a82feeeaafa07e15f9ab864e7" dependencies = [ "serde", "serde_json", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 72b13a8..4b5f7a6 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -56,7 +56,8 @@ chrono = { version = "0.4", features = ["serde"] } config = "0.15" futures = "0.3" geo-types = "0.7" -geoengine-api-client = "0.9.3" +# geoengine-api-client = "0.9.3" +geoengine-api-client = { git = "https://github.com/geo-engine/geoengine", branch = "fix/rust-api-wfs-computation-id" } geojson = "1.0" indoc = "2.0" nom = "8.0" diff --git a/backend/conf/default.toml b/backend/conf/default.toml index f7fe76a..e9e64fb 100644 --- a/backend/conf/default.toml +++ b/backend/conf/default.toml @@ -24,3 +24,6 @@ level = "info" [data_ids] land_use_imperviousness_builtup = "land_use_impervious_built_up" + +[credits.biodiversity_sensitive_areas] +credits_per_site = 10 diff --git a/backend/examples/query-geo-engine.rs b/backend/examples/query-geo-engine.rs index 3cbb6e7..af8f351 100644 --- a/backend/examples/query-geo-engine.rs +++ b/backend/examples/query-geo-engine.rs @@ -46,6 +46,8 @@ async fn main() { Coordinate2D::new(5.988_658_074_58, 54.983_104_153), ); + let mut computation_id = String::new(); + let feature_collection = wfs_handler( &configuration, &workflow_id, @@ -62,9 +64,11 @@ async fn main() { None, Some(&workflow_id), None, + Some(&mut computation_id), ) .await .unwrap(); + eprintln!("Computation ID: {computation_id}"); eprintln!("{feature_collection:#?}"); } diff --git a/backend/src/auth.rs b/backend/src/auth.rs index 2168872..673ac2e 100644 --- a/backend/src/auth.rs +++ b/backend/src/auth.rs @@ -7,7 +7,7 @@ use crate::{ BiodiversitySensitiveAreasProcess, HabitatDistanceProcess, LandUseSealedAreaProcess, NDVIProcess, }, - state::USER, + state::{CONTEXT, TaskContext}, util::{Secret, error_response}, }; use anyhow::{Context, Result}; @@ -217,7 +217,9 @@ where }; // continue the request, scoped - USER.scope(user, inner.call(request)).await + CONTEXT + .scope(TaskContext::new(user), inner.call(request)) + .await }) } } @@ -253,6 +255,8 @@ fn uuid_parser(input: &str) -> IResult<&str, Uuid> { #[cfg(test)] mod tests { + use crate::state::TaskLocalContext; + use super::*; use axum::body::Body; use axum::http::Request as HttpRequest; @@ -348,7 +352,7 @@ mod tests { fn mock_inner_outputs_user() -> BoxCloneService, Response, Infallible> { let inner = tower::service_fn(|_req: Request| async { - Ok((StatusCode::OK, USER.get().id.to_string()).into_response()) + Ok((StatusCode::OK, CONTEXT.user_id().unwrap().to_string()).into_response()) }); tower::util::BoxCloneService::new(inner) } diff --git a/backend/src/config.rs b/backend/src/config.rs index 5bab653..20f0fba 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -14,6 +14,7 @@ pub struct Config { pub database: Database, pub geoengine: GeoEngineInstance, pub data_ids: DataIdsConfig, + pub credits: CreditsConfig, pub logging: Logging, } @@ -70,6 +71,7 @@ impl GeoEngineInstance { if let Some(session_token) = session_token { let session_token: Uuid = *session_token; + configuration.bearer_access_token = Some(session_token.to_string()); } @@ -131,6 +133,18 @@ fn get_config() -> anyhow::Result { Ok(builder.build()?.try_deserialize()?) } +/// Specifies configuration for the credits system +#[derive(serde::Deserialize, Clone, Debug)] +pub struct CreditsConfig { + pub biodiversity_sensitive_areas: BiodiversitySensitiveAreasCreditsConfig, +} + +/// Specifies credits configuration for the biodiversity sensitive areas +#[derive(serde::Deserialize, Clone, Debug)] +pub struct BiodiversitySensitiveAreasCreditsConfig { + pub credits_per_site: u64, +} + #[cfg(test)] mod tests { use super::*; diff --git a/backend/src/credits.rs b/backend/src/credits.rs new file mode 100644 index 0000000..dd8787a --- /dev/null +++ b/backend/src/credits.rs @@ -0,0 +1,177 @@ +use crate::{ + db::{ + DbHandle, + model::{ComputationId, Credits, TimestampMillis}, + }, + server::AppState, + state::{CONTEXT, TaskLocalContext}, +}; +use anyhow::Context; +use axum::{ + Json, + extract::{Path, State}, +}; +use chrono::{TimeZone, Utc}; +use ogcapi::{ + services::{self as ogcapi_services}, + types::common::Exception, +}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use utoipa::{IntoParams, ToSchema}; +use utoipa_axum::{router::OpenApiRouter, routes}; +use uuid::Uuid; + +pub fn router() -> OpenApiRouter { + OpenApiRouter::new().routes(routes!(get_credits)) +} + +/// Returns the user's credits. +#[utoipa::path(get, path = "/{year}/{month}", tag = "User", + params(GetCreditsParams), + responses( + ( + status = OK, + description = "The user's credits for the specified month.", + body = GetCreditsResponse + ), + ( + status = INTERNAL_SERVER_ERROR, + description = "A server error occurred.", + body = Exception, + example = json!(Exception::new_from_status(500)) + ) + ) +)] +pub async fn get_credits( + Path(GetCreditsParams { year, month }): Path, + State(app_state): State, +) -> ogcapi_services::Result> { + let user_id = CONTEXT.user_id()?; + let timestamp_start_inclusive = Utc + .with_ymd_and_hms(i32::from(year), u32::from(month), 1, 0, 0, 0) + .earliest() + .context("invalid start timestamp")?; + let timestamp_end_exclusive: TimestampMillis = timestamp_start_inclusive + .checked_add_months(chrono::Months::new(1)) + .context("invalid end timestamp")? + .into(); + let timestamp_start_inclusive: TimestampMillis = timestamp_start_inclusive.into(); + + let fields = Credits::fields(); + + let credits = Credits::filter( + fields + .timestamp() + .ge(timestamp_start_inclusive) + .and(fields.timestamp().lt(timestamp_end_exclusive)) + .and(fields.job().user_id().eq(user_id)), + ) + .exec(&mut app_state.db.db()) + .await + .context("failed to execute credits query")?; + + let credits_per_job = Vec::::from_credits(&credits); + + Ok(Json(GetCreditsResponse { + year, + month, + credits_used: credits_per_job.sum(), + details: credits_per_job, + })) +} + +#[derive(Debug, Deserialize, IntoParams)] +#[serde(rename_all = "camelCase")] +pub struct GetCreditsParams { + pub year: u16, + pub month: u8, +} + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct GetCreditsResponse { + pub year: u16, + pub month: u8, + pub credits_used: u32, + pub details: Vec, // Add this field to include the details of credits +} + +#[derive(Debug, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreditsForJob { + pub job_id: Uuid, + pub credits_used: u32, +} + +/// A trait to sum credits used for a list of credits. +/// +/// TODO: Use `toasty`'s aggregate queries () once available. +trait SumCredits { + fn sum(&self) -> u32; + + fn from_credits(credits_list: &[Credits]) -> Self; +} + +impl SumCredits for Vec { + fn sum(&self) -> u32 { + self.iter().map(|c| c.credits_used).sum() + } + + fn from_credits(credits_list: &[Credits]) -> Self { + let mut map: HashMap = HashMap::new(); + for c in credits_list { + let entry = map.entry(c.job_id).or_insert(0); + *entry += c.credits.unwrap_or(0) as u32; + } + map.into_iter() + .map(|(job_id, credits_used)| CreditsForJob { + job_id, + credits_used, + }) + .collect() + } +} + +/// Add credits used for a specific job to the database. +/// +/// The job ID is retrieved from the task-local context, which must be set before calling this function. +pub async fn add_credits_used( + db: DbHandle, + computation_id: ComputationId, + credits: u64, +) -> anyhow::Result<()> { + add_credits_opt(db, computation_id, Some(credits)).await +} + +/// Add credits used for a specific job to the database. +/// +/// The job ID is retrieved from the task-local context, which must be set before calling this function. +pub async fn add_credits_pending( + db: DbHandle, + computation_id: ComputationId, +) -> anyhow::Result<()> { + add_credits_opt(db, computation_id, None).await +} + +async fn add_credits_opt( + mut db: DbHandle, + computation_id: ComputationId, + credits: Option, +) -> anyhow::Result<()> { + let Some(job_id) = CONTEXT.job_id()? else { + anyhow::bail!("No job ID set in the task context"); + }; + + toasty::create!(Credits { + job_id, + computation_id, + credits, + timestamp: TimestampMillis::from(Utc::now()), + }) + .exec(db.as_mut()) + .await + .context("failed to insert credits into database")?; + + Ok(()) +} diff --git a/backend/src/db/model.rs b/backend/src/db/model.rs index a9e7078..7212fee 100644 --- a/backend/src/db/model.rs +++ b/backend/src/db/model.rs @@ -1,6 +1,13 @@ +#![allow( + clippy::used_underscore_binding, + reason = "For embedded NewTypes, toasty accesses the inner value via `_0`" +)] + +use anyhow::Context; use chrono::{DateTime, Utc}; use o2o::o2o; use serde::{Deserialize, Serialize}; +use std::str::FromStr; use uuid::Uuid; /// Job status code enumeration @@ -77,24 +84,25 @@ pub struct Job { pub message: Option, /// Job creation timestamp (stored as Unix timestamp in milliseconds) - pub created: i64, + pub created: TimestampMillis, /// Job completion timestamp (stored as Unix timestamp in milliseconds) - pub finished: Option, + pub finished: Option, /// Last update timestamp (stored as Unix timestamp in milliseconds) - pub updated: i64, + pub updated: TimestampMillis, /// Progress percentage (0-100) pub progress: Option, - /// Links associated with the job (stored as JSONB) + /// Links associated with the job pub links: Vec, /// Response type pub response: Response, - /// Job results (JSONB) + /// Job results + /// TODO: make enum of all possible result types #[column(type = "jsonb")] pub results: Option, @@ -105,47 +113,41 @@ pub struct Job { pub credits: toasty::Deferred>, } -impl Job { - /// Convert created timestamp to `DateTime` - pub fn created_at(&self) -> DateTime { - DateTime::::from_timestamp_millis(self.created).unwrap_or_else(Utc::now) - } - - /// Convert finished timestamp to `DateTime` - pub fn finished_at(&self) -> Option> { - self.finished - .and_then(DateTime::::from_timestamp_millis) - } - - /// Convert updated timestamp to `DateTime` - pub fn updated_at(&self) -> DateTime { - DateTime::::from_timestamp_millis(self.updated).unwrap_or_else(Utc::now) - } +#[allow( + clippy::used_underscore_items, + reason = "For embedded NewTypes, toasty accesses the inner value via `_0`" +)] +pub fn job_updated_field() -> toasty::stmt::Path { + Job::fields().updated()._0() +} - /// Set created from `DateTime` - pub fn with_created(mut self, dt: DateTime) -> Self { - self.created = dt.timestamp_millis(); - self +/// A timestamp stored as a Unix timestamp in milliseconds, used for database storage. +/// +/// TODO: Remove once we can use `jiff` or `DateTime` in `toasty` models +#[derive( + Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize, toasty::Embed, +)] +pub struct TimestampMillis(i64); + +impl From> for TimestampMillis { + fn from(dt: DateTime) -> Self { + Self(dt.timestamp_millis()) } +} - /// Set finished from `DateTime` - pub fn with_finished(mut self, dt: Option>) -> Self { - self.finished = dt.map(|d| d.timestamp_millis()); - self - } +impl TryInto> for TimestampMillis { + type Error = anyhow::Error; - /// Set updated from `DateTime` - pub fn with_updated(mut self, dt: DateTime) -> Self { - self.updated = dt.timestamp_millis(); - self + fn try_into(self) -> Result, Self::Error> { + DateTime::::from_timestamp_millis(self.0).context("invalid timestamp") } } /// Credits database model #[derive(Debug, Clone, Serialize, Deserialize, toasty::Model)] -#[key(job_id, compute_id)] // Note: `toasty` requires a primary key, but we would omit this normally. +#[key(job_id, computation_id)] // Note: `toasty` requires a primary key, but we would omit this normally. pub struct Credits { - pub timestamp: i64, + pub timestamp: TimestampMillis, /// Referenced job ID #[index] @@ -155,22 +157,22 @@ pub struct Credits { #[belongs_to(key = job_id, references = job_id)] pub job: toasty::Deferred, - /// Geo Engine compute ID (if applicable) + /// Geo Engine computation ID (if applicable) /// /// Note: Not stored as nullable because `toasty` requires a primary key. - pub compute_id: ComputeId, + pub computation_id: ComputationId, /// Credits used; empty if not yet determined (e.g., job still running) #[allow(clippy::struct_field_names, reason = "Database field name")] pub credits: Option, } -/// An optional compute ID for Geo Engine jobs, stored as a string. +/// An optional computation ID for Geo Engine jobs, stored as a string. /// This is used to track the compute resources used by a job in the Geo Engine system. #[derive(Debug, Clone, Serialize, Deserialize, toasty::Embed)] -pub struct ComputeId(Uuid); +pub struct ComputationId(Uuid); -impl ComputeId { +impl ComputationId { pub fn some(id: Uuid) -> Self { Self(id) } @@ -180,11 +182,28 @@ impl ComputeId { } pub fn get(&self) -> Option { - if self.0.is_nil() { + if self.is_none() { return None; } Some(self.0) } + + pub fn is_none(&self) -> bool { + self.0.is_nil() + } + + pub fn is_some(&self) -> bool { + !self.is_none() + } +} + +impl FromStr for ComputationId { + type Err = anyhow::Error; + + fn from_str(s: &str) -> Result { + let id = Uuid::parse_str(s).context("invalid UUID string")?; + Ok(Self(id)) + } } #[cfg(test)] diff --git a/backend/src/handler.rs b/backend/src/handler.rs index 04a28c4..175549c 100644 --- a/backend/src/handler.rs +++ b/backend/src/handler.rs @@ -1,4 +1,7 @@ -use crate::auth::{AuthCodeResponse, UserSession}; +use crate::{ + auth::{AuthCodeResponse, UserSession}, + server::AppState, +}; use anyhow::Context; use axum::{ Json, @@ -6,10 +9,7 @@ use axum::{ http::StatusCode, response::IntoResponse, }; -use geoengine_api_client::apis::{ - configuration::Configuration, - session_api::{oidc_init, oidc_login}, -}; +use geoengine_api_client::apis::session_api::{oidc_init, oidc_login}; use ogcapi::{ services::{self as ogcapi_services}, types::common::Exception, @@ -19,7 +19,7 @@ use url::Url; use utoipa::IntoParams; use utoipa_axum::{router::OpenApiRouter, routes}; -pub fn auth_router() -> OpenApiRouter { +pub fn auth_router() -> OpenApiRouter { OpenApiRouter::new() .routes(routes!(auth_handler)) .routes(routes!(auth_request_url_handler)) @@ -47,12 +47,12 @@ pub async fn health_handler() -> StatusCode { ) )] async fn auth_handler( - State(api_config): State, + State(app_state): State, Query(AuthRequestUrlParams { redirect_uri }): Query, Json(auth_code_response): Json, ) -> ogcapi_services::Result> { let user_session = oidc_login( - &api_config, + &app_state.api_config, redirect_uri.as_str(), auth_code_response.into(), ) @@ -87,10 +87,10 @@ struct AuthRequestUrlParams { ) )] async fn auth_request_url_handler( - State(api_config): State, + State(app_state): State, Query(AuthRequestUrlParams { redirect_uri }): Query, ) -> ogcapi_services::Result { - let auth_code_flow_request_url = oidc_init(&api_config, redirect_uri.as_str()) + let auth_code_flow_request_url = oidc_init(&app_state.api_config, redirect_uri.as_str()) .await .context("Failed to perform OIDC login")?; @@ -115,6 +115,7 @@ mod tests { use super::*; use crate::auth::AuthCodeResponse; use crate::config::GeoEngineInstance; + use crate::db::tests::with_temp_db; use axum::extract::{Query, State}; use axum::routing::get; use axum::{Json, Router}; @@ -138,8 +139,10 @@ mod tests { assert_eq!(response.status(), StatusCode::NO_CONTENT); } - #[tokio::test] - async fn test_auth_handler_with_mock_server() { + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn it_retrieves_user_session_from_auth_handler() { + with_temp_db(async move |db| { + // start mock server let server = Server::run(); @@ -172,7 +175,7 @@ mod tests { // call handler let res = auth_handler( - State(api_config), + State(AppState { db, api_config }), Query(AuthRequestUrlParams { redirect_uri: Url::parse(&redirect).unwrap(), }), @@ -181,5 +184,7 @@ mod tests { .await; assert!(res.is_ok(), "expected Ok(UserSession) from auth_handler"); + + }).await; } } diff --git a/backend/src/jobs.rs b/backend/src/jobs.rs index 563188d..cef39ce 100644 --- a/backend/src/jobs.rs +++ b/backend/src/jobs.rs @@ -1,14 +1,19 @@ use crate::{ - db::{DbHandle, model}, - state::USER, + db::{ + DbHandle, + model::{Job, JobType, Link, Response, StatusCode, TimestampMillis, job_updated_field}, + }, + state::{CONTEXT, TaskLocalContext}, }; use anyhow::Context; use chrono::Utc; use ogcapi::{ drivers::ProcessResult, types::{ - common::Link, - processes::{ExecuteResults, Response, StatusCode, StatusInfo}, + common::Link as ApiLink, + processes::{ + ExecuteResults, Response as ApiResponse, StatusCode as ApiStatusCode, StatusInfo, + }, }, }; use tracing::instrument; @@ -29,12 +34,12 @@ impl JobHandler { /// Set them to `Failed` with appropriate message. #[instrument(skip(self), level = "debug")] async fn clean_running_jobs_from_previous_sessions(&mut self) -> anyhow::Result<()> { - let now_ms = Utc::now().timestamp_millis(); + let now_ms: TimestampMillis = Utc::now().into(); - toasty::update!(model::Job::filter( - model::Job::fields().status().eq(model::StatusCode::Running) + toasty::update!(Job::filter( + Job::fields().status().eq(StatusCode::Running) ) { - status: model::StatusCode::Failed, + status: StatusCode::Failed, message: Some("Server restarted during job execution".to_string()), updated: now_ms, }) @@ -46,21 +51,25 @@ impl JobHandler { #[async_trait::async_trait] impl ogcapi::drivers::JobHandler for JobHandler { - async fn register(&self, job: &StatusInfo, response_mode: Response) -> anyhow::Result { - let user = USER.try_get().context("missing authenticated user")?; + async fn register( + &self, + job: &StatusInfo, + response_mode: ApiResponse, + ) -> anyhow::Result { + let user_id = CONTEXT.user_id()?; - if job.job_id.is_empty() { + if !job.job_id.is_empty() { anyhow::bail!("Job ID must be empty when registering a new job"); } - let now_ms = Utc::now().timestamp_millis(); - let created_ms = job.created.map_or(now_ms, |dt| dt.timestamp_millis()); - let updated_ms = job.updated.map_or(now_ms, |dt| dt.timestamp_millis()); + let now_ms: TimestampMillis = Utc::now().into(); + let created_ms = job.created.map_or(now_ms, Into::into); + let updated_ms = job.updated.map_or(now_ms, Into::into); - let job = toasty::create!(model::Job { + let job = toasty::create!(Job { process_id: job.process_id.clone(), - job_type: model::JobType::from(job.r#type.clone()), - status: model::StatusCode::from(job.status.clone()), + job_type: JobType::from(job.r#type.clone()), + status: StatusCode::from(job.status.clone()), message: job.message.clone(), created: created_ms, finished: None, @@ -71,27 +80,29 @@ impl ogcapi::drivers::JobHandler for JobHandler { .iter() .cloned() .map(Into::into) - .collect::>(), - response: model::Response::from(response_mode), + .collect::>(), + response: Response::from(response_mode), results: None, - user_id: user.id, + user_id, }) .exec(&mut self.db.db()) .await .context("Failed to insert job into database")?; + CONTEXT.set_job_id(job.job_id)?; + Ok(job.job_id.to_string()) } #[instrument(skip(self, job), level = "debug")] async fn update(&self, job: &StatusInfo) -> anyhow::Result<()> { let job_id = Uuid::parse_str(&job.job_id).context("Invalid job ID format")?; - let now_ms = Utc::now().timestamp_millis(); - let updated_ms = job.updated.map_or(now_ms, |dt| dt.timestamp_millis()); + let now_ms: TimestampMillis = Utc::now().into(); + let updated_ms = job.updated.map_or(now_ms, Into::into); let progress = job.progress.map_or(0, i16::from); - toasty::update!(model::Job::filter_by_job_id(job_id) { - status: model::StatusCode::from(job.status.clone()), + toasty::update!(Job::filter_by_job_id(job_id) { + status: StatusCode::from(job.status.clone()), message: job.message.clone(), updated: updated_ms, progress: Some(progress), @@ -100,89 +111,95 @@ impl ogcapi::drivers::JobHandler for JobHandler { .await .context("Failed to update job in database")?; + CONTEXT.set_job_id(job_id)?; + Ok(()) } #[instrument(skip(self), level = "debug")] async fn status_list(&self, offset: usize, limit: usize) -> anyhow::Result> { - let user = USER.try_get().context("missing authenticated user")?; - - let results: Vec = - model::Job::filter(model::Job::fields().user_id().eq(user.id)) - .order_by(model::Job::fields().updated().desc()) - .limit(limit) - .offset(offset) - .exec(&mut self.db.db()) - .await - .context("Failed to query job status list from database")?; - - Ok(results.into_iter().map(job_to_status_info).collect()) + let user_id = CONTEXT.user_id()?; + + let results: Vec = Job::filter(Job::fields().user_id().eq(user_id)) + .order_by(job_updated_field().desc()) + .limit(limit) + .offset(offset) + .exec(&mut self.db.db()) + .await + .context("Failed to query job status list from database")?; + + Ok(results + .into_iter() + .map(TryInto::try_into) + .collect::, _>>()?) } #[instrument(skip(self), level = "debug")] async fn status(&self, id: &str) -> anyhow::Result> { - let user = USER.try_get().context("missing authenticated user")?; + let user_id = CONTEXT.user_id()?; let id = Uuid::parse_str(id).context("Invalid job ID format")?; - let result: Option = model::Job::filter_by_job_id(id) - .filter(model::Job::fields().user_id().eq(user.id)) + let result: Option = Job::filter_by_job_id(id) + .filter(Job::fields().user_id().eq(user_id)) .first() .exec(&mut self.db.db()) .await .context("Failed to query job status from database")?; - Ok(result.map(job_to_status_info)) + Ok(result.map(TryInto::try_into).transpose()?) } #[instrument(skip(self, message, links, results), level = "debug")] async fn finish( &self, job_id: &str, - status: &StatusCode, + status: &ApiStatusCode, message: Option, - links: Vec, + links: Vec, results: Option, ) -> anyhow::Result<()> { - let now_ms = Utc::now().timestamp_millis(); + let now_ms: TimestampMillis = Utc::now().into(); let job_id = Uuid::parse_str(job_id).context("Invalid job ID format")?; let results_json = results.map(serde_json::to_value).transpose()?; - toasty::update!(model::Job::filter_by_job_id(job_id) { - status: model::StatusCode::from(status.clone()), + toasty::update!(Job::filter_by_job_id(job_id) { + status: StatusCode::from(status.clone()), message, updated: now_ms, finished: Some(now_ms), progress: Some(100i16), - links: links.iter().cloned().map(Into::into).collect::>(), + links: links.iter().cloned().map(Into::into).collect::>(), results: results_json, }) .exec(&mut self.db.db()) .await .context("Failed to finish job in database")?; + CONTEXT.set_job_id(job_id)?; + Ok(()) } #[instrument(skip(self), level = "debug")] async fn dismiss(&self, id: &str) -> anyhow::Result> { - let user = USER.try_get().context("missing authenticated user")?; - let now_ms = Utc::now().timestamp_millis(); + let user_id = CONTEXT.user_id()?; + let now_ms: TimestampMillis = Utc::now().into(); let id = Uuid::parse_str(id).context("Invalid job ID format")?; // First, get the current job to return - let current_job: Option = model::Job::filter_by_job_id(id) - .filter(model::Job::fields().user_id().eq(user.id)) + let current_job: Option = Job::filter_by_job_id(id) + .filter(Job::fields().user_id().eq(user_id)) .first() .exec(&mut self.db.db()) .await .context("Failed to query current job before dismiss")?; // Update the job to dismissed status - toasty::update!(model::Job::filter( - model::Job::fields().job_id().eq(id) + toasty::update!(Job::filter( + Job::fields().job_id().eq(id) ) - .filter(model::Job::fields().user_id().eq(user.id)) { - status: model::StatusCode::Dismissed, + .filter(Job::fields().user_id().eq(user_id)) { + status: StatusCode::Dismissed, message: Some("Job dismissed by user".to_string()), updated: now_ms, }) @@ -191,21 +208,22 @@ impl ogcapi::drivers::JobHandler for JobHandler { .context("Failed to dismiss job in database")?; // Return the updated job info - Ok(current_job.map(|mut job| { - job.status = model::StatusCode::Dismissed; - job.message = Some("Job dismissed by user".to_string()); - job.updated = now_ms; - job_to_status_info(job) - })) + let Some(mut job) = current_job else { + return Ok(None); + }; + job.status = StatusCode::Dismissed; + job.message = Some("Job dismissed by user".to_string()); + job.updated = now_ms; + job.try_into().map(Some) } #[instrument(skip(self), level = "debug")] async fn results(&self, id: &str) -> anyhow::Result { - let user = USER.try_get().context("missing authenticated user")?; + let user_id = CONTEXT.user_id()?; let id = Uuid::parse_str(id).context("Invalid job ID format")?; - let result: Option = model::Job::filter_by_job_id(id) - .filter(model::Job::fields().user_id().eq(user.id)) + let result: Option = Job::filter_by_job_id(id) + .filter(Job::fields().user_id().eq(user_id)) .first() .exec(&mut self.db.db()) .await @@ -229,30 +247,29 @@ impl ogcapi::drivers::JobHandler for JobHandler { } } -/// Convert a Job model to `StatusInfo` for OGC API responses -fn job_to_status_info(job: model::Job) -> StatusInfo { - let created = job.created_at(); - let updated = job.updated_at(); - let finished = job.finished_at(); - - StatusInfo { - job_id: job.job_id.to_string(), - process_id: job.process_id, - status: job.status.into(), - message: job.message.clone(), - r#type: job.job_type.into(), - created: Some(created), - updated: Some(updated), - finished, - progress: job.progress.map(|p| p as u8), - links: job.links.into_iter().map(Into::into).collect(), +impl TryInto for Job { + type Error = anyhow::Error; + + fn try_into(self) -> Result { + Ok(StatusInfo { + job_id: self.job_id.to_string(), + process_id: self.process_id, + status: self.status.into(), + message: self.message, + r#type: self.job_type.into(), + created: Some(self.created.try_into()?), + updated: Some(self.updated.try_into()?), + finished: self.finished.map(TryInto::try_into).transpose()?, + progress: self.progress.map(|p| p as u8), + links: self.links.into_iter().map(Into::into).collect(), + }) } } #[cfg(test)] mod tests { use super::*; - use crate::{auth::User, db::tests::with_temp_db}; + use crate::{auth::User, db::tests::with_temp_db, state::TaskContext}; use ogcapi::drivers::JobHandler as _; fn mock_user() -> User { @@ -266,7 +283,7 @@ mod tests { StatusInfo { job_id: String::new(), // job_id must be empty when registering a new job process_id: Some("proc".to_string()), - status: StatusCode::Accepted, + status: ApiStatusCode::Accepted, message: Some("msg".to_string()), r#type: Default::default(), created: Some(Utc::now()), @@ -278,17 +295,18 @@ mod tests { } #[tokio::test(flavor = "multi_thread")] - async fn test_register() { + async fn it_registers_jobs() { with_temp_db(|db_pool| async move { let handler = JobHandler::new(db_pool).await.unwrap(); - USER.scope(mock_user(), async move { - let status_info = mock_status_info(); - let result = handler.register(&status_info, Response::Raw).await; - assert!(result.is_ok()); - let job_id = result.unwrap(); - assert!(!job_id.is_empty()); - }) - .await; + CONTEXT + .scope(TaskContext::new(mock_user()), async move { + let status_info = mock_status_info(); + let result = handler.register(&status_info, ApiResponse::Raw).await; + assert!(result.is_ok()); + let job_id = result.unwrap(); + assert!(!job_id.is_empty()); + }) + .await; }) .await; } @@ -297,18 +315,22 @@ mod tests { async fn test_update() { with_temp_db(|db_pool| async move { let handler = JobHandler::new(db_pool).await.unwrap(); - USER.scope(mock_user(), async move { - let status_info = mock_status_info(); - // Register first - let job_id = handler.register(&status_info, Response::Raw).await.unwrap(); - // Update - let mut updated_info = status_info.clone(); - updated_info.job_id = job_id; - updated_info.status = StatusCode::Running; - let result = handler.update(&updated_info).await; - assert!(result.is_ok()); - }) - .await; + CONTEXT + .scope(TaskContext::new(mock_user()), async move { + let status_info = mock_status_info(); + // Register first + let job_id = handler + .register(&status_info, ApiResponse::Raw) + .await + .unwrap(); + // Update + let mut updated_info = status_info.clone(); + updated_info.job_id = job_id; + updated_info.status = ApiStatusCode::Running; + let result = handler.update(&updated_info).await; + assert!(result.is_ok()); + }) + .await; }) .await; } @@ -317,17 +339,21 @@ mod tests { async fn test_status_list() { with_temp_db(|db_pool| async move { let handler = JobHandler::new(db_pool).await.unwrap(); - USER.scope(mock_user(), async move { - // Register a job - let status_info = mock_status_info(); - handler.register(&status_info, Response::Raw).await.unwrap(); - // List - let result = handler.status_list(0, 10).await; - assert!(result.is_ok()); - let list = result.unwrap(); - assert!(!list.is_empty()); - }) - .await; + CONTEXT + .scope(TaskContext::new(mock_user()), async move { + // Register a job + let status_info = mock_status_info(); + handler + .register(&status_info, ApiResponse::Raw) + .await + .unwrap(); + // List + let result = handler.status_list(0, 10).await; + assert!(result.is_ok()); + let list = result.unwrap(); + assert!(!list.is_empty()); + }) + .await; }) .await; } @@ -336,16 +362,20 @@ mod tests { async fn test_status() { with_temp_db(|db_pool| async move { let handler = JobHandler::new(db_pool).await.unwrap(); - USER.scope(mock_user(), async move { - let status_info = mock_status_info(); - let job_id = handler.register(&status_info, Response::Raw).await.unwrap(); - let result = handler.status(&job_id).await; - assert!(result.is_ok()); - let status = result.unwrap(); - assert!(status.is_some()); - assert_eq!(status.unwrap().job_id, job_id); - }) - .await; + CONTEXT + .scope(TaskContext::new(mock_user()), async move { + let status_info = mock_status_info(); + let job_id = handler + .register(&status_info, ApiResponse::Raw) + .await + .unwrap(); + let result = handler.status(&job_id).await; + assert!(result.is_ok()); + let status = result.unwrap(); + assert!(status.is_some()); + assert_eq!(status.unwrap().job_id, job_id); + }) + .await; }) .await; } @@ -354,21 +384,25 @@ mod tests { async fn test_finish() { with_temp_db(|db_pool| async move { let handler = JobHandler::new(db_pool).await.unwrap(); - USER.scope(mock_user(), async move { - let status_info = mock_status_info(); - let job_id = handler.register(&status_info, Response::Raw).await.unwrap(); - let result = handler - .finish( - &job_id, - &StatusCode::Successful, - Some("done".to_string()), - vec![], - None, - ) - .await; - assert!(result.is_ok()); - }) - .await; + CONTEXT + .scope(TaskContext::new(mock_user()), async move { + let status_info = mock_status_info(); + let job_id = handler + .register(&status_info, ApiResponse::Raw) + .await + .unwrap(); + let result = handler + .finish( + &job_id, + &ApiStatusCode::Successful, + Some("done".to_string()), + vec![], + None, + ) + .await; + assert!(result.is_ok()); + }) + .await; }) .await; } @@ -377,16 +411,20 @@ mod tests { async fn test_dismiss() { with_temp_db(|db_pool| async move { let handler = JobHandler::new(db_pool).await.unwrap(); - USER.scope(mock_user(), async move { - let status_info = mock_status_info(); - let job_id = handler.register(&status_info, Response::Raw).await.unwrap(); - let result = handler.dismiss(&job_id).await; - assert!(result.is_ok()); - let dismissed = result.unwrap(); - assert!(dismissed.is_some()); - assert_eq!(dismissed.unwrap().status, StatusCode::Dismissed); - }) - .await; + CONTEXT + .scope(TaskContext::new(mock_user()), async move { + let status_info = mock_status_info(); + let job_id = handler + .register(&status_info, ApiResponse::Raw) + .await + .unwrap(); + let result = handler.dismiss(&job_id).await; + assert!(result.is_ok()); + let dismissed = result.unwrap(); + assert!(dismissed.is_some()); + assert_eq!(dismissed.unwrap().status, ApiStatusCode::Dismissed); + }) + .await; }) .await; } @@ -395,12 +433,13 @@ mod tests { async fn test_results_no_job() { with_temp_db(|db_pool| async move { let handler = JobHandler::new(db_pool).await.unwrap(); - USER.scope(mock_user(), async move { - let non_existent_id = "00000000-0000-0000-0000-000000000000"; - let result = handler.results(non_existent_id).await; - assert!(matches!(result.unwrap(), ProcessResult::NoSuchJob)); - }) - .await; + CONTEXT + .scope(TaskContext::new(mock_user()), async move { + let non_existent_id = "00000000-0000-0000-0000-000000000000"; + let result = handler.results(non_existent_id).await; + assert!(matches!(result.unwrap(), ProcessResult::NoSuchJob)); + }) + .await; }) .await; } diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 5a28e91..b826fca 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -1,6 +1,7 @@ mod auth; mod collection_transactions; mod config; +mod credits; mod db; mod handler; mod jobs; diff --git a/backend/src/processes/biodiversity_sensitive_areas/mod.rs b/backend/src/processes/biodiversity_sensitive_areas/mod.rs index b1c2255..b5e0070 100644 --- a/backend/src/processes/biodiversity_sensitive_areas/mod.rs +++ b/backend/src/processes/biodiversity_sensitive_areas/mod.rs @@ -1,5 +1,7 @@ use crate::{ - db::{DbHandle, util::RecordValueExt}, + CONFIG, + credits::add_credits_used, + db::{DbHandle, model::ComputationId, util::RecordValueExt}, processes::{ habitat_distance::natura2000_exists, parameters::{ @@ -13,6 +15,7 @@ use crate::{ }; use anyhow::{Context, Result}; use approx::AbsDiffEq; +use geojson::Feature; use indoc::formatdoc; use ogcapi::{ processes::Processor, @@ -331,6 +334,8 @@ impl Processor for BiodiversitySensitiveAreasProcess { let value = serde_json::to_value(execute.inputs)?; let inputs: BiodiversitySensitiveAreasProcessInputs = serde_json::from_value(value)?; + let number_of_input_features = inputs.sites.value().features.len(); + // If no outputs were requested, default to all outputs if execute.outputs.is_empty() { for key in [ @@ -392,6 +397,14 @@ impl Processor for BiodiversitySensitiveAreasProcess { .then_some(errors); } + add_credits_used( + self.connection.clone(), + ComputationId::none(), + number_of_input_features as u64 + * CONFIG.credits.biodiversity_sensitive_areas.credits_per_site, + ) + .await?; + Ok(outputs.into()) } } @@ -837,36 +850,10 @@ pub async fn compute_biodiversity_sensitive_areas( let mut errors = Vec::new(); for feature in &sites.value().features { - let location = match location_from_feature(feature, location_property) { - Ok(location) => location, - Err(error) => { - errors.push(error.to_string()); - continue; - } - }; - let site_specification = match site_specification_from_feature(feature, site_type_property) - { - Ok(spec) => spec, - Err(error) => { - errors.push(error.to_string()); - continue; - } - }; - let Some(polygon) = polygon_from_feature(feature) else { - errors.push(format!( - "Skipping feature `{}` due to missing or invalid geometry (not a point or polygon)", - feature_id_str(feature), - )); - continue; - }; - - site_inputs.push(SiteInput { - location, - was_point: was_point(feature), - buffer_distance_km: site_specification.buffer_distance(), - specification: site_specification, - geom: polygon, - }); + match feature_to_site_input(feature, location_property, site_type_property) { + Ok(site_input) => site_inputs.push(site_input), + Err(error) => errors.push(error.to_string()), + } } let site_table: Vec = toasty::sql::query(formatdoc!(r#" @@ -938,6 +925,31 @@ pub async fn compute_biodiversity_sensitive_areas( Ok((site_table, errors)) } +/// Convert a `GeoJSON` feature into a `SiteInput`, extracting the location, site specification, and geometry. +/// Returns an error if any required property is missing or if the geometry is invalid. +fn feature_to_site_input( + feature: &Feature, + location_property: &str, + site_type_property: &str, +) -> Result { + let location = location_from_feature(feature, location_property)?; + let site_specification = site_specification_from_feature(feature, site_type_property)?; + let Some(polygon) = polygon_from_feature(feature) else { + anyhow::bail!( + "Skipping feature `{feature_id}` due to missing or invalid geometry (not a point or polygon)", + feature_id = feature_id_str(feature), + ); + }; + + Ok(SiteInput { + location, + was_point: was_point(feature), + buffer_distance_km: site_specification.buffer_distance(), + specification: site_specification, + geom: polygon, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/backend/src/processes/land_use_sealed_area/compute.rs b/backend/src/processes/land_use_sealed_area/compute.rs index 209bb8e..b123d96 100644 --- a/backend/src/processes/land_use_sealed_area/compute.rs +++ b/backend/src/processes/land_use_sealed_area/compute.rs @@ -1,5 +1,6 @@ use crate::{ CONFIG, + db::model::ComputationId, processes::{ land_use_sealed_area::types::{ LandUseSummary, LandUseSummaryRow, LandUseSummaryRowType, SiteLandUseRow, @@ -63,7 +64,7 @@ pub async fn compute_site_land_use_data( sites: &FeatureCollectionGeoJsonInput, location_name_field: &str, location_type_field: &str, -) -> anyhow::Result<(Vec, Vec)> { +) -> anyhow::Result<(Vec, Vec, ComputationId)> { let mut site_rows = Vec::new(); tracing::info!( @@ -83,9 +84,9 @@ pub async fn compute_site_land_use_data( ) .await?; - let result_geojson = GeoJsonFeatureCollection::try_from( - sealed_area_process(configuration, upload_data_id, year).await?, - )?; + let (result_geojson, computation_id) = + sealed_area_process(configuration, upload_data_id, year).await?; + let result_geojson = GeoJsonFeatureCollection::try_from(result_geojson)?; for feature in &result_geojson.as_ref().features { match extract_site_land_use_rows(feature, location_name_field, location_type_field) { @@ -97,7 +98,7 @@ pub async fn compute_site_land_use_data( } } - Ok((site_rows, validation_and_bbox.errors)) + Ok((site_rows, validation_and_bbox.errors, computation_id)) } fn extract_site_land_use_rows( @@ -326,7 +327,7 @@ async fn sealed_area_process( configuration: &Configuration, upload_data_id: String, year: Year, -) -> Result { +) -> Result<(GeoJson, ComputationId)> { let operators = build_sealed_area_vector_operator(upload_data_id); let processing_graph_id = register_workflow_handler(configuration, operators.sealed_area()) @@ -348,7 +349,9 @@ async fn sealed_area_process( .context("unable to get workflow metadata")?, )?)?; - wfs_handler( + let mut computation_id = String::new(); + + let geojson = wfs_handler( configuration, &processing_graph_id, WfsRequest::GetFeature, @@ -364,9 +367,14 @@ async fn sealed_area_process( Some(&time_str), Some(&processing_graph_id), None, + Some(&mut computation_id), ) .await - .context("unable to compute sealed areas") + .context("unable to compute sealed areas")?; + + let computation_id = ComputationId::from_str(&computation_id)?; + + Ok((geojson, computation_id)) } /// Compute summary table from per-site land-use data. @@ -639,10 +647,9 @@ pub async fn compute_documentation_sources( #[cfg(test)] mod tests { - use approx::assert_abs_diff_eq; - use super::*; use crate::processes::parameters::UnitForArea; + use approx::assert_abs_diff_eq; #[test] fn it_calculates_percentage_change() { @@ -1011,7 +1018,7 @@ mod tests { )] async fn it_computes_sealed_surface_correctly() { use crate::auth::User; - use crate::state::USER; + use crate::state::{CONTEXT, TaskContext}; use geoengine_api_client::models::{ BoundingBox2D, CollectionType, Coordinate2D, DatasetNameResponse, FeatureDataType, GeoJson, IdResponse, Measurement, TypedResultDescriptor, TypedVectorResultDescriptor, @@ -1030,7 +1037,7 @@ mod tests { session_token: Uuid::from_u128(42).into(), }; - USER.scope(user, async { + CONTEXT.scope(TaskContext::new(user), async { // Start httptest server and mock the external Geo Engine endpoints let server = Server::run(); @@ -1155,7 +1162,7 @@ mod tests { } }), ], - })), + }).append_header("x-computation-id", "00000000-0000-0000-0000-000000000003")), ); // Build API configuration pointing to the mock server @@ -1237,7 +1244,7 @@ mod tests { let year = Year::new(2026); // Call compute_site_land_use_data - let (site_rows, errors) = compute_site_land_use_data( + let (site_rows, errors, _computation_id) = compute_site_land_use_data( &api_config, year, &sites, diff --git a/backend/src/processes/land_use_sealed_area/mod.rs b/backend/src/processes/land_use_sealed_area/mod.rs index 220f7b0..1d64d16 100644 --- a/backend/src/processes/land_use_sealed_area/mod.rs +++ b/backend/src/processes/land_use_sealed_area/mod.rs @@ -2,6 +2,8 @@ use std::collections::HashMap; use crate::{ CONFIG, + credits::add_credits_pending, + db::DbHandle, processes::{ land_use_sealed_area::{ compute::{ @@ -20,7 +22,7 @@ use crate::{ }, util::{set_min_max_in_schema, to_output_keys}, }, - state::USER, + state::{CONTEXT, TaskLocalContext}, util::{md_content, md_heading}, }; use anyhow::Result; @@ -44,7 +46,15 @@ mod types; #[doc = include_str!("description.md")] #[derive(Debug, Clone)] -pub struct LandUseSealedAreaProcess; +pub struct LandUseSealedAreaProcess { + db: DbHandle, +} + +impl LandUseSealedAreaProcess { + pub fn new(db: DbHandle) -> Self { + Self { db } + } +} #[derive(Deserialize, Serialize, Debug, Clone, JsonSchema, ToSchema)] #[serde(rename_all = "camelCase")] @@ -158,11 +168,10 @@ impl Processor for LandUseSealedAreaProcess { } fn process(&self) -> Result { - let configuration = USER.try_get().ok().map(|user| { - CONFIG - .geoengine - .api_config(Some(user.session_token.clone())) - }); + let configuration = CONTEXT + .session_token() + .ok() + .map(|session_token| CONFIG.geoengine.api_config(Some(session_token))); // TODO: make `process` async & get `USER` passed to here tokio::task::block_in_place(|| { @@ -176,15 +185,11 @@ impl Processor for LandUseSealedAreaProcess { let requested_outputs = OutputKeys::from_requested_outputs(&execute.outputs)?; - let user = USER.try_get()?; - let results = self .execute( inputs, requested_outputs, - CONFIG - .geoengine - .api_config(Some(user.session_token.clone())), + CONFIG.geoengine.api_config(Some(CONTEXT.session_token()?)), ) .await?; @@ -353,7 +358,7 @@ impl LandUseSealedAreaProcess { || requested_outputs.errors { // Compute per-site land-use data - let (site_rows, errors) = compute_site_land_use_data( + let (site_rows, errors, computation_id) = compute_site_land_use_data( &configuration, inputs.year, &inputs.sites, @@ -384,6 +389,8 @@ impl LandUseSealedAreaProcess { if requested_outputs.errors { outputs.errors = Some(errors); } + + add_credits_pending(self.db.clone(), computation_id).await?; } if requested_outputs.documentation_sources { @@ -517,7 +524,10 @@ impl From for ExecuteResults { #[cfg(test)] mod tests { use super::*; - use crate::{auth::User, processes::parameters::GeoJsonInputMediaType, state::USER}; + use crate::{ + auth::User, db::tests::with_temp_db, processes::parameters::GeoJsonInputMediaType, + state::TaskContext, + }; use geoengine_api_client::models::{ BoundingBox2D, CollectionType, Coordinate2D, DataId, DatasetNameResponse, FeatureDataType, GeoJson, IdResponse, InternalDataId, Measurement, Provenance, ProvenanceEntry, @@ -653,56 +663,59 @@ mod tests { assert!(deserialized.previous_year_data.is_some()); } - #[tokio::test] + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] async fn process_summary_has_expected_inputs_and_outputs() { - let process = LandUseSealedAreaProcess - .process(None) - .await - .expect("to produce process description"); - - // summary id / version - assert_eq!(process.summary.id, "land-use-sealed-area"); - assert_eq!(process.summary.version, "0.1.0"); - - // job control options contain sync and async execute - let mut has_sync = false; - let mut has_async = false; - for opt in &process.summary.job_control_options { - match opt { - JobControlOptions::SyncExecute => has_sync = true, - JobControlOptions::AsyncExecute => has_async = true, - JobControlOptions::Dismiss => {} + with_temp_db(async move |db| { + let process = LandUseSealedAreaProcess::new(db) + .process(None) + .await + .expect("to produce process description"); + + // summary id / version + assert_eq!(process.summary.id, "land-use-sealed-area"); + assert_eq!(process.summary.version, "0.1.0"); + + // job control options contain sync and async execute + let mut has_sync = false; + let mut has_async = false; + for opt in &process.summary.job_control_options { + match opt { + JobControlOptions::SyncExecute => has_sync = true, + JobControlOptions::AsyncExecute => has_async = true, + JobControlOptions::Dismiss => {} + } + } + assert!(has_sync, "expected SyncExecute in job_control_options"); + assert!(has_async, "expected AsyncExecute in job_control_options"); + + // Check required inputs + for key in [ + input_keys::SITES, + input_keys::LOCATION_NAME_FIELD, + input_keys::SITE_TYPE_FIELD, + input_keys::UNIT_FOR_AREA, + ] { + assert!( + process.inputs.contains_key(key), + "expected input key `{key}` in process inputs" + ); } - } - assert!(has_sync, "expected SyncExecute in job_control_options"); - assert!(has_async, "expected AsyncExecute in job_control_options"); - - // Check required inputs - for key in [ - input_keys::SITES, - input_keys::LOCATION_NAME_FIELD, - input_keys::SITE_TYPE_FIELD, - input_keys::UNIT_FOR_AREA, - ] { - assert!( - process.inputs.contains_key(key), - "expected input key `{key}` in process inputs" - ); - } - // Check required outputs - for key in [ - OutputKeys::LAND_USE_SUMMARY, - OutputKeys::SITE_LAND_USE_TABLE, - OutputKeys::INPUTS, - OutputKeys::ERRORS, - OutputKeys::DOCUMENTATION_SOURCES, - ] { - assert!( - process.outputs.contains_key(key), - "expected output key `{key}` in process outputs" - ); - } + // Check required outputs + for key in [ + OutputKeys::LAND_USE_SUMMARY, + OutputKeys::SITE_LAND_USE_TABLE, + OutputKeys::INPUTS, + OutputKeys::ERRORS, + OutputKeys::DOCUMENTATION_SOURCES, + ] { + assert!( + process.outputs.contains_key(key), + "expected output key `{key}` in process outputs" + ); + } + }) + .await; } #[test] @@ -868,11 +881,14 @@ mod tests { assert!(results.is_empty()); } - #[test] - fn it_provides_correct_process_metadata() { - let process = LandUseSealedAreaProcess; - assert_eq!(process.id(), "land-use-sealed-area"); - assert_eq!(process.version(), "0.1.0"); + #[tokio::test(flavor = "multi_thread")] + async fn it_provides_correct_process_metadata() { + with_temp_db(async move |db| { + let process = LandUseSealedAreaProcess::new(db); + assert_eq!(process.id(), "land-use-sealed-area"); + assert_eq!(process.version(), "0.1.0"); + }) + .await; } #[tokio::test(flavor = "multi_thread")] @@ -886,12 +902,16 @@ mod tests { reason = "Ok for coordinates in test data" )] async fn it_runs_the_process() { + with_temp_db(async move |db| { + let user = User { id: Uuid::from_u128(42), session_token: Uuid::from_u128(42).into(), }; - USER.scope(user, async { + CONTEXT.scope(TaskContext::new(user), async { + CONTEXT.set_job_id(Uuid::from_u128(0x0000_0000_0000_0000_0000)).unwrap(); + // Start httptest server and mock the external Geo Engine endpoints let server = Server::run(); @@ -1001,7 +1021,7 @@ mod tests { } }), ], - })), + }).append_header("x-computation-id", "00000000-0000-0000-0000-000000000003")), ); server.expect( @@ -1132,12 +1152,13 @@ mod tests { .collect(); let requested_outputs = OutputKeys::from_requested_outputs(&requested_outputs).unwrap(); - let process = LandUseSealedAreaProcess; - let result = process - .execute(inputs.clone(), requested_outputs, api_config) - .await - .unwrap(); - + + let process = LandUseSealedAreaProcess::new(db); + let result = process + .execute(inputs.clone(), requested_outputs, api_config) + .await + .unwrap(); + assert_eq!( serde_json::to_value(&result).unwrap(), @@ -1291,5 +1312,7 @@ mod tests { }) ); }).await; + }) + .await; } } diff --git a/backend/src/processes/ndvi.rs b/backend/src/processes/ndvi.rs index 5a34cf6..2cbabeb 100644 --- a/backend/src/processes/ndvi.rs +++ b/backend/src/processes/ndvi.rs @@ -35,20 +35,30 @@ use ogcapi::{ }; use schemars::{JsonSchema, generate::SchemaSettings}; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::{collections::HashMap, str::FromStr}; use tracing::instrument; use utoipa::ToSchema; use crate::{ config::CONFIG, + credits::add_credits_pending, + db::{DbHandle, model::ComputationId}, processes::parameters::{Month, PointGeoJsonInput, Year}, - state::USER, + state::{CONTEXT, TaskLocalContext}, util::{error_response, to_api_vector_process}, }; /// Calculates the Normalized Difference Vegetation Index (NDVI) and the corrected NDVI (kNDVI) from satellite imagery. #[derive(Debug, Clone)] -pub struct NDVIProcess; +pub struct NDVIProcess { + db: DbHandle, +} + +impl NDVIProcess { + pub fn new(db: DbHandle) -> Self { + Self { db } + } +} #[derive(Deserialize, Serialize, Debug, JsonSchema, ToSchema)] pub struct NDVIProcessInputs { @@ -318,10 +328,8 @@ impl Processor for NDVIProcess { } } - let mut outputs = compute_ndvi( - &CONFIG - .geoengine - .api_config(USER.try_get().ok().map(|user| user.session_token)), + let (mut outputs, computation_id) = compute_ndvi( + &CONFIG.geoengine.api_config(CONTEXT.session_token().ok()), &inputs.coordinate.value.coordinates, inputs.year, inputs.month, @@ -334,6 +342,10 @@ impl Processor for NDVIProcess { outputs.inputs = Some(inputs); } + if computation_id.is_some() { + add_credits_pending(self.db.clone(), computation_id).await?; + } + Ok(outputs.into()) } } @@ -356,7 +368,7 @@ async fn compute_ndvi( Month(month): Month, should_compute_ndvi: bool, should_compute_k_ndvi: bool, -) -> Result { +) -> Result<(NDVIProcessOutputs, ComputationId)> { const NDVI: &str = "NDVI"; const K_NDVI: &str = "kNDVI"; @@ -369,11 +381,14 @@ async fn compute_ndvi( (true, false) => vec![ndvi_source()], (false, true) => vec![k_ndvi_source()], (false, false) => { - return Ok(NDVIProcessOutputs { - ndvi: None, - k_ndvi: None, - inputs: None, - }); + return Ok(( + NDVIProcessOutputs { + ndvi: None, + k_ndvi: None, + inputs: None, + }, + ComputationId::none(), + )); } }; let workflow = to_api_vector_process(&VectorOperator::RasterVectorJoin( @@ -437,6 +452,8 @@ async fn compute_ndvi( let maxx = 509_760; let maxy = 5_700_000; + let mut computation_id = String::new(); + let feature_collection = wfs_handler( configuration, &workflow_id, @@ -453,12 +470,14 @@ async fn compute_ndvi( Some(&time_str), Some(&workflow_id), None, + Some(&mut computation_id), ) .await?; - // dbg!(&feature_collection); - - outputs_from_feature_collection(&feature_collection, NDVI, K_NDVI) + Ok(( + outputs_from_feature_collection(&feature_collection, NDVI, K_NDVI)?, + ComputationId::from_str(&computation_id)?, + )) } #[allow(unused)] // TODO: implement @@ -750,6 +769,8 @@ fn k_ndvi_expression() -> String { #[cfg(test)] mod tests { + use crate::db::tests::with_temp_db; + use super::*; use geoengine_api_client::apis::configuration::Configuration as ApiConfiguration; use httptest::matchers::*; @@ -793,12 +814,15 @@ mod tests { // Mock WFS feature handler (GET /wfs/{workflow} -> GeoJSON with NDVI properties) server.expect( - Expectation::matching(request::method("GET")).respond_with(json_encoded(json!({ - "type": "FeatureCollection", - "features": [ - { "type": "Feature", "properties": { "NDVI": 0.123, "kNDVI": 0.456 } } - ] - }))), + Expectation::matching(request::method("GET")).respond_with( + json_encoded(json!({ + "type": "FeatureCollection", + "features": [ + { "type": "Feature", "properties": { "NDVI": 0.123, "kNDVI": 0.456 } } + ] + })) + .append_header("x-computation-id", "00000000-0000-0000-0000-000000000004"), + ), ); // Build API configuration pointing to the mock server @@ -808,9 +832,10 @@ mod tests { // Call compute_ndvi with both outputs requested let coordinates = PointType::from((12.34, 56.78)); - let outputs = compute_ndvi(&api_config, &coordinates, Year(2014), Month(1), true, true) - .await - .expect("compute_ndvi should succeed"); + let (outputs, _computation_id) = + compute_ndvi(&api_config, &coordinates, Year(2014), Month(1), true, true) + .await + .expect("compute_ndvi should succeed"); assert!(outputs.ndvi.is_some()); assert!(outputs.k_ndvi.is_some()); @@ -820,39 +845,42 @@ mod tests { assert!((k_ndvi - 0.456).abs() < 1e-12); } - #[test] - fn process_summary_has_expected_inputs_and_outputs() { - let p = NDVIProcess; - let process = p.process().expect("to produce process description"); - - // summary id / version - assert_eq!(process.summary.id, "ndvi"); - assert_eq!(process.summary.version, "0.1.0"); - - // job control options contain sync and async execute - let mut has_sync = false; - let mut has_async = false; - for opt in &process.summary.job_control_options { - match opt { - JobControlOptions::SyncExecute => has_sync = true, - JobControlOptions::AsyncExecute => has_async = true, - JobControlOptions::Dismiss => todo!(), + #[tokio::test(flavor = "multi_thread", worker_threads = 1)] + async fn process_summary_has_expected_inputs_and_outputs() { + with_temp_db(async move |db| { + let p = NDVIProcess::new(db); + let process = p.process().expect("to produce process description"); + + // summary id / version + assert_eq!(process.summary.id, "ndvi"); + assert_eq!(process.summary.version, "0.1.0"); + + // job control options contain sync and async execute + let mut has_sync = false; + let mut has_async = false; + for opt in &process.summary.job_control_options { + match opt { + JobControlOptions::SyncExecute => has_sync = true, + JobControlOptions::AsyncExecute => has_async = true, + JobControlOptions::Dismiss => todo!(), + } } - } - assert!(has_sync, "expected SyncExecute in job_control_options"); - assert!(has_async, "expected AsyncExecute in job_control_options"); + assert!(has_sync, "expected SyncExecute in job_control_options"); + assert!(has_async, "expected AsyncExecute in job_control_options"); - // inputs contain coordinate, year, month - assert!(process.inputs.contains_key("coordinate")); - assert!(process.inputs.contains_key("year")); - assert!(process.inputs.contains_key("month")); + // inputs contain coordinate, year, month + assert!(process.inputs.contains_key("coordinate")); + assert!(process.inputs.contains_key("year")); + assert!(process.inputs.contains_key("month")); - // outputs contain ndvi and k_ndvi - assert!(process.outputs.contains_key("ndvi")); - assert!(process.outputs.contains_key("kNdvi")); + // outputs contain ndvi and k_ndvi + assert!(process.outputs.contains_key("ndvi")); + assert!(process.outputs.contains_key("kNdvi")); - // some basic checks for descriptions and schema presence - let ndvi_output = &process.outputs["ndvi"]; - assert!(ndvi_output.schema.is_object()); + // some basic checks for descriptions and schema presence + let ndvi_output = &process.outputs["ndvi"]; + assert!(ndvi_output.schema.is_object()); + }) + .await; } } diff --git a/backend/src/server.rs b/backend/src/server.rs index 5846cd2..b22036b 100644 --- a/backend/src/server.rs +++ b/backend/src/server.rs @@ -2,6 +2,7 @@ use crate::{ auth::GeoEngineAuthMiddlewareLayer, collection_transactions::NoCollectionTransactions, config::CONFIG, + credits, db::setup_db, handler, jobs::JobHandler, @@ -11,6 +12,7 @@ use crate::{ }, state::spawn_with_user, }; +use geoengine_api_client::apis::configuration::Configuration; use ogcapi::{ processes::{Processor, echo::Echo}, services::{self as ogcapi_services}, @@ -25,6 +27,12 @@ use utoipa::{ }; use utoipa_axum::{router::OpenApiRouter, routes}; +#[derive(Clone, Debug)] +pub struct AppState { + pub db: crate::db::DbHandle, + pub api_config: Configuration, +} + /// Create and configure the OGC API service, including routes, state, and OpenAPI documentation. pub async fn server() -> anyhow::Result { let db_pool = setup_db(&CONFIG.database).await?; @@ -32,7 +40,11 @@ pub async fn server() -> anyhow::Result { let mut misc_router = OpenApiRouter::new() .routes(routes!(handler::health_handler)) .nest("/auth", handler::auth_router()) - .with_state(CONFIG.geoengine.api_config(None)); + .nest("/credits", credits::router()) + .with_state(AppState { + db: db_pool.clone(), + api_config: CONFIG.geoengine.api_config(None), + }); misc_router .get_openapi_mut() @@ -40,8 +52,8 @@ pub async fn server() -> anyhow::Result { let mut processors: Vec> = vec![ Box::new(Echo), - Box::new(NDVIProcess), - Box::new(LandUseSealedAreaProcess), + Box::new(NDVIProcess::new(db_pool.clone())), + Box::new(LandUseSealedAreaProcess::new(db_pool.clone())), ]; add_habitat_distance_process(&mut processors, db_pool.clone()).await; add_biodiversity_sensitive_areas_process(&mut processors, db_pool.clone()).await; diff --git a/backend/src/state.rs b/backend/src/state.rs index ac50b01..1796c7c 100644 --- a/backend/src/state.rs +++ b/backend/src/state.rs @@ -1,19 +1,78 @@ -use crate::auth::User; -use tokio::task_local; +use crate::{ + auth::User, + util::{Secret, read_lock, write_lock}, +}; +use anyhow::Result; +use std::sync::{Arc, RwLock}; +use tokio::{task::LocalKey, task_local}; +use tracing::Instrument; task_local! { - pub static USER: User; + pub static CONTEXT: TaskContext; +} + +#[derive(Clone, Debug)] +pub struct TaskContext { + user: User, + job_id: Arc>>, +} + +impl TaskContext { + pub fn new(user: User) -> Self { + Self { + user, + job_id: Arc::new(RwLock::new(None)), + } + } + + pub fn set_job_id(&self, job_id: uuid::Uuid) { + *write_lock(&self.job_id) = Some(job_id); + } + + pub fn job_id(&self) -> Option { + *read_lock(&self.job_id) + } +} + +pub trait TaskLocalContext { + fn user_id(&'static self) -> Result; + fn session_token(&'static self) -> Result>; + fn job_id(&'static self) -> Result>; + fn set_job_id(&'static self, job_id: uuid::Uuid) -> Result<()>; +} + +impl TaskLocalContext for LocalKey { + fn user_id(&'static self) -> Result { + let context = self.try_get()?; + Ok(context.user.id) + } + + fn session_token(&'static self) -> Result> { + let context = self.try_get()?; + Ok(context.user.session_token) + } + + fn job_id(&'static self) -> Result> { + let context = self.try_get()?; + Ok(context.job_id()) + } + + fn set_job_id(&'static self, job_id: uuid::Uuid) -> Result<()> { + let context = self.try_get()?; + context.set_job_id(job_id); + Ok(()) + } } pub fn spawn_with_user(fut: F) -> tokio::task::JoinHandle<()> where F: futures::Future + Send + 'static, { - let Ok(user) = USER.try_get() else { + let Ok(context) = CONTEXT.try_get() else { return tokio::spawn(fut); // fallback if no user is set }; - tokio::spawn(USER.scope(user, fut)) + tokio::spawn(CONTEXT.scope(context, fut.in_current_span())) } #[cfg(test)] @@ -30,15 +89,16 @@ mod tests { let (tx, rx) = tokio::sync::oneshot::channel(); // set user in this scope - USER.scope(user.clone(), async { - spawn_with_user(async { - let current_user = USER.get(); - tx.send(current_user.id).unwrap(); + CONTEXT + .scope(TaskContext::new(user.clone()), async { + spawn_with_user(async { + let current_user = CONTEXT.get(); + tx.send(current_user.user.id).unwrap(); + }) + .await + .unwrap(); }) - .await - .unwrap(); - }) - .await; + .await; tokio::select! { () = tokio::time::sleep(std::time::Duration::from_secs(1)) => { diff --git a/backend/src/util.rs b/backend/src/util.rs index 2b67f32..74c876e 100644 --- a/backend/src/util.rs +++ b/backend/src/util.rs @@ -45,7 +45,6 @@ pub fn error_response( } /// Helper function to read-lock a `RwLock`, recovering from poisoning if necessary -#[allow(unused)] // TODO: use or delete pub(crate) fn read_lock(mutex: &std::sync::RwLock) -> std::sync::RwLockReadGuard<'_, T> { match mutex.read() { Ok(guard) => guard, @@ -57,7 +56,6 @@ pub(crate) fn read_lock(mutex: &std::sync::RwLock) -> std::sync::RwLockRea } /// Helper function to write-lock a `RwLock`, recovering from poisoning if necessary. -#[allow(unused)] // TODO: use or delete pub(crate) fn write_lock(mutex: &std::sync::RwLock) -> std::sync::RwLockWriteGuard<'_, T> { match mutex.write() { Ok(guard) => guard, From fd5872606782d888d773b429bb69f208a40a3262 Mon Sep 17 00:00:00 2001 From: Christian Beilschmidt Date: Tue, 11 Aug 2026 17:37:16 +0200 Subject: [PATCH 04/11] feat: add user credits retrieval functionality --- .../typescript/.openapi-generator/FILES | 2 + api-client/typescript/apis/UserApi.ts | 76 +++++++ api-client/typescript/docs/UserApi.md | 57 ++++++ api-client/typescript/models/CreditsForJob.ts | 43 ++++ .../typescript/models/GetCreditsResponse.ts | 58 ++++++ .../typescript/models/ObjectSerializer.ts | 6 + api-client/typescript/models/all.ts | 2 + api-client/typescript/types/ObjectParamAPI.ts | 37 ++++ api-client/typescript/types/ObservableAPI.ts | 36 ++++ api-client/typescript/types/PromiseAPI.ts | 24 +++ backend/conf/default.toml | 3 + backend/diesel-guard.toml | 40 ---- backend/diesel.toml | 13 -- backend/src/config.rs | 9 +- backend/src/credits.rs | 170 +++++++++++++++- backend/src/db/model.rs | 12 +- .../src/processes/land_use_sealed_area/mod.rs | 20 +- backend/src/processes/ndvi.rs | 5 +- frontend/src/app/app.routes.ts | 5 + .../src/app/credits/credits.component.spec.ts | 31 +++ frontend/src/app/credits/credits.component.ts | 192 ++++++++++++++++++ .../app/navigation/navigation.component.html | 5 + frontend/src/app/user.service.ts | 7 + justfile | 5 +- openapi.json | 107 ++++++++++ test-client/biodiversity-sensitive-areas.http | 54 ++++- test-client/call.http | 30 +-- test-client/land-use-sealed-area.http | 94 ++++++++- 28 files changed, 1057 insertions(+), 86 deletions(-) create mode 100644 api-client/typescript/models/CreditsForJob.ts create mode 100644 api-client/typescript/models/GetCreditsResponse.ts delete mode 100644 backend/diesel-guard.toml delete mode 100644 backend/diesel.toml create mode 100644 frontend/src/app/credits/credits.component.spec.ts create mode 100644 frontend/src/app/credits/credits.component.ts diff --git a/api-client/typescript/.openapi-generator/FILES b/api-client/typescript/.openapi-generator/FILES index de6aa44..17176b6 100644 --- a/api-client/typescript/.openapi-generator/FILES +++ b/api-client/typescript/.openapi-generator/FILES @@ -48,6 +48,7 @@ models/Constraints6.ts models/Constraints7.ts models/Constraints8.ts models/Constraints9.ts +models/CreditsForJob.ts models/DataResource.ts models/DateField.ts models/DateTimeField.ts @@ -72,6 +73,7 @@ models/GeoJSONPoint.ts models/GeoJSONPolygon.ts models/GeoJsonInputMediaType.ts models/GeoPointField.ts +models/GetCreditsResponse.ts models/HabitatDistanceProcessInputs.ts models/HabitatDistanceProcessOutputs.ts models/HabitatDistanceProcessParams.ts diff --git a/api-client/typescript/apis/UserApi.ts b/api-client/typescript/apis/UserApi.ts index dc0a56b..8d70c93 100644 --- a/api-client/typescript/apis/UserApi.ts +++ b/api-client/typescript/apis/UserApi.ts @@ -10,6 +10,7 @@ import {SecurityAuthentication} from '../auth/auth'; import { AuthCodeResponse } from '../models/AuthCodeResponse'; import { Exception } from '../models/Exception'; +import { GetCreditsResponse } from '../models/GetCreditsResponse'; import { UserSession } from '../models/UserSession'; /** @@ -96,6 +97,45 @@ export class UserApiRequestFactory extends BaseAPIRequestFactory { + const defaultAuth: SecurityAuthentication | undefined = _config?.authMethods?.default + if (defaultAuth?.applySecurityAuthentication) { + await defaultAuth?.applySecurityAuthentication(requestContext); + } + + return requestContext; + } + + /** + * Returns the user\'s credits. + * @param year + * @param month + */ + public async getCredits(year: number, month: number, _options?: Configuration): Promise { + let _config = _options || this.configuration; + + // verify required parameter 'year' is not null or undefined + if (year === null || year === undefined) { + throw new RequiredError("UserApi", "getCredits", "year"); + } + + + // verify required parameter 'month' is not null or undefined + if (month === null || month === undefined) { + throw new RequiredError("UserApi", "getCredits", "month"); + } + + + // Path Params + const localVarPath = '/credits/{year}/{month}' + .replace('{' + 'year' + '}', encodeURIComponent(String(year))) + .replace('{' + 'month' + '}', encodeURIComponent(String(month))); + + // Make Request Context + const requestContext = _config.baseServer.makeRequestContext(localVarPath, HttpMethod.GET); + requestContext.setHeaderParam("Accept", "application/json, */*;q=0.8") + + + const defaultAuth: SecurityAuthentication | undefined = _config?.authMethods?.default if (defaultAuth?.applySecurityAuthentication) { await defaultAuth?.applySecurityAuthentication(requestContext); @@ -180,4 +220,40 @@ export class UserApiResponseProcessor { throw new ApiException(response.httpStatusCode, "Unknown API Status Code!", await response.getBodyAsAny(), response.headers); } + /** + * Unwraps the actual response sent by the server from the response context and deserializes the response content + * to the expected objects + * + * @params response Response returned by the server for a request to getCredits + * @throws ApiException if the response code was not in [200, 299] + */ + public async getCreditsWithHttpInfo(response: ResponseContext): Promise> { + const contentType = ObjectSerializer.normalizeMediaType(response.headers["content-type"]); + if (isCodeInRange("200", response.httpStatusCode)) { + const body: GetCreditsResponse = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "GetCreditsResponse", "" + ) as GetCreditsResponse; + return new HttpInfo(response.httpStatusCode, response.headers, response.body, body); + } + if (isCodeInRange("500", response.httpStatusCode)) { + const body: Exception = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "Exception", "" + ) as Exception; + throw new ApiException(response.httpStatusCode, "A server error occurred.", body, response.headers); + } + + // Work around for missing responses in specification, e.g. for petstore.yaml + if (response.httpStatusCode >= 200 && response.httpStatusCode <= 299) { + const body: GetCreditsResponse = ObjectSerializer.deserialize( + ObjectSerializer.parse(await response.body.text(), contentType), + "GetCreditsResponse", "" + ) as GetCreditsResponse; + return new HttpInfo(response.httpStatusCode, response.headers, response.body, body); + } + + throw new ApiException(response.httpStatusCode, "Unknown API Status Code!", await response.getBodyAsAny(), response.headers); + } + } diff --git a/api-client/typescript/docs/UserApi.md b/api-client/typescript/docs/UserApi.md index 809069c..86bfbf2 100644 --- a/api-client/typescript/docs/UserApi.md +++ b/api-client/typescript/docs/UserApi.md @@ -6,6 +6,7 @@ Method | HTTP request | Description ------------- | ------------- | ------------- [**authHandler**](UserApi.md#authHandler) | **POST** /auth/accessTokenLogin | [**authRequestUrlHandler**](UserApi.md#authRequestUrlHandler) | **GET** /auth/authenticationRequestUrl | Generates a URL for initiating the OIDC code flow, which the frontend can use to redirect the user to the identity provider\'s login page. +[**getCredits**](UserApi.md#getCredits) | **GET** /credits/{year}/{month} | Returns the user\'s credits. # **authHandler** @@ -121,4 +122,60 @@ No authorization required [[Back to top]](#) [[Back to API list]](README.md#documentation-for-api-endpoints) [[Back to Model list]](README.md#documentation-for-models) [[Back to README]](README.md) +# **getCredits** +> GetCreditsResponse getCredits() + + +### Example + + +```typescript +import { createConfiguration, UserApi } from ''; +import type { UserApiGetCreditsRequest } from ''; + +const configuration = createConfiguration(); +const apiInstance = new UserApi(configuration); + +const request: UserApiGetCreditsRequest = { + + year: 0, + + month: 0, +}; + +const data = await apiInstance.getCredits(request); +console.log('API called successfully. Returned data:', data); +``` + + +### Parameters + +Name | Type | Description | Notes +------------- | ------------- | ------------- | ------------- + **year** | [**number**] | | defaults to undefined + **month** | [**number**] | | defaults to undefined + + +### Return type + +**GetCreditsResponse** + +### Authorization + +No authorization required + +### HTTP request headers + + - **Content-Type**: Not defined + - **Accept**: application/json + + +### HTTP response details +| Status code | Description | Response headers | +|-------------|-------------|------------------| +**200** | The user\'s credits for the specified month. | - | +**500** | A server error occurred. | - | + +[[Back to top]](#) [[Back to API list]](README.md#documentation-for-api-endpoints) [[Back to Model list]](README.md#documentation-for-models) [[Back to README]](README.md) + diff --git a/api-client/typescript/models/CreditsForJob.ts b/api-client/typescript/models/CreditsForJob.ts new file mode 100644 index 0000000..39fe71a --- /dev/null +++ b/api-client/typescript/models/CreditsForJob.ts @@ -0,0 +1,43 @@ +/** + * BioIS API + * API for the BioIS service, providing access to geospatial processing and job management. + * + * OpenAPI spec version: 0.1.0 + * Contact: info@geoengine.de + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { HttpFile } from '../http/http'; + +export class CreditsForJob { + 'jobId': string; + 'creditsUsed': number; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "jobId", + "baseName": "jobId", + "type": "string", + "format": "uuid" + }, + { + "name": "creditsUsed", + "baseName": "creditsUsed", + "type": "number", + "format": "int32" + } ]; + + static getAttributeTypeMap() { + return CreditsForJob.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/api-client/typescript/models/GetCreditsResponse.ts b/api-client/typescript/models/GetCreditsResponse.ts new file mode 100644 index 0000000..2bbf5f9 --- /dev/null +++ b/api-client/typescript/models/GetCreditsResponse.ts @@ -0,0 +1,58 @@ +/** + * BioIS API + * API for the BioIS service, providing access to geospatial processing and job management. + * + * OpenAPI spec version: 0.1.0 + * Contact: info@geoengine.de + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import { CreditsForJob } from '../models/CreditsForJob'; +import { HttpFile } from '../http/http'; + +export class GetCreditsResponse { + 'year': number; + 'month': number; + 'creditsUsed': number; + 'details': Array; + + static readonly discriminator: string | undefined = undefined; + + static readonly mapping: {[index: string]: string} | undefined = undefined; + + static readonly attributeTypeMap: Array<{name: string, baseName: string, type: string, format: string}> = [ + { + "name": "year", + "baseName": "year", + "type": "number", + "format": "int32" + }, + { + "name": "month", + "baseName": "month", + "type": "number", + "format": "int32" + }, + { + "name": "creditsUsed", + "baseName": "creditsUsed", + "type": "number", + "format": "int32" + }, + { + "name": "details", + "baseName": "details", + "type": "Array", + "format": "" + } ]; + + static getAttributeTypeMap() { + return GetCreditsResponse.attributeTypeMap; + } + + public constructor() { + } +} diff --git a/api-client/typescript/models/ObjectSerializer.ts b/api-client/typescript/models/ObjectSerializer.ts index 8ee32bf..daae640 100644 --- a/api-client/typescript/models/ObjectSerializer.ts +++ b/api-client/typescript/models/ObjectSerializer.ts @@ -28,6 +28,7 @@ export * from '../models/Constraints6'; export * from '../models/Constraints7'; export * from '../models/Constraints8'; export * from '../models/Constraints9'; +export * from '../models/CreditsForJob'; export * from '../models/DataResource'; export * from '../models/DateField'; export * from '../models/DateTimeField'; @@ -52,6 +53,7 @@ export * from '../models/GeoJSONPoint'; export * from '../models/GeoJSONPolygon'; export * from '../models/GeoJsonInputMediaType'; export * from '../models/GeoPointField'; +export * from '../models/GetCreditsResponse'; export * from '../models/HabitatDistanceProcessInputs'; export * from '../models/HabitatDistanceProcessOutputs'; export * from '../models/HabitatDistanceProcessParams'; @@ -144,6 +146,7 @@ import { Constraints6 } from '../models/Constraints6'; import { Constraints7 } from '../models/Constraints7'; import { Constraints8 } from '../models/Constraints8'; import { Constraints9 } from '../models/Constraints9'; +import { CreditsForJob } from '../models/CreditsForJob'; import { DataResourceClass } from '../models/DataResource'; import { DateField , DateFieldTypeEnum } from '../models/DateField'; import { DateTimeField , DateTimeFieldTypeEnum } from '../models/DateTimeField'; @@ -168,6 +171,7 @@ import { GeoJSONPoint, GeoJSONPointTypeEnum } from '../models/GeoJSONPoint'; import { GeoJSONPolygon, GeoJSONPolygonTypeEnum } from '../models/GeoJSONPolygon'; import { GeoJsonInputMediaType } from '../models/GeoJsonInputMediaType'; import { GeoPointField , GeoPointFieldTypeEnum , GeoPointFieldFormatEnum } from '../models/GeoPointField'; +import { GetCreditsResponse } from '../models/GetCreditsResponse'; import { HabitatDistanceProcessInputs } from '../models/HabitatDistanceProcessInputs'; import { HabitatDistanceProcessOutputs } from '../models/HabitatDistanceProcessOutputs'; import { HabitatDistanceProcessParams } from '../models/HabitatDistanceProcessParams'; @@ -324,6 +328,7 @@ let typeMap: {[index: string]: any} = { "Constraints7": Constraints7, "Constraints8": Constraints8, "Constraints9": Constraints9, + "CreditsForJob": CreditsForJob, "DataResource": DataResourceClass, "DateField": DateField, "DateTimeField": DateTimeField, @@ -347,6 +352,7 @@ let typeMap: {[index: string]: any} = { "GeoJSONPoint": GeoJSONPoint, "GeoJSONPolygon": GeoJSONPolygon, "GeoPointField": GeoPointField, + "GetCreditsResponse": GetCreditsResponse, "HabitatDistanceProcessInputs": HabitatDistanceProcessInputs, "HabitatDistanceProcessOutputs": HabitatDistanceProcessOutputs, "HabitatDistanceProcessParams": HabitatDistanceProcessParams, diff --git a/api-client/typescript/models/all.ts b/api-client/typescript/models/all.ts index 7bf4da4..8a13110 100644 --- a/api-client/typescript/models/all.ts +++ b/api-client/typescript/models/all.ts @@ -28,6 +28,7 @@ export * from '../models/Constraints6' export * from '../models/Constraints7' export * from '../models/Constraints8' export * from '../models/Constraints9' +export * from '../models/CreditsForJob' export * from '../models/DataResource' export * from '../models/DateField' export * from '../models/DateTimeField' @@ -52,6 +53,7 @@ export * from '../models/GeoJSONPoint' export * from '../models/GeoJSONPolygon' export * from '../models/GeoJsonInputMediaType' export * from '../models/GeoPointField' +export * from '../models/GetCreditsResponse' export * from '../models/HabitatDistanceProcessInputs' export * from '../models/HabitatDistanceProcessOutputs' export * from '../models/HabitatDistanceProcessParams' diff --git a/api-client/typescript/types/ObjectParamAPI.ts b/api-client/typescript/types/ObjectParamAPI.ts index 5ec50d0..28853f5 100644 --- a/api-client/typescript/types/ObjectParamAPI.ts +++ b/api-client/typescript/types/ObjectParamAPI.ts @@ -32,6 +32,7 @@ import { Constraints6 } from '../models/Constraints6'; import { Constraints7 } from '../models/Constraints7'; import { Constraints8 } from '../models/Constraints8'; import { Constraints9 } from '../models/Constraints9'; +import { CreditsForJob } from '../models/CreditsForJob'; import { DataResource } from '../models/DataResource'; import { DateField } from '../models/DateField'; import { DateTimeField } from '../models/DateTimeField'; @@ -56,6 +57,7 @@ import { GeoJSONPoint } from '../models/GeoJSONPoint'; import { GeoJSONPolygon } from '../models/GeoJSONPolygon'; import { GeoJsonInputMediaType } from '../models/GeoJsonInputMediaType'; import { GeoPointField } from '../models/GeoPointField'; +import { GetCreditsResponse } from '../models/GetCreditsResponse'; import { HabitatDistanceProcessInputs } from '../models/HabitatDistanceProcessInputs'; import { HabitatDistanceProcessOutputs } from '../models/HabitatDistanceProcessOutputs'; import { HabitatDistanceProcessParams } from '../models/HabitatDistanceProcessParams'; @@ -557,6 +559,25 @@ export interface UserApiAuthRequestUrlHandlerRequest { redirectUri: string } +export interface UserApiGetCreditsRequest { + /** + * + * Minimum: 0 + * Defaults to: undefined + * @type number + * @memberof UserApigetCredits + */ + year: number + /** + * + * Minimum: 0 + * Defaults to: undefined + * @type number + * @memberof UserApigetCredits + */ + month: number +} + export class ObjectUserApi { private api: ObservableUserApi @@ -594,4 +615,20 @@ export class ObjectUserApi { return this.api.authRequestUrlHandler(param.redirectUri, options).toPromise(); } + /** + * Returns the user\'s credits. + * @param param the request object + */ + public getCreditsWithHttpInfo(param: UserApiGetCreditsRequest, options?: ConfigurationOptions): Promise> { + return this.api.getCreditsWithHttpInfo(param.year, param.month, options).toPromise(); + } + + /** + * Returns the user\'s credits. + * @param param the request object + */ + public getCredits(param: UserApiGetCreditsRequest, options?: ConfigurationOptions): Promise { + return this.api.getCredits(param.year, param.month, options).toPromise(); + } + } diff --git a/api-client/typescript/types/ObservableAPI.ts b/api-client/typescript/types/ObservableAPI.ts index b44d944..2bec73f 100644 --- a/api-client/typescript/types/ObservableAPI.ts +++ b/api-client/typescript/types/ObservableAPI.ts @@ -33,6 +33,7 @@ import { Constraints6 } from '../models/Constraints6'; import { Constraints7 } from '../models/Constraints7'; import { Constraints8 } from '../models/Constraints8'; import { Constraints9 } from '../models/Constraints9'; +import { CreditsForJob } from '../models/CreditsForJob'; import { DataResource } from '../models/DataResource'; import { DateField } from '../models/DateField'; import { DateTimeField } from '../models/DateTimeField'; @@ -57,6 +58,7 @@ import { GeoJSONPoint } from '../models/GeoJSONPoint'; import { GeoJSONPolygon } from '../models/GeoJSONPolygon'; import { GeoJsonInputMediaType } from '../models/GeoJsonInputMediaType'; import { GeoPointField } from '../models/GeoPointField'; +import { GetCreditsResponse } from '../models/GetCreditsResponse'; import { HabitatDistanceProcessInputs } from '../models/HabitatDistanceProcessInputs'; import { HabitatDistanceProcessOutputs } from '../models/HabitatDistanceProcessOutputs'; import { HabitatDistanceProcessParams } from '../models/HabitatDistanceProcessParams'; @@ -735,4 +737,38 @@ export class ObservableUserApi { return this.authRequestUrlHandlerWithHttpInfo(redirectUri, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); } + /** + * Returns the user\'s credits. + * @param year + * @param month + */ + public getCreditsWithHttpInfo(year: number, month: number, _options?: ConfigurationOptions): Observable> { + const _config = mergeConfiguration(this.configuration, _options); + + const requestContextPromise = this.requestFactory.getCredits(year, month, _config); + // build promise chain + let middlewarePreObservable = from(requestContextPromise); + for (const middleware of _config.middleware) { + middlewarePreObservable = middlewarePreObservable.pipe(mergeMap((ctx: RequestContext) => middleware.pre(ctx))); + } + + return middlewarePreObservable.pipe(mergeMap((ctx: RequestContext) => _config.httpApi.send(ctx))). + pipe(mergeMap((response: ResponseContext) => { + let middlewarePostObservable = of(response); + for (const middleware of _config.middleware.reverse()) { + middlewarePostObservable = middlewarePostObservable.pipe(mergeMap((rsp: ResponseContext) => middleware.post(rsp))); + } + return middlewarePostObservable.pipe(map((rsp: ResponseContext) => this.responseProcessor.getCreditsWithHttpInfo(rsp))); + })); + } + + /** + * Returns the user\'s credits. + * @param year + * @param month + */ + public getCredits(year: number, month: number, _options?: ConfigurationOptions): Observable { + return this.getCreditsWithHttpInfo(year, month, _options).pipe(map((apiResponse: HttpInfo) => apiResponse.data)); + } + } diff --git a/api-client/typescript/types/PromiseAPI.ts b/api-client/typescript/types/PromiseAPI.ts index 3269b73..62e5e50 100644 --- a/api-client/typescript/types/PromiseAPI.ts +++ b/api-client/typescript/types/PromiseAPI.ts @@ -32,6 +32,7 @@ import { Constraints6 } from '../models/Constraints6'; import { Constraints7 } from '../models/Constraints7'; import { Constraints8 } from '../models/Constraints8'; import { Constraints9 } from '../models/Constraints9'; +import { CreditsForJob } from '../models/CreditsForJob'; import { DataResource } from '../models/DataResource'; import { DateField } from '../models/DateField'; import { DateTimeField } from '../models/DateTimeField'; @@ -56,6 +57,7 @@ import { GeoJSONPoint } from '../models/GeoJSONPoint'; import { GeoJSONPolygon } from '../models/GeoJSONPolygon'; import { GeoJsonInputMediaType } from '../models/GeoJsonInputMediaType'; import { GeoPointField } from '../models/GeoPointField'; +import { GetCreditsResponse } from '../models/GetCreditsResponse'; import { HabitatDistanceProcessInputs } from '../models/HabitatDistanceProcessInputs'; import { HabitatDistanceProcessOutputs } from '../models/HabitatDistanceProcessOutputs'; import { HabitatDistanceProcessParams } from '../models/HabitatDistanceProcessParams'; @@ -530,6 +532,28 @@ export class PromiseUserApi { return result.toPromise(); } + /** + * Returns the user\'s credits. + * @param year + * @param month + */ + public getCreditsWithHttpInfo(year: number, month: number, _options?: PromiseConfigurationOptions): Promise> { + const observableOptions = wrapOptions(_options); + const result = this.api.getCreditsWithHttpInfo(year, month, observableOptions); + return result.toPromise(); + } + + /** + * Returns the user\'s credits. + * @param year + * @param month + */ + public getCredits(year: number, month: number, _options?: PromiseConfigurationOptions): Promise { + const observableOptions = wrapOptions(_options); + const result = this.api.getCredits(year, month, observableOptions); + return result.toPromise(); + } + } diff --git a/backend/conf/default.toml b/backend/conf/default.toml index e9e64fb..36debf0 100644 --- a/backend/conf/default.toml +++ b/backend/conf/default.toml @@ -25,5 +25,8 @@ level = "info" [data_ids] land_use_imperviousness_builtup = "land_use_impervious_built_up" +[credits] +credits_lookup_interval_seconds = 60 + [credits.biodiversity_sensitive_areas] credits_per_site = 10 diff --git a/backend/diesel-guard.toml b/backend/diesel-guard.toml deleted file mode 100644 index 8546349..0000000 --- a/backend/diesel-guard.toml +++ /dev/null @@ -1,40 +0,0 @@ -# diesel-guard configuration file -# Copy this file to diesel-guard.toml and customize as needed - -# Framework configuration (REQUIRED) -# Specify which migration framework you're using -# Valid values: "diesel" or "sqlx" -framework = "diesel" -# framework = "sqlx" - -# Skip checking migrations created before this timestamp -# Useful for retrofitting diesel-guard into existing projects -# -# Timestamp format depends on your framework: -# Diesel: YYYYMMDDHHMMSS, YYYY_MM_DD_HHMMSS, or YYYY-MM-DD-HHMMSS -# Examples: 20240101000000, 2024_01_01_000000, 2024-01-01-000000 -# SQLx: YYYYMMDDHHMMSS (14 digits, no separators) -# Example: 20240101000000 -# -# Works with any format - diesel-guard normalizes timestamps for comparison -# start_after = "20240101000000" - -# Check rollback migrations (down.sql files or -- migrate:down sections) -# Default: false (only checks forward migrations) -# -# Set to true if you want to ensure rollback migrations are also safe -check_down = true - -# Disable specific safety checks -# Valid check names: -# - AddColumnCheck (ADD COLUMN with DEFAULT) -# - AddIndexCheck (CREATE INDEX without CONCURRENTLY) -# - AddNotNullCheck (ALTER COLUMN SET NOT NULL) -# - AlterColumnTypeCheck (ALTER COLUMN TYPE) -# - DropColumnCheck (DROP COLUMN) -# -# Example: Disable checking for ADD COLUMN and DROP COLUMN operations -# disable_checks = ["AddColumnCheck", "DropColumnCheck"] -# -# Default: [] (all checks enabled) -# disable_checks = [] diff --git a/backend/diesel.toml b/backend/diesel.toml deleted file mode 100644 index 8eb32ae..0000000 --- a/backend/diesel.toml +++ /dev/null @@ -1,13 +0,0 @@ -# For documentation on how to configure this file, -# see https://diesel.rs/guides/configuring-diesel-cli - -[print_schema] -file = "src/db/schema.rs" -import_types = [] -custom_type_derives = ["Debug"] -with_docs = true -allow_tables_to_appear_in_same_query_config = "fk_related_tables" -filter = { except_tables = ["_sqlx_migrations", "spatial_ref_sys"] } - -[migrations_directory] -dir = "migrations" diff --git a/backend/src/config.rs b/backend/src/config.rs index 20f0fba..d5a63bc 100644 --- a/backend/src/config.rs +++ b/backend/src/config.rs @@ -1,6 +1,6 @@ use crate::util::Secret; use geoengine_api_client::apis::configuration::Configuration; -use std::{path::Path, sync::LazyLock}; +use std::{path::Path, sync::LazyLock, time::Duration}; use tracing::Level; use tracing_subscriber::filter::Directive; use url::Url; @@ -136,9 +136,16 @@ fn get_config() -> anyhow::Result { /// Specifies configuration for the credits system #[derive(serde::Deserialize, Clone, Debug)] pub struct CreditsConfig { + pub credits_lookup_interval_seconds: u64, pub biodiversity_sensitive_areas: BiodiversitySensitiveAreasCreditsConfig, } +impl CreditsConfig { + pub fn credits_lookup_interval(&self) -> Duration { + Duration::from_secs(self.credits_lookup_interval_seconds) + } +} + /// Specifies credits configuration for the biodiversity sensitive areas #[derive(serde::Deserialize, Clone, Debug)] pub struct BiodiversitySensitiveAreasCreditsConfig { diff --git a/backend/src/credits.rs b/backend/src/credits.rs index dd8787a..5340d28 100644 --- a/backend/src/credits.rs +++ b/backend/src/credits.rs @@ -1,4 +1,5 @@ use crate::{ + CONFIG, db::{ DbHandle, model::{ComputationId, Credits, TimestampMillis}, @@ -6,18 +7,24 @@ use crate::{ server::AppState, state::{CONTEXT, TaskLocalContext}, }; -use anyhow::Context; +use anyhow::{Context, Result}; use axum::{ Json, extract::{Path, State}, }; use chrono::{TimeZone, Utc}; +use geoengine_api_client::{ + apis::{configuration::Configuration, user_api::computation_quota_handler}, + models::OperatorQuota, +}; use ogcapi::{ services::{self as ogcapi_services}, types::common::Exception, }; use serde::{Deserialize, Serialize}; -use std::collections::HashMap; +use std::{collections::HashMap, sync::LazyLock}; +use tokio::{sync::mpsc, time::sleep}; +use tracing::{error, warn}; use utoipa::{IntoParams, ToSchema}; use utoipa_axum::{router::OpenApiRouter, routes}; use uuid::Uuid; @@ -141,7 +148,7 @@ pub async fn add_credits_used( computation_id: ComputationId, credits: u64, ) -> anyhow::Result<()> { - add_credits_opt(db, computation_id, Some(credits)).await + add_credits_opt(db, None, computation_id, Some(credits)).await } /// Add credits used for a specific job to the database. @@ -149,18 +156,25 @@ pub async fn add_credits_used( /// The job ID is retrieved from the task-local context, which must be set before calling this function. pub async fn add_credits_pending( db: DbHandle, + configuration: Configuration, computation_id: ComputationId, ) -> anyhow::Result<()> { - add_credits_opt(db, computation_id, None).await + add_credits_opt(db, Some(configuration), computation_id, None).await } async fn add_credits_opt( mut db: DbHandle, + configuration: Option, computation_id: ComputationId, credits: Option, ) -> anyhow::Result<()> { let Some(job_id) = CONTEXT.job_id()? else { - anyhow::bail!("No job ID set in the task context"); + // anyhow::bail!("No job ID set in the task context"); // TODO: enforce this always + warn!( + "computationId"=%computation_id, + "No job ID set in the task context, skipping adding credits" + ); + return Ok(()); }; toasty::create!(Credits { @@ -173,5 +187,151 @@ async fn add_credits_opt( .await .context("failed to insert credits into database")?; + if let Some(configuration) = configuration + && computation_id.is_some() + { + enqueue_credits_lookup(configuration, db.clone(), job_id, computation_id); + } + Ok(()) } + +static CREDITS_LOOKUP_QUEUE: LazyLock> = LazyLock::new(|| { + let (tx, mut rx) = mpsc::unbounded_channel::(); + let reentry_tx = tx.clone(); + tokio::spawn(async move { + let mut pending_lookups = Vec::new(); + loop { + // Process all available items + while let Ok(mut lookup) = rx.try_recv() { + // stop if the queue is empty + match lookup.process().await { + Ok(CreditsLookupStatus::Finished) => { + // Credits lookup finished successfully, nothing to do + } + Ok(CreditsLookupStatus::Pending) => { + // Credits lookup is still pending, requeue the lookup for later processing + pending_lookups.push(lookup); + } + Err(e) => { + error!( + "Failed to process credits lookup for job {job_id} and computation ID {computation_id}: {e:?}", + job_id = &lookup.job_id, + computation_id = &lookup.computation_id + ); + // TODO: Decide whether to requeue the lookup or not. + // For now, we will not requeue it to avoid infinite error loops. + } + } + } + + // Move pending lookups back into the queue + for lookup in pending_lookups.drain(..) { + enqueue_credits_lookup_inner(&reentry_tx, lookup); + } + + // Queue is empty, wait 1 minute before processing next batch + sleep(CONFIG.credits.credits_lookup_interval()).await; + } + }); + tx +}); + +fn enqueue_credits_lookup_inner(tx: &mpsc::UnboundedSender, lookup: CreditsLookup) { + if let Err(e) = tx.send(lookup) { + error!( + "Failed to enqueue credits lookup for job {job_id} and computation ID {computation_id}: {e:?}", + job_id = &e.0.job_id, + computation_id = &e.0.computation_id + ); + } +} + +fn enqueue_credits_lookup( + configuration: Configuration, + db: DbHandle, + job_id: Uuid, + computation_id: ComputationId, +) { + let lookup = CreditsLookup::new(configuration, db, job_id, computation_id); + enqueue_credits_lookup_inner(&CREDITS_LOOKUP_QUEUE, lookup); +} + +/// Data for looking up credits for a specific job and computation ID. +/// +/// Geo Engine batches computation logging, so we need to store the computation ID to be able to look up the credits +/// used for a specific job, later. +struct CreditsLookup { + configuration: Configuration, + db: DbHandle, + + job_id: Uuid, + computation_id: ComputationId, + + quotas: Option>, +} + +enum CreditsLookupStatus { + Pending, + Finished, +} + +impl CreditsLookup { + fn new( + configuration: Configuration, + db: DbHandle, + job_id: Uuid, + computation_id: ComputationId, + ) -> Self { + Self { + configuration, + db, + job_id, + computation_id, + quotas: None, + } + } + + /// Process the credits lookup by querying the Geo Engine API and storing the result in the database. + async fn process(&mut self) -> Result { + let quotas = + computation_quota_handler(&self.configuration, &self.computation_id.to_string()) + .await?; + + if quotas.is_empty() { + // Quotas are empty, the computation credits are still missing, so we need to wait for the next batch + return Ok(CreditsLookupStatus::Pending); + } + + let Some(stored_quotas) = &self.quotas else { + // We got quotas, but we need to make sure that the result is consistent + self.quotas = Some(quotas); + return Ok(CreditsLookupStatus::Pending); + }; + + if stored_quotas != "as { + // Quotas have changed, we need to wait for the next batch + self.quotas = Some(quotas); + return Ok(CreditsLookupStatus::Pending); + } + + self.update_credits().await?; + + Ok(CreditsLookupStatus::Finished) + } + + async fn update_credits(&self) -> Result<()> { + fn quotas_sum<'q>(quotas: impl IntoIterator) -> u64 { + quotas.into_iter().map(|q| q.count as u64).sum() + } + + let credits = self.quotas.as_ref().map(quotas_sum); + + toasty::update!(Credits:: filter_by_job_id(self.job_id) { + credits, + }) + .exec(&mut self.db.db()) + .await + .context("Failed to update credits in database") + } +} diff --git a/backend/src/db/model.rs b/backend/src/db/model.rs index 7212fee..6a707d4 100644 --- a/backend/src/db/model.rs +++ b/backend/src/db/model.rs @@ -169,7 +169,7 @@ pub struct Credits { /// An optional computation ID for Geo Engine jobs, stored as a string. /// This is used to track the compute resources used by a job in the Geo Engine system. -#[derive(Debug, Clone, Serialize, Deserialize, toasty::Embed)] +#[derive(Debug, Clone, Copy, Serialize, Deserialize, toasty::Embed)] pub struct ComputationId(Uuid); impl ComputationId { @@ -206,6 +206,16 @@ impl FromStr for ComputationId { } } +impl std::fmt::Display for ComputationId { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + if let Some(id) = self.get() { + write!(f, "{id}") + } else { + write!(f, "None") + } + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/backend/src/processes/land_use_sealed_area/mod.rs b/backend/src/processes/land_use_sealed_area/mod.rs index 1d64d16..f24bf38 100644 --- a/backend/src/processes/land_use_sealed_area/mod.rs +++ b/backend/src/processes/land_use_sealed_area/mod.rs @@ -390,7 +390,7 @@ impl LandUseSealedAreaProcess { outputs.errors = Some(errors); } - add_credits_pending(self.db.clone(), computation_id).await?; + add_credits_pending(self.db.clone(), configuration.clone(), computation_id).await?; } if requested_outputs.documentation_sources { @@ -530,8 +530,9 @@ mod tests { }; use geoengine_api_client::models::{ BoundingBox2D, CollectionType, Coordinate2D, DataId, DatasetNameResponse, FeatureDataType, - GeoJson, IdResponse, InternalDataId, Measurement, Provenance, ProvenanceEntry, - TypedResultDescriptor, TypedVectorResultDescriptor, VectorColumnInfo, VectorDataType, + GeoJson, IdResponse, InternalDataId, Measurement, OperatorQuota, Provenance, + ProvenanceEntry, TypedResultDescriptor, TypedVectorResultDescriptor, VectorColumnInfo, + VectorDataType, }; use geojson::FeatureCollection; use httptest::{ @@ -1041,6 +1042,15 @@ mod tests { }])), ); + server.expect( + Expectation::matching(request::method_path("GET", "//quota/computations/00000000-0000-0000-0000-000000000003")) + .respond_with(json_encoded(vec![OperatorQuota::new( + "OPERATOR-NAME".to_string(), + "OPERATOR-PATH".to_string(), + 100, + )])), + ); + // Build API configuration pointing to the mock server let mut api_config = Configuration::new(); api_config.base_path = server.url_str("/"); @@ -1152,13 +1162,13 @@ mod tests { .collect(); let requested_outputs = OutputKeys::from_requested_outputs(&requested_outputs).unwrap(); - + let process = LandUseSealedAreaProcess::new(db); let result = process .execute(inputs.clone(), requested_outputs, api_config) .await .unwrap(); - + assert_eq!( serde_json::to_value(&result).unwrap(), diff --git a/backend/src/processes/ndvi.rs b/backend/src/processes/ndvi.rs index 2cbabeb..12d57c0 100644 --- a/backend/src/processes/ndvi.rs +++ b/backend/src/processes/ndvi.rs @@ -328,8 +328,9 @@ impl Processor for NDVIProcess { } } + let configuration = CONFIG.geoengine.api_config(CONTEXT.session_token().ok()); let (mut outputs, computation_id) = compute_ndvi( - &CONFIG.geoengine.api_config(CONTEXT.session_token().ok()), + &configuration, &inputs.coordinate.value.coordinates, inputs.year, inputs.month, @@ -343,7 +344,7 @@ impl Processor for NDVIProcess { } if computation_id.is_some() { - add_credits_pending(self.db.clone(), computation_id).await?; + add_credits_pending(self.db.clone(), configuration, computation_id).await?; } Ok(outputs.into()) diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 00e4ee7..1c7052c 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -22,6 +22,11 @@ const appRoutes: Routes = [ title: 'Create new', loadComponent: () => import('./create/create.component').then((m) => m.CreateComponent), }, + { + path: 'credits', + title: 'Credits', + loadComponent: () => import('./credits/credits.component').then((m) => m.CreditsComponent), + }, { path: 'signout', title: `${TITLE} – Sign Out`, diff --git a/frontend/src/app/credits/credits.component.spec.ts b/frontend/src/app/credits/credits.component.spec.ts new file mode 100644 index 0000000..fcab10e --- /dev/null +++ b/frontend/src/app/credits/credits.component.spec.ts @@ -0,0 +1,31 @@ +import { YearAndMonth } from './credits.component'; + +describe('YearAndMonth', () => { + it('should create an instance from a Date object', () => { + const date = new Date(2023, 4, 15); // May 15, 2023 + const yearAndMonth = YearAndMonth.fromDate(date); + expect(yearAndMonth.year).toBe(2023); + expect(yearAndMonth.month).toBe(5); + }); + + it('should return the next month correctly', () => { + const yearAndMonth = new YearAndMonth(2023, 12); // December 2023 + const next = yearAndMonth.nextMonth(); + expect(next.year).toBe(2024); + expect(next.month).toBe(1); + }); + + it('should return the previous month correctly', () => { + const yearAndMonth = new YearAndMonth(2023, 1); // January 2023 + const previous = yearAndMonth.previousMonth(); + expect(previous.year).toBe(2022); + expect(previous.month).toBe(12); + }); + + it('should convert to a Date object correctly', () => { + const yearAndMonth = new YearAndMonth(2023, 5); // May 2023 + const date = yearAndMonth.date; + expect(date.getFullYear()).toBe(2023); + expect(date.getMonth()).toBe(4); // Months are zero-based in JavaScript Date + }); +}); diff --git a/frontend/src/app/credits/credits.component.ts b/frontend/src/app/credits/credits.component.ts new file mode 100644 index 0000000..b924e02 --- /dev/null +++ b/frontend/src/app/credits/credits.component.ts @@ -0,0 +1,192 @@ +import { ChangeDetectionStrategy, Component, inject, resource, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { MatDatepicker, MatDatepickerModule } from '@angular/material/datepicker'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { provideNativeDateAdapter } from '@angular/material/core'; +import { PageTitleComponent } from '../navigation/page-title.component'; +import { MatIconModule } from '@angular/material/icon'; +import { MatButtonModule } from '@angular/material/button'; +import { UserService } from '../user.service'; +import { MatTableModule } from '@angular/material/table'; +import { RouterLink } from '@angular/router'; + +const YEAR_AND_MONTH_FORMAT = { + parse: { + dateInput: 'MM/yyyy', + }, + display: { + dateInput: 'MM/yyyy', + monthYearLabel: 'MMM yyyy', + dateA11yLabel: 'DD', + monthYearA11yLabel: 'MMMM yyyy', + }, +}; + +@Component({ + selector: 'app-credits', + template: ` + +
+ + + {{ yearAndMonth().date | date: 'MMM yyyy' }} + + + + + +
+
+ @if (credits.isLoading()) { + + } @else if (credits.error()) { +

Error loading credits: {{ credits.error() }}

+ } @else if (credits.value(); as value) { + Credits for {{ yearAndMonth().date | date: 'MMM yyyy' }} +

+ Credits used: + {{ value.creditsUsed }} +

+ @if (value.details.length) { +

Details:

+ + + Job Id + + {{ element.jobId }} + + + + + Credits used + {{ element.creditsUsed }} + + + + + + } + } +
+ `, + styles: ` + .month-picker { + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + gap: 1rem; + } + + mat-spinner { + margin: 5rem auto; + } + + .credits { + margin: 1rem; + + border-color: var(--mat-sys-surface-container-lowest); + border-radius: var(--mat-sys-corner-medium); + padding: 1rem; + + legend { + font: var(--mat-sys-body-small); + color: var(--mat-sys-on-surface-variant); + } + + .error { + color: var(--mat-sys-error); + } + + .credits-used { + font: var(--mat-sys-title-large); + color: var(--mat-sys-on-surface); + } + } + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + MatButtonModule, + MatDatepickerModule, + MatIconModule, + MatProgressSpinnerModule, + MatTableModule, + PageTitleComponent, + RouterLink, + ], + providers: [provideNativeDateAdapter(YEAR_AND_MONTH_FORMAT)], +}) +export class CreditsComponent { + readonly userService = inject(UserService); + + readonly yearAndMonth = signal(YearAndMonth.fromCurrentDate()); + readonly credits = resource({ + params: () => ({ + year: this.yearAndMonth().year, + month: this.yearAndMonth().month, + }), + loader: ({ params: { year, month } }) => this.userService.credits(year, month), + }); + + setMonthAndYear(date: Date, datepicker: MatDatepicker): void { + this.yearAndMonth.set(YearAndMonth.fromDate(date)); + datepicker.close(); + } + + nextMonth(): void { + this.yearAndMonth.set(this.yearAndMonth().nextMonth()); + } + + previousMonth(): void { + this.yearAndMonth.set(this.yearAndMonth().previousMonth()); + } +} + +/** + * A simple class to represent a year and month combination. + * It provides methods to create instances from a Date object, get the next and previous months, + * and format the year and month as a string. + */ +export class YearAndMonth { + year: number; + month: number; + + constructor(year: number, month: number) { + this.year = year; + this.month = month; + } + + static fromDate(date: Date): YearAndMonth { + return new YearAndMonth(date.getFullYear(), date.getMonth() + 1); + } + + static fromCurrentDate(): YearAndMonth { + return YearAndMonth.fromDate(new Date()); + } + + nextMonth(): YearAndMonth { + const year = this.year + Math.trunc(this.month / 12); + const month = 1 + (this.month % 12); + return new YearAndMonth(year, month); + } + + previousMonth(): YearAndMonth { + const year = this.year - Math.trunc((13 - this.month) / 12); + const month = 1 + ((this.month + 10) % 12); + return new YearAndMonth(year, month); + } + + get date(): Date { + return new Date(this.year, this.month - 1); + } +} diff --git a/frontend/src/app/navigation/navigation.component.html b/frontend/src/app/navigation/navigation.component.html index 9c3b82b..3d3598a 100644 --- a/frontend/src/app/navigation/navigation.component.html +++ b/frontend/src/app/navigation/navigation.component.html @@ -28,6 +28,11 @@ }
+ + + credit_card + Credits + logout diff --git a/frontend/src/app/user.service.ts b/frontend/src/app/user.service.ts index 3afa1e1..900746a 100644 --- a/frontend/src/app/user.service.ts +++ b/frontend/src/app/user.service.ts @@ -5,6 +5,7 @@ import { UserSession, UserApi, Configuration, + GetCreditsResponse, } from '@geoengine/biois'; const USER_SESSION_KEY = 'userSession'; @@ -69,6 +70,12 @@ export class UserService { window.location.href = oidcUrl; } + async credits(year: number, month: number): Promise { + const userApi = new UserApi(this.apiConfiguration()); + const credits = await userApi.getCredits(year, month); + return credits; + } + apiConfiguration(): Configuration { const authMethods = { accessToken: 'Bearer ' + this.user()?.id /* TODO: handle missing/expired token */, diff --git a/justfile b/justfile index 47a4691..effc274 100644 --- a/justfile +++ b/justfile @@ -100,12 +100,13 @@ lint-api-client: _clear rm -r node_modules rm package-lock.json +[arg('write', long='write', value='', help='Write changes to files instead of checking.')] [group('backend')] [group('lint')] [working-directory('backend')] -lint-backend-rustfmt: _clear +lint-backend-rustfmt write="--check": _clear @echo "Running rustfmt…" - cargo fmt --all -- --check + cargo fmt --all {{ write }} [arg("relaxed", long="relaxed", value="true", help="Don't fail on warnings, only print them")] [group('backend')] diff --git a/openapi.json b/openapi.json index 2ff36f5..4b094f4 100644 --- a/openapi.json +++ b/openapi.json @@ -602,6 +602,63 @@ } } }, + "/credits/{year}/{month}": { + "get": { + "tags": [ + "User" + ], + "summary": "Returns the user's credits.", + "operationId": "get_credits", + "parameters": [ + { + "name": "year", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + }, + { + "name": "month", + "in": "path", + "required": true, + "schema": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + ], + "responses": { + "200": { + "description": "The user's credits for the specified month.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetCreditsResponse" + } + } + } + }, + "500": { + "description": "A server error occurred.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Exception" + }, + "example": { + "status": 500, + "type": "https://httpwg.org/specs/rfc7231.html#status.500" + } + } + } + } + } + } + }, "/processes/ndvi/execution": { "post": { "tags": [ @@ -928,6 +985,24 @@ } } }, + "CreditsForJob": { + "type": "object", + "required": [ + "jobId", + "creditsUsed" + ], + "properties": { + "jobId": { + "type": "string", + "format": "uuid" + }, + "creditsUsed": { + "type": "integer", + "format": "int32", + "minimum": 0 + } + } + }, "DescriptionType": { "type": "object", "description": "Basic description type", @@ -1054,6 +1129,38 @@ "application/geo+json" ] }, + "GetCreditsResponse": { + "type": "object", + "required": [ + "year", + "month", + "creditsUsed", + "details" + ], + "properties": { + "year": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "month": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "creditsUsed": { + "type": "integer", + "format": "int32", + "minimum": 0 + }, + "details": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CreditsForJob" + } + } + } + }, "HabitatDistanceProcessInputs": { "type": "object", "required": [ diff --git a/test-client/biodiversity-sensitive-areas.http b/test-client/biodiversity-sensitive-areas.http index 5acd175..9aad0c4 100644 --- a/test-client/biodiversity-sensitive-areas.http +++ b/test-client/biodiversity-sensitive-areas.http @@ -43,9 +43,59 @@ Content-Type: application/json }, "mediaType": "application/geo+json" }, - "siteTypeProperty": "siteType", - "locationProperty": "location" + "siteTypeField": "siteType", + "locationNameField": "location", + "unitForArea": "m²" }, "outputs": {}, "response": "document" } + +### + +POST http://localhost:4040/processes/biodiversity-sensitive-areas/execution +Authorization: Bearer {{token}} +Content-Type: application/json +Prefer: respond-async + +{ + "inputs": { + "sites": { + "value": { + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [8.77366548049784, 50.803270291022386], + [8.773649409958182, 50.802437463615604], + [8.774613642351255, 50.80241207230304], + [8.774597571811597, 50.803255056507936], + [8.77366548049784, 50.803270291022386] + ] + ] + }, + "properties": { + "location": "Marbuger Unistadion", + "siteType": "office" + } + } + ] + }, + "mediaType": "application/geo+json" + }, + "siteTypeField": "siteType", + "locationNameField": "location", + "unitForArea": "m²" + }, + "outputs": {}, + "response": "document" +} + +### + +GET http://localhost:4040/credits/2026/08 +Authorization: Bearer {{token}} diff --git a/test-client/call.http b/test-client/call.http index 88b3578..7b10a08 100644 --- a/test-client/call.http +++ b/test-client/call.http @@ -1,15 +1,17 @@ -# @name anonymousSession -POST https://geoengine.biois.app.geoengine.io/api/anonymous +# @name session +POST http://localhost:3030/api/anonymous + +@token = {{session.response.body.$.id}} ### GET http://localhost:4040/processes/ndvi -Authorization: Bearer {{anonymousSession.response.body.$.id}} +Authorization: Bearer {{token}} ### POST http://localhost:4040/processes/ndvi/execution -Authorization: Bearer {{anonymousSession.response.body.$.id}} +Authorization: Bearer {{token}} Content-Type: application/json { @@ -30,7 +32,7 @@ Content-Type: application/json ### POST http://localhost:4040/processes/ndvi/execution -Authorization: Bearer {{anonymousSession.response.body.$.id}} +Authorization: Bearer {{token}} Content-Type: application/json { @@ -54,7 +56,7 @@ Content-Type: application/json ### POST http://localhost:4040/processes/ndvi/execution -Authorization: Bearer {{anonymousSession.response.body.$.id}} +Authorization: Bearer {{token}} Content-Type: application/json { @@ -82,7 +84,7 @@ Content-Type: application/json # @name process POST http://localhost:4040/processes/ndvi/execution -Authorization: Bearer {{anonymousSession.response.body.$.id}} +Authorization: Bearer {{token}} Content-Type: application/json Prefer: respond-async @@ -104,19 +106,19 @@ Prefer: respond-async ### GET http://localhost:4040/jobs/{{process.response.body.jobID}} -Authorization: Bearer {{anonymousSession.response.body.$.id}} +Authorization: Bearer {{token}} Content-Type: application/json ### GET http://localhost:4040/jobs/{{process.response.body.jobID}}/results -Authorization: Bearer {{anonymousSession.response.body.$.id}} +Authorization: Bearer {{token}} Content-Type: application/json ### POST http://localhost:4040/processes/ndvi/execution -Authorization: Bearer {{anonymousSession.response.body.$.id}} +Authorization: Bearer {{token}} Content-Type: application/json { @@ -137,7 +139,7 @@ Content-Type: application/json ### POST http://localhost:4040/processes/ndvi/execution -Authorization: Bearer {{anonymousSession.response.body.$.id}} +Authorization: Bearer {{token}} Content-Type: application/json { @@ -172,12 +174,12 @@ Content-Type: application/json ### GET http://localhost:4040/processes/habitatDistance -Authorization: Bearer {{anonymousSession.response.body.$.id}} +Authorization: Bearer {{token}} ### POST http://localhost:4040/processes/habitatDistance/execution -Authorization: Bearer {{anonymousSession.response.body.$.id}} +Authorization: Bearer {{token}} Content-Type: application/json { @@ -196,7 +198,7 @@ Content-Type: application/json ### GET http://localhost:4040/jobs/{{process.response.body.jobID}} -Authorization: Bearer {{anonymousSession.response.body.$.id}} +Authorization: Bearer {{token}} Content-Type: application/json ### \ No newline at end of file diff --git a/test-client/land-use-sealed-area.http b/test-client/land-use-sealed-area.http index 570cc2d..fd27b9e 100644 --- a/test-client/land-use-sealed-area.http +++ b/test-client/land-use-sealed-area.http @@ -95,4 +95,96 @@ Content-Type: application/json "response": "document" } -### \ No newline at end of file +### + +POST http://localhost:4040/processes/land-use-sealed-area/execution +Authorization: Bearer {{token}} +Content-Type: application/json +Prefer: respond-async + +{ + "inputs": { + "sites": { + "value": { + "type": "FeatureCollection", + "name": "test-data-sealing", + "crs": { + "type": "name", + "properties": { + "name": "urn:ogc:def:crs:OGC:1.3:CRS84" + } + }, + "features": [ + { + "type": "Feature", + "properties": { + "name": "Musizierhaus", + "siteType": "site" + }, + "geometry": { + "type": "Polygon", + "coordinates": [[[8.773084369718358, 50.812878691542942], [8.773108704553691, 50.812363165183598], [8.773636906051513, 50.812368774306606], [8.77363422626267, 50.812881114843449], [8.773084369718358, 50.812878691542942]]] + } + }, + { + "type": "Feature", + "properties": { + "name": "Im Garten", + "siteType": "site" + }, + "geometry": { + "type": "Polygon", + "coordinates": [[[8.771216247876815, 50.812571907069582], [8.771206911327607, 50.812312207328453], [8.771692263238966, 50.812307122122299], [8.771706722708121, 50.8125771236597], [8.771216247876815, 50.812571907069582]]] + } + }, + { + "type": "Feature", + "properties": { + "name": "Teil der Zentralbib", + "siteType": "site" + }, + "geometry": { + "type": "Polygon", + "coordinates": [[[8.771008264934009, 50.813509037898953], [8.771036928561866, 50.813239495110878], [8.771494891149727, 50.813251198211937], [8.771508900259988, 50.813538273899908], [8.771008264934009, 50.813509037898953]]] + } + }, + { + "type": "Feature", + "properties": { + "name": "Garten im Musizierhaus", + "siteType": "natureOnSite" + }, + "geometry": { + "type": "Polygon", + "coordinates": [[[8.773166017675141, 50.812438921387283], [8.773167633417881, 50.812377454260897], [8.773350797293739, 50.812382815303913], [8.773343971472983, 50.812437395514991], [8.773166017675141, 50.812438921387283]]] + } + }, + { + "type": "Feature", + "properties": { + "name": "Im Alten Botanischen Garten", + "siteType": "natureOffSite" + }, + "geometry": { + "type": "Polygon", + "coordinates": [[[8.771981787517911, 50.811958373658889], [8.771975230909757, 50.811797761589858], [8.772320354208373, 50.811794598662132], [8.772310652890782, 50.811958453698445], [8.771981787517911, 50.811958373658889]]] + } + } + ] + }, + "mediaType": "application/geo+json" + }, + "locationNameField": "name", + "siteTypeField": "siteType", + "year": 2024, + "unitForArea": "m²" + }, + "outputs": {}, + "response": "document" +} + +### + + +GET http://localhost:4040/credits/2026/08 +Authorization: Bearer {{token}} From 56c25a814127207ebbb8f76376b2d96fe24f4329 Mon Sep 17 00:00:00 2001 From: Christian Beilschmidt Date: Wed, 12 Aug 2026 09:22:50 +0200 Subject: [PATCH 05/11] refactor: macro for tests with db and scope --- backend/Cargo.lock | 295 +++---- backend/Cargo.toml | 18 +- backend/macros/Cargo.toml | 16 + backend/macros/src/lib.rs | 25 + backend/macros/src/test.rs | 170 ++++ backend/src/{credits.rs => credits/db.rs} | 214 ++--- backend/src/credits/handler.rs | 333 ++++++++ backend/src/credits/mod.rs | 5 + backend/src/db/model.rs | 10 +- backend/src/handler.rs | 10 +- backend/src/jobs.rs | 229 +++--- backend/src/lib.rs | 1 + .../biodiversity_sensitive_areas/mod.rs | 406 +++++----- backend/src/processes/habitat_distance.rs | 112 ++- .../src/processes/land_use_sealed_area/mod.rs | 742 +++++++++--------- backend/src/processes/ndvi.rs | 65 +- justfile | 7 + 17 files changed, 1488 insertions(+), 1170 deletions(-) create mode 100644 backend/macros/Cargo.toml create mode 100644 backend/macros/src/lib.rs create mode 100644 backend/macros/src/test.rs rename backend/src/{credits.rs => credits/db.rs} (65%) create mode 100644 backend/src/credits/handler.rs create mode 100644 backend/src/credits/mod.rs diff --git a/backend/Cargo.lock b/backend/Cargo.lock index 6643006..d378615 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -167,9 +167,9 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.91" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", @@ -208,9 +208,9 @@ checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "aws-lc-rs" -version = "1.17.3" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +checksum = "ce2b2dcc879c3bae0d371e77c99f2238400ef24ec001394befa67b6e543add9e" dependencies = [ "aws-lc-sys", "zeroize", @@ -218,9 +218,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.43.0" +version = "0.44.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +checksum = "f09fae7be8bb3174e05c6afdb34199e6dc0c7c04ba9fa237b1967adfbde27483" dependencies = [ "cc", "cmake", @@ -330,6 +330,7 @@ dependencies = [ "approx 0.6.0-rc2", "async-trait", "axum", + "biois-macros", "chrono", "config", "futures", @@ -342,12 +343,11 @@ dependencies = [ "o2o", "ogcapi", "pretty_assertions", - "reqwest", "schemars 1.2.2", "serde", "serde_json", "tempfile", - "toasty 0.9.0 (git+https://github.com/tokio-rs/toasty?branch=main)", + "toasty", "toasty-cli", "tokio", "tower", @@ -361,6 +361,16 @@ dependencies = [ "wkt", ] +[[package]] +name = "biois-macros" +version = "0.1.0" +dependencies = [ + "pretty_assertions", + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "bit-set" version = "0.11.1" @@ -455,9 +465,9 @@ dependencies = [ [[package]] name = "bstr" -version = "1.13.0" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f7dc094d718f2e1c1559ad110e27eeaae14a5465d3d56dd6dbd793079fbd530" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", @@ -490,9 +500,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +checksum = "5d262e149917187838d5b42777c8253bcb64500067342904e7d429499a6f277e" dependencies = [ "find-msvc-tools", "jobserver", @@ -558,9 +568,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.6.5" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" dependencies = [ "clap_builder", "clap_derive", @@ -568,9 +578,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.5" +version = "4.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" dependencies = [ "anstream", "anstyle", @@ -961,7 +971,7 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" dependencies = [ - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1185,9 +1195,9 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "26b73573e6edcd2af0cdf47bd6cb58f0b3839491263c314eaad1ccf24430e1de" [[package]] name = "flagset" @@ -1278,9 +1288,9 @@ checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -1293,9 +1303,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -1303,15 +1313,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1331,38 +1341,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -1427,7 +1437,7 @@ dependencies = [ "rstar 0.8.4", "rstar 0.9.3", "serde", - "thiserror 2.0.19", + "thiserror 2.0.20", ] [[package]] @@ -1456,7 +1466,7 @@ dependencies = [ "log", "serde", "serde_json", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", ] @@ -2138,7 +2148,7 @@ dependencies = [ "jni-sys", "log", "simd_cesu8", - "thiserror 2.0.19", + "thiserror 2.0.20", "walkdir", "windows-link", ] @@ -2187,9 +2197,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -2466,9 +2476,9 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] @@ -2590,7 +2600,7 @@ dependencies = [ "serde", "serde_json", "serde_qs", - "thiserror 2.0.19", + "thiserror 2.0.20", "url", ] @@ -2648,7 +2658,7 @@ dependencies = [ "serde_json", "serde_qs", "serde_yaml", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tower", "tower-http", @@ -2965,9 +2975,9 @@ dependencies = [ [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" @@ -3075,7 +3085,7 @@ dependencies = [ "rustc-hash", "rustls", "socket2", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tracing", "web-time", @@ -3098,7 +3108,7 @@ dependencies = [ "rustls", "rustls-pki-types", "slab", - "thiserror 2.0.19", + "thiserror 2.0.20", "tinyvec", "tracing", "web-time", @@ -3609,9 +3619,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ "aws-lc-rs", "ring", @@ -3800,9 +3810,9 @@ dependencies = [ [[package]] name = "serde_qs" -version = "1.1.2" +version = "1.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67d525c8ff68aa99e5818302259bdd02d86d0303710616f39c0f44846ff6d332" +checksum = "475b9310e272827d34113bf8679b6dfb7fa0023ee049764ba003f8f984e8b5d1" dependencies = [ "itoa", "percent-encoding", @@ -3844,9 +3854,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "ee78f1fbe43ac4a0e47aadb3dbd357b69eb0d3793e948624cd03dd2750ab1c0a" dependencies = [ "base64", "bs58", @@ -3854,6 +3864,7 @@ dependencies = [ "hex", "indexmap 1.9.3", "indexmap 2.14.0", + "jiff", "schemars 0.9.0", "schemars 1.2.2", "serde_core", @@ -3864,9 +3875,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "8705578779c2b6bd90d84d66eb2e206b708b1a4d7b9f17641b293545bf1c7e46" dependencies = [ "darling", "proc-macro2", @@ -4115,7 +4126,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "tokio", "tokio-stream", "tracing", @@ -4147,7 +4158,7 @@ dependencies = [ "percent-encoding", "rustls", "smallvec", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tokio", "tokio-stream", @@ -4231,7 +4242,7 @@ dependencies = [ "smallvec", "sqlx-core 0.8.6", "stringprep", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "whoami 1.6.1", ] @@ -4258,7 +4269,7 @@ dependencies = [ "sha1 0.11.0", "sha2 0.11.0", "sqlx-core 0.9.0", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing", ] @@ -4295,7 +4306,7 @@ dependencies = [ "smallvec", "sqlx-core 0.8.6", "stringprep", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "whoami 1.6.1", ] @@ -4319,7 +4330,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core 0.8.6", - "thiserror 2.0.19", + "thiserror 2.0.20", "tracing", "url", ] @@ -4463,11 +4474,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ - "thiserror-impl 2.0.19", + "thiserror-impl 2.0.20", ] [[package]] @@ -4483,9 +4494,9 @@ dependencies = [ [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -4589,9 +4600,9 @@ dependencies = [ [[package]] name = "toasty" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2abf272caf351f58e03758745b6185576fcfe7a513b007683c2f8eec698eed2" +checksum = "c526dee282a42d5a2e61b189b8767181586a74a3d6494ef9076fc8d8a1daa7b9" dependencies = [ "async-trait", "bit-set", @@ -4602,47 +4613,25 @@ dependencies = [ "indexmap 2.14.0", "inventory", "serde", - "toasty-core 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", - "toasty-macros 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", - "tokio", - "tokio-stream", - "toml", - "toml_edit", - "tracing", - "url", - "uuid", -] - -[[package]] -name = "toasty" -version = "0.9.0" -source = "git+https://github.com/tokio-rs/toasty?branch=main#eb220ca08079716a82feeeaafa07e15f9ab864e7" -dependencies = [ - "async-trait", - "bit-set", - "by_address", - "deadpool", - "hashbrown 0.17.1", - "index_vec", - "indexmap 2.14.0", - "inventory", "serde_core", "serde_json", - "toasty-core 0.9.0 (git+https://github.com/tokio-rs/toasty?branch=main)", + "toasty-core", "toasty-driver-mysql", "toasty-driver-postgresql", - "toasty-macros 0.9.0 (git+https://github.com/tokio-rs/toasty?branch=main)", + "toasty-macros", "tokio", "tokio-stream", + "toml", + "toml_edit", "tracing", "uuid", ] [[package]] name = "toasty-cli" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81a49aa74c23053c0e0e526214a5f060f86c1e8261f0170516c11ab63dc55487" +checksum = "8b31a55c15b5bd72db9465f00966ade3ac522eb34d80752ecb3038b991240bbf" dependencies = [ "anyhow", "clap", @@ -4652,32 +4641,16 @@ dependencies = [ "jiff", "rand 0.10.2", "serde", - "toasty 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", + "toasty", + "toasty-core", "toml", - "url", ] [[package]] name = "toasty-core" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc1650657e3784097859a1c31cf91b48e337f85684757e0c6ad44e887f0e009f" -dependencies = [ - "async-trait", - "bit-set", - "hashbrown 0.17.1", - "heck", - "indexmap 2.14.0", - "serde", - "tokio-stream", - "tracing", - "uuid", -] - -[[package]] -name = "toasty-core" -version = "0.9.0" -source = "git+https://github.com/tokio-rs/toasty?branch=main#eb220ca08079716a82feeeaafa07e15f9ab864e7" +checksum = "6ce096128c41a7b5a86b0a3570a8ffa866de889a081281956bb73c6a7ba0b88a" dependencies = [ "async-trait", "bit-set", @@ -4696,14 +4669,15 @@ dependencies = [ [[package]] name = "toasty-driver-mysql" -version = "0.9.0" -source = "git+https://github.com/tokio-rs/toasty?branch=main#eb220ca08079716a82feeeaafa07e15f9ab864e7" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a7a281dccebd99defe5e84ab56e8871cf22296ac3bcca8d333e427697c8b7ea" dependencies = [ "async-trait", "sqlx-core 0.9.0", "sqlx-mysql 0.9.0", "time", - "toasty-core 0.9.0 (git+https://github.com/tokio-rs/toasty?branch=main)", + "toasty-core", "toasty-sql", "tokio", "tracing", @@ -4712,8 +4686,9 @@ dependencies = [ [[package]] name = "toasty-driver-postgresql" -version = "0.9.0" -source = "git+https://github.com/tokio-rs/toasty?branch=main#eb220ca08079716a82feeeaafa07e15f9ab864e7" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "695e13090ba6c14ee3c827f539b2301a712127f503aca234c5b7f7af00c504e0" dependencies = [ "async-trait", "fallible-iterator", @@ -4725,7 +4700,7 @@ dependencies = [ "rustls-pemfile", "rustls-platform-verifier", "rustls-webpki", - "toasty-core 0.9.0 (git+https://github.com/tokio-rs/toasty?branch=main)", + "toasty-core", "toasty-sql", "tokio", "tokio-postgres", @@ -4736,23 +4711,9 @@ dependencies = [ [[package]] name = "toasty-macros" -version = "0.9.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16966e54a422be54959b0ad12bdbdcffc101c77aeca7d49fefff33142c709a29" -dependencies = [ - "hashbrown 0.17.1", - "heck", - "pluralizer", - "proc-macro2", - "quote", - "syn 3.0.3", - "toasty-core 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", -] - -[[package]] -name = "toasty-macros" -version = "0.9.0" -source = "git+https://github.com/tokio-rs/toasty?branch=main#eb220ca08079716a82feeeaafa07e15f9ab864e7" +checksum = "a46f85a2a258f59e32789b2de7a5b171e4c68835311dcec9ca71ef6e7901714d" dependencies = [ "hashbrown 0.17.1", "heck", @@ -4760,17 +4721,18 @@ dependencies = [ "proc-macro2", "quote", "syn 3.0.3", - "toasty-core 0.9.0 (git+https://github.com/tokio-rs/toasty?branch=main)", + "toasty-core", ] [[package]] name = "toasty-sql" -version = "0.9.0" -source = "git+https://github.com/tokio-rs/toasty?branch=main#eb220ca08079716a82feeeaafa07e15f9ab864e7" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b44b08035efcf8e087c6377ae18942066b1f8e976cd3821571e45463ecf3d6f" dependencies = [ "serde", "serde_json", - "toasty-core 0.9.0 (git+https://github.com/tokio-rs/toasty?branch=main)", + "toasty-core", ] [[package]] @@ -4782,7 +4744,6 @@ dependencies = [ "bytes", "libc", "mio", - "parking_lot", "pin-project-lite", "signal-hook-registry", "socket2", @@ -4834,7 +4795,7 @@ dependencies = [ "socket2", "tokio", "tokio-util", - "whoami 2.1.2", + "whoami 2.1.3", ] [[package]] @@ -5013,7 +4974,7 @@ checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" dependencies = [ "crossbeam-channel", "symlink", - "thiserror 2.0.19", + "thiserror 2.0.20", "time", "tracing-subscriber", ] @@ -5322,9 +5283,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -5335,9 +5296,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -5345,9 +5306,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5355,9 +5316,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -5368,9 +5329,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] @@ -5390,9 +5351,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -5447,9 +5408,9 @@ dependencies = [ [[package]] name = "whoami" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "998767ef88740d1f5b0682a9c53c24431453923962269c2db68ee43788c5a40d" +checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c" dependencies = [ "libc", "libredox", @@ -5773,18 +5734,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 4b5f7a6..028f53b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -1,3 +1,9 @@ +[workspace] +members = ["macros"] + +[workspace.dependencies] +pretty_assertions = "1.4" + [package] name = "biois" version = "0.1.0" @@ -52,6 +58,7 @@ anyhow = "1.0" approx = { version = "0.6.0-rc2", features = ["derive", "vec_impl"] } async-trait = "0.1" axum = { version = "0.8", features = ["multipart"] } +biois-macros = { path = "macros" } chrono = { version = "0.4", features = ["serde"] } config = "0.15" futures = "0.3" @@ -69,17 +76,11 @@ ogcapi = { git = "https://github.com/georust/ogcapi", branch = "main", default-f "types", ] } o2o = "0.5" -pretty_assertions = "1.4" -reqwest = { version = "0.13", features = ["json"] } schemars = "1.2" serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" tempfile = "3.27" -# toasty = { version = "0.9", features = ["postgresql", "serde"] } -toasty = { git = "https://github.com/tokio-rs/toasty", branch = "main", features = [ - "postgresql", - "serde", -] } +toasty = { version = "0.10", features = ["postgresql", "serde"] } tokio = { version = "1.49", features = ["rt-multi-thread", "macros"] } tower = "0.5" tracing = "0.1" @@ -93,4 +94,5 @@ wkt = "0.14" [dev-dependencies] httptest = "0.16" -toasty-cli = "0.9" +pretty_assertions = { workspace = true } +toasty-cli = "0.10" diff --git a/backend/macros/Cargo.toml b/backend/macros/Cargo.toml new file mode 100644 index 0000000..faab19a --- /dev/null +++ b/backend/macros/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "biois-macros" +version = "0.1.0" +edition = "2024" +publish = false + +[lib] +proc-macro = true + +[dependencies] +proc-macro2 = "1.0" +quote = "1.0" +syn = { version = "2.0", features = ["full", "parsing", "extra-traits"] } + +[dev-dependencies] +pretty_assertions = { workspace = true } diff --git a/backend/macros/src/lib.rs b/backend/macros/src/lib.rs new file mode 100644 index 0000000..4cfbeb1 --- /dev/null +++ b/backend/macros/src/lib.rs @@ -0,0 +1,25 @@ +use crate::test::TestArgs; +use proc_macro::TokenStream; +use syn::{ItemFn, parse_macro_input}; + +mod test; + +/// Wraps a database-backed async test in the temporary DB helper and, optionally, +/// a task-local context scope. +/// +/// Usage: +/// ```rust,no_run,ignore +/// use crate::{db::DbHandle, state::TaskContext}; +/// +/// #[biois::test(task_context = TaskContext::new(user))] +/// async fn it_tests_something(db: DbHandle) { +/// let _ = db; +/// } +/// ``` +#[proc_macro_attribute] +pub fn test(args: TokenStream, item: TokenStream) -> TokenStream { + let args = parse_macro_input!(args as TestArgs); + let input = parse_macro_input!(item as ItemFn); + let expanded = crate::test::test(args, input); + TokenStream::from(expanded) +} diff --git a/backend/macros/src/test.rs b/backend/macros/src/test.rs new file mode 100644 index 0000000..661c4bb --- /dev/null +++ b/backend/macros/src/test.rs @@ -0,0 +1,170 @@ +use proc_macro2::TokenStream; +use quote::quote; +use syn::{Attribute, Error as SynError, FnArg, ItemFn, parse::Parse}; + +pub fn test(args: TestArgs, input: ItemFn) -> TokenStream { + let fn_attrs = input.attrs.iter().filter(|attr| !is_test_attr(attr)); + let vis = &input.vis; + let asyncness = &input.sig.asyncness; + let ident = &input.sig.ident; + let generics = &input.sig.generics; + let output = &input.sig.output; + let stmts = &input.block.stmts; + + let inputs = &input.sig.inputs; + let mut iter = inputs.iter(); + let first_param = iter.next(); + let trailing_params = iter.collect::>(); + + let first_param = match first_param { + Some(FnArg::Typed(pat_type)) => pat_type, + Some(FnArg::Receiver(_)) => { + return SynError::new_spanned( + ident, + "crate::test does not support methods with `self` receivers", + ) + .to_compile_error(); + } + None => { + return SynError::new_spanned( + ident, + "crate::test requires a first parameter like `db: DbHandle`", + ) + .to_compile_error(); + } + }; + + if !trailing_params.is_empty() { + return SynError::new_spanned( + &first_param.ty, + "crate::test currently supports only a single `db: DbHandle` parameter", + ) + .to_compile_error(); + } + + let db_binding = &first_param.pat; + let task_context = args.task_context.as_ref(); + + let scoped_body = if let Some(task_context) = task_context { + quote! { + crate::state::CONTEXT + .scope(#task_context, async { + #(#stmts)* + }) + .await + } + } else { + quote! { + #(#stmts)* + } + }; + + quote! { + #(#fn_attrs)* + #[tokio::test(flavor = "multi_thread")] + #vis #asyncness fn #ident #generics () #output { + crate::db::tests::with_temp_db(|#db_binding| async move { + #scoped_body + }) + .await; + } + } +} + +pub struct TestArgs { + task_context: Option, +} + +impl Parse for TestArgs { + fn parse(input: syn::parse::ParseStream<'_>) -> syn::Result { + let mut task_context = None; + + while !input.is_empty() { + let name: syn::Ident = input.parse()?; + let _eq: syn::token::Eq = input.parse()?; + let value: syn::Expr = input.parse()?; + + if name == "task_context" { + task_context = Some(value); + } else { + return Err(SynError::new_spanned( + name, + "unsupported attribute argument; expected `task_context = ...`", + )); + } + + if input.is_empty() { + break; + } + let _comma: syn::token::Comma = input.parse()?; + } + + Ok(Self { task_context }) + } +} + +fn is_test_attr(attr: &Attribute) -> bool { + attr.path().is_ident("test") || attr.path().is_ident("tokio") +} + +#[cfg(test)] +mod tests { + use super::*; + use pretty_assertions::assert_eq; + + #[test] + fn it_generates_expected_token_output_for_a_db_test() { + let input: ItemFn = syn::parse_quote! { + async fn it_tests_something(db: crate::db::DbHandle) { + let value = db.schema_name(); + assert!(!value.is_empty()); + } + }; + let args: TestArgs = syn::parse_quote!(task_context = crate::state::TaskContext::new(user)); + + let actual = test(args, input).to_string(); + let expected = quote! { + #[tokio::test(flavor = "multi_thread")] + async fn it_tests_something() { + crate::db::tests::with_temp_db(|db| async move { + crate::state::CONTEXT + .scope(crate::state::TaskContext::new(user), async { + let value = db.schema_name(); + assert!(!value.is_empty()); + }) + .await + }) + .await; + } + } + .to_string(); + + assert_eq!(actual, expected); + } + + #[test] + fn it_generates_expected_token_output_without_task_context() { + let input: ItemFn = syn::parse_quote! { + async fn it_tests_something(db: crate::db::DbHandle) { + let value = db.schema_name(); + assert!(!value.is_empty()); + } + }; + let args: TestArgs = syn::parse_quote!(); + + let actual = test(args, input).to_string(); + let expected = quote! { + #[tokio::test(flavor = "multi_thread")] + async fn it_tests_something() { + crate::db::tests::with_temp_db(|db| async move { + let value = db.schema_name(); + assert!(!value.is_empty()); + }) + .await; + } + } + .to_string(); + + assert_eq!(actual, expected); + } +} diff --git a/backend/src/credits.rs b/backend/src/credits/db.rs similarity index 65% rename from backend/src/credits.rs rename to backend/src/credits/db.rs index 5340d28..9817236 100644 --- a/backend/src/credits.rs +++ b/backend/src/credits/db.rs @@ -4,141 +4,62 @@ use crate::{ DbHandle, model::{ComputationId, Credits, TimestampMillis}, }, - server::AppState, state::{CONTEXT, TaskLocalContext}, }; use anyhow::{Context, Result}; -use axum::{ - Json, - extract::{Path, State}, -}; -use chrono::{TimeZone, Utc}; +use chrono::Utc; use geoengine_api_client::{ apis::{configuration::Configuration, user_api::computation_quota_handler}, models::OperatorQuota, }; -use ogcapi::{ - services::{self as ogcapi_services}, - types::common::Exception, -}; -use serde::{Deserialize, Serialize}; -use std::{collections::HashMap, sync::LazyLock}; +use std::sync::LazyLock; use tokio::{sync::mpsc, time::sleep}; -use tracing::{error, warn}; -use utoipa::{IntoParams, ToSchema}; -use utoipa_axum::{router::OpenApiRouter, routes}; +use tracing::{error, instrument, warn}; use uuid::Uuid; -pub fn router() -> OpenApiRouter { - OpenApiRouter::new().routes(routes!(get_credits)) -} - -/// Returns the user's credits. -#[utoipa::path(get, path = "/{year}/{month}", tag = "User", - params(GetCreditsParams), - responses( - ( - status = OK, - description = "The user's credits for the specified month.", - body = GetCreditsResponse - ), - ( - status = INTERNAL_SERVER_ERROR, - description = "A server error occurred.", - body = Exception, - example = json!(Exception::new_from_status(500)) - ) - ) -)] -pub async fn get_credits( - Path(GetCreditsParams { year, month }): Path, - State(app_state): State, -) -> ogcapi_services::Result> { - let user_id = CONTEXT.user_id()?; - let timestamp_start_inclusive = Utc - .with_ymd_and_hms(i32::from(year), u32::from(month), 1, 0, 0, 0) - .earliest() - .context("invalid start timestamp")?; - let timestamp_end_exclusive: TimestampMillis = timestamp_start_inclusive - .checked_add_months(chrono::Months::new(1)) - .context("invalid end timestamp")? - .into(); - let timestamp_start_inclusive: TimestampMillis = timestamp_start_inclusive.into(); - - let fields = Credits::fields(); - - let credits = Credits::filter( - fields - .timestamp() - .ge(timestamp_start_inclusive) - .and(fields.timestamp().lt(timestamp_end_exclusive)) - .and(fields.job().user_id().eq(user_id)), - ) - .exec(&mut app_state.db.db()) - .await - .context("failed to execute credits query")?; - - let credits_per_job = Vec::::from_credits(&credits); - - Ok(Json(GetCreditsResponse { - year, - month, - credits_used: credits_per_job.sum(), - details: credits_per_job, - })) -} - -#[derive(Debug, Deserialize, IntoParams)] -#[serde(rename_all = "camelCase")] -pub struct GetCreditsParams { - pub year: u16, - pub month: u8, -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct GetCreditsResponse { - pub year: u16, - pub month: u8, - pub credits_used: u32, - pub details: Vec, // Add this field to include the details of credits -} - -#[derive(Debug, Serialize, ToSchema)] -#[serde(rename_all = "camelCase")] -pub struct CreditsForJob { - pub job_id: Uuid, - pub credits_used: u32, -} - -/// A trait to sum credits used for a list of credits. +/// A queue for processing credits lookups in the background. /// -/// TODO: Use `toasty`'s aggregate queries () once available. -trait SumCredits { - fn sum(&self) -> u32; - - fn from_credits(credits_list: &[Credits]) -> Self; -} +/// TODO: Load pending credits lookups from the database on startup and enqueue them for processing. +static CREDITS_LOOKUP_QUEUE: LazyLock> = LazyLock::new(|| { + let (tx, mut rx) = mpsc::unbounded_channel::(); + let reentry_tx = tx.clone(); + tokio::spawn(async move { + let mut pending_lookups = Vec::new(); + loop { + // Process all available items + while let Ok(mut lookup) = rx.try_recv() { + // stop if the queue is empty + match lookup.process().await { + Ok(CreditsLookupStatus::Finished) => { + // Credits lookup finished successfully, nothing to do + } + Ok(CreditsLookupStatus::Pending) => { + // Credits lookup is still pending, requeue the lookup for later processing + pending_lookups.push(lookup); + } + Err(e) => { + error!( + "Failed to process credits lookup for job {job_id} and computation ID {computation_id}: {e:?}", + job_id = &lookup.job_id, + computation_id = &lookup.computation_id + ); + // TODO: Decide whether to requeue the lookup or not. + // For now, we will not requeue it to avoid infinite error loops. + } + } + } -impl SumCredits for Vec { - fn sum(&self) -> u32 { - self.iter().map(|c| c.credits_used).sum() - } + // Move pending lookups back into the queue + for lookup in pending_lookups.drain(..) { + enqueue_credits_lookup_inner(&reentry_tx, lookup); + } - fn from_credits(credits_list: &[Credits]) -> Self { - let mut map: HashMap = HashMap::new(); - for c in credits_list { - let entry = map.entry(c.job_id).or_insert(0); - *entry += c.credits.unwrap_or(0) as u32; + // Queue is empty, wait 1 minute before processing next batch + sleep(CONFIG.credits.credits_lookup_interval()).await; } - map.into_iter() - .map(|(job_id, credits_used)| CreditsForJob { - job_id, - credits_used, - }) - .collect() - } -} + }); + tx +}); /// Add credits used for a specific job to the database. /// @@ -196,47 +117,6 @@ async fn add_credits_opt( Ok(()) } -static CREDITS_LOOKUP_QUEUE: LazyLock> = LazyLock::new(|| { - let (tx, mut rx) = mpsc::unbounded_channel::(); - let reentry_tx = tx.clone(); - tokio::spawn(async move { - let mut pending_lookups = Vec::new(); - loop { - // Process all available items - while let Ok(mut lookup) = rx.try_recv() { - // stop if the queue is empty - match lookup.process().await { - Ok(CreditsLookupStatus::Finished) => { - // Credits lookup finished successfully, nothing to do - } - Ok(CreditsLookupStatus::Pending) => { - // Credits lookup is still pending, requeue the lookup for later processing - pending_lookups.push(lookup); - } - Err(e) => { - error!( - "Failed to process credits lookup for job {job_id} and computation ID {computation_id}: {e:?}", - job_id = &lookup.job_id, - computation_id = &lookup.computation_id - ); - // TODO: Decide whether to requeue the lookup or not. - // For now, we will not requeue it to avoid infinite error loops. - } - } - } - - // Move pending lookups back into the queue - for lookup in pending_lookups.drain(..) { - enqueue_credits_lookup_inner(&reentry_tx, lookup); - } - - // Queue is empty, wait 1 minute before processing next batch - sleep(CONFIG.credits.credits_lookup_interval()).await; - } - }); - tx -}); - fn enqueue_credits_lookup_inner(tx: &mpsc::UnboundedSender, lookup: CreditsLookup) { if let Err(e) = tx.send(lookup) { error!( @@ -247,6 +127,7 @@ fn enqueue_credits_lookup_inner(tx: &mpsc::UnboundedSender, looku } } +#[instrument(skip(configuration, db), level = "debug")] fn enqueue_credits_lookup( configuration: Configuration, db: DbHandle, @@ -261,6 +142,7 @@ fn enqueue_credits_lookup( /// /// Geo Engine batches computation logging, so we need to store the computation ID to be able to look up the credits /// used for a specific job, later. +#[derive(Debug)] struct CreditsLookup { configuration: Configuration, db: DbHandle, @@ -271,6 +153,7 @@ struct CreditsLookup { quotas: Option>, } +#[derive(Debug)] enum CreditsLookupStatus { Pending, Finished, @@ -293,6 +176,7 @@ impl CreditsLookup { } /// Process the credits lookup by querying the Geo Engine API and storing the result in the database. + #[instrument(skip(self), level = "debug", fields(job_id = %self.job_id, computation_id = %self.computation_id), ret)] async fn process(&mut self) -> Result { let quotas = computation_quota_handler(&self.configuration, &self.computation_id.to_string()) @@ -303,8 +187,12 @@ impl CreditsLookup { return Ok(CreditsLookupStatus::Pending); } + // Quotas are available, we can store them in the database + self.update_credits().await?; + let Some(stored_quotas) = &self.quotas else { // We got quotas, but we need to make sure that the result is consistent + // so we store the quotas and wait for the next batch to make sure that the result is consistent self.quotas = Some(quotas); return Ok(CreditsLookupStatus::Pending); }; @@ -315,8 +203,6 @@ impl CreditsLookup { return Ok(CreditsLookupStatus::Pending); } - self.update_credits().await?; - Ok(CreditsLookupStatus::Finished) } diff --git a/backend/src/credits/handler.rs b/backend/src/credits/handler.rs new file mode 100644 index 0000000..664bb47 --- /dev/null +++ b/backend/src/credits/handler.rs @@ -0,0 +1,333 @@ +use crate::{ + db::model::{Credits, TimestampMillis}, + server::AppState, + state::{CONTEXT, TaskLocalContext}, +}; +use anyhow::Context; +use axum::{ + Json, + extract::{Path, State}, +}; +use chrono::{TimeZone, Utc}; +use ogcapi::{ + services::{self as ogcapi_services}, + types::common::Exception, +}; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use utoipa::{IntoParams, ToSchema}; +use utoipa_axum::{router::OpenApiRouter, routes}; +use uuid::Uuid; + +pub fn router() -> OpenApiRouter { + OpenApiRouter::new().routes(routes!(get_credits)) +} + +/// Returns the user's credits. +#[utoipa::path(get, path = "/{year}/{month}", tag = "User", + params(GetCreditsParams), + responses( + ( + status = OK, + description = "The user's credits for the specified month.", + body = GetCreditsResponse + ), + ( + status = INTERNAL_SERVER_ERROR, + description = "A server error occurred.", + body = Exception, + example = json!(Exception::new_from_status(500)) + ) + ) +)] +pub async fn get_credits( + Path(GetCreditsParams { year, month }): Path, + State(app_state): State, +) -> ogcapi_services::Result> { + let user_id = CONTEXT.user_id()?; + let timestamp_start_inclusive = Utc + .with_ymd_and_hms(i32::from(year), u32::from(month), 1, 0, 0, 0) + .earliest() + .context("invalid start timestamp")?; + let timestamp_end_exclusive: TimestampMillis = timestamp_start_inclusive + .checked_add_months(chrono::Months::new(1)) + .context("invalid end timestamp")? + .into(); + let timestamp_start_inclusive: TimestampMillis = timestamp_start_inclusive.into(); + + let fields = Credits::fields(); + + let credits = Credits::filter( + fields + .timestamp() + .ge(timestamp_start_inclusive) + .and(fields.timestamp().lt(timestamp_end_exclusive)) + .and(fields.job().user_id().eq(user_id)), + ) + .exec(&mut app_state.db.db()) + .await + .context("failed to execute credits query")?; + + let credits_per_job = Vec::::from_credits(&credits); + + Ok(Json(GetCreditsResponse { + year, + month, + credits_used: credits_per_job.sum(), + details: credits_per_job, + })) +} + +#[derive(Debug, Deserialize, IntoParams)] +#[serde(rename_all = "camelCase")] +pub struct GetCreditsParams { + pub year: u16, + pub month: u8, +} + +#[derive(Debug, PartialEq, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct GetCreditsResponse { + pub year: u16, + pub month: u8, + pub credits_used: u32, + pub details: Vec, // Add this field to include the details of credits +} + +#[derive(Debug, PartialEq, Serialize, ToSchema)] +#[serde(rename_all = "camelCase")] +pub struct CreditsForJob { + pub job_id: Uuid, + pub credits_used: u32, +} + +/// A trait to sum credits used for a list of credits. +/// +/// TODO: Use `toasty`'s aggregate queries () once available. +trait SumCredits { + fn sum(&self) -> u32; + + fn from_credits(credits_list: &[Credits]) -> Self; +} + +impl SumCredits for Vec { + fn sum(&self) -> u32 { + self.iter().map(|c| c.credits_used).sum() + } + + fn from_credits(credits_list: &[Credits]) -> Self { + // Since UUID v7 is time-ordered, we can use a BTreeMap to aggregate credits by job_id. + let mut map = BTreeMap::::new(); + for c in credits_list { + let entry = map.entry(c.job_id).or_insert(0); + *entry += c.credits.unwrap_or(0) as u32; + } + map.into_iter() + .map(|(job_id, credits_used)| CreditsForJob { + job_id, + credits_used, + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + auth::User, + credits::add_credits_used, + db::model::{ComputationId, JobType, StatusCode}, + jobs::JobHandler, + server::AppState, + state::TaskContext, + util::Secret, + }; + use axum::extract::Path; + use chrono::Datelike; + use ogcapi::{ + drivers::JobHandler as _, + types::processes::{Response as ApiResponse, StatusInfo}, + }; + use pretty_assertions::assert_eq; + + fn mock_user() -> User { + User { + id: Uuid::from_u128(0xabcd_efab_cdef_abcd_efab_cdef_abcd_efab), + session_token: Secret(Uuid::from_u128(0x1234_5678_90ab_cdef_1234_5678_90ab_cdef)), + } + } + + fn year_and_month() -> (u16, u8) { + let current_date = Utc::now(); + (current_date.year() as u16, current_date.month() as u8) + } + + #[crate::test(task_context = TaskContext::new(mock_user()))] + async fn it_returns_empty_list_when_no_credits_for_month(db: DbHandle) { + let app_state = AppState { + db, + api_config: crate::CONFIG.geoengine.api_config(None), + }; + + let Json(result) = get_credits( + Path(GetCreditsParams { + year: 2024, + month: 1, + }), + axum::extract::State(app_state), + ) + .await + .unwrap(); + + assert_eq!( + result, + GetCreditsResponse { + year: 2024, + month: 1, + credits_used: 0, + details: vec![], + } + ); + } + + #[crate::test(task_context = TaskContext::new(mock_user()))] + async fn it_returns_one_credit_per_job_with_different_computation_ids(db: DbHandle) { + let app_state = AppState { + db: db.clone(), + api_config: crate::CONFIG.geoengine.api_config(None), + }; + + let job_handler = JobHandler::new(db.clone()).await.unwrap(); + + let job_id_1 = job_handler + .register( + &StatusInfo { + r#type: JobType::Process.into(), + job_id: String::new(), + status: StatusCode::Successful.into(), + ..Default::default() + }, + ApiResponse::Document, + ) + .await + .unwrap() + .parse() + .unwrap(); + + add_credits_used( + db.clone(), + ComputationId::some(Uuid::from_u128(0x1111_1111_1111_1111_1111_1111_1111_1111)), + 100, + ) + .await + .unwrap(); + + let job_id_2 = job_handler + .register( + &StatusInfo { + r#type: JobType::Process.into(), + job_id: String::new(), + status: StatusCode::Successful.into(), + ..Default::default() + }, + ApiResponse::Document, + ) + .await + .unwrap() + .parse() + .unwrap(); + + add_credits_used(db.clone(), ComputationId::none(), 50) + .await + .unwrap(); + + let (year, month) = year_and_month(); + let Json(result) = get_credits( + Path(GetCreditsParams { year, month }), + axum::extract::State(app_state), + ) + .await + .unwrap(); + + assert_eq!( + result, + GetCreditsResponse { + year, + month, + credits_used: 150, + details: vec![ + CreditsForJob { + job_id: job_id_1, + credits_used: 100 + }, + CreditsForJob { + job_id: job_id_2, + credits_used: 50 + } + ], + } + ); + } + + #[crate::test(task_context = TaskContext::new(User { + id: Uuid::new_v4(), + session_token: Secret(Uuid::new_v4()), + }))] + async fn it_aggregates_multiple_credits_for_one_job(db: DbHandle) { + let app_state = AppState { + db: db.clone(), + api_config: crate::CONFIG.geoengine.api_config(None), + }; + + let job_handler = JobHandler::new(db.clone()).await.unwrap(); + + let job_id_1 = job_handler + .register( + &StatusInfo { + r#type: JobType::Process.into(), + job_id: String::new(), + status: StatusCode::Successful.into(), + ..Default::default() + }, + ApiResponse::Document, + ) + .await + .unwrap() + .parse() + .unwrap(); + + let computation_id_1 = + ComputationId::some(Uuid::from_u128(0x1111_1111_1111_1111_1111_1111_1111_1111)); + add_credits_used(db.clone(), computation_id_1, 100) + .await + .unwrap(); + + let computation_id_2 = + ComputationId::some(Uuid::from_u128(0x2222_2222_2222_2222_2222_2222_2222_2222)); + add_credits_used(db.clone(), computation_id_2, 75) + .await + .unwrap(); + + let (year, month) = year_and_month(); + let Json(result) = get_credits( + Path(GetCreditsParams { year, month }), + axum::extract::State(app_state), + ) + .await + .unwrap(); + + assert_eq!( + result, + GetCreditsResponse { + year, + month, + credits_used: 175, + details: vec![CreditsForJob { + job_id: job_id_1, + credits_used: 175 + }], + } + ); + } +} diff --git a/backend/src/credits/mod.rs b/backend/src/credits/mod.rs new file mode 100644 index 0000000..cd9f019 --- /dev/null +++ b/backend/src/credits/mod.rs @@ -0,0 +1,5 @@ +mod db; +mod handler; + +pub use db::{add_credits_pending, add_credits_used}; +pub use handler::router; diff --git a/backend/src/db/model.rs b/backend/src/db/model.rs index 6a707d4..2a20dcd 100644 --- a/backend/src/db/model.rs +++ b/backend/src/db/model.rs @@ -1,6 +1,6 @@ #![allow( clippy::used_underscore_binding, - reason = "For embedded NewTypes, toasty accesses the inner value via `_0`" + reason = "For embedded NewTypes, toasty accesses the inner value via `_0`, cf. " )] use anyhow::Context; @@ -113,14 +113,6 @@ pub struct Job { pub credits: toasty::Deferred>, } -#[allow( - clippy::used_underscore_items, - reason = "For embedded NewTypes, toasty accesses the inner value via `_0`" -)] -pub fn job_updated_field() -> toasty::stmt::Path { - Job::fields().updated()._0() -} - /// A timestamp stored as a Unix timestamp in milliseconds, used for database storage. /// /// TODO: Remove once we can use `jiff` or `DateTime` in `toasty` models diff --git a/backend/src/handler.rs b/backend/src/handler.rs index 175549c..340c4f8 100644 --- a/backend/src/handler.rs +++ b/backend/src/handler.rs @@ -115,14 +115,12 @@ mod tests { use super::*; use crate::auth::AuthCodeResponse; use crate::config::GeoEngineInstance; - use crate::db::tests::with_temp_db; use axum::extract::{Query, State}; use axum::routing::get; use axum::{Json, Router}; use axum::{body::Body, http::Request}; use httptest::matchers::request::method; use httptest::{Expectation, Server, responders::json_encoded}; - use reqwest::StatusCode; use serde_json::json; use tower::ServiceExt; use url::Url; @@ -139,10 +137,8 @@ mod tests { assert_eq!(response.status(), StatusCode::NO_CONTENT); } - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] - async fn it_retrieves_user_session_from_auth_handler() { - with_temp_db(async move |db| { - + #[crate::test] + async fn it_retrieves_user_session_from_auth_handler(db: DbHandle) { // start mock server let server = Server::run(); @@ -184,7 +180,5 @@ mod tests { .await; assert!(res.is_ok(), "expected Ok(UserSession) from auth_handler"); - - }).await; } } diff --git a/backend/src/jobs.rs b/backend/src/jobs.rs index cef39ce..12cbfd5 100644 --- a/backend/src/jobs.rs +++ b/backend/src/jobs.rs @@ -1,7 +1,7 @@ use crate::{ db::{ DbHandle, - model::{Job, JobType, Link, Response, StatusCode, TimestampMillis, job_updated_field}, + model::{Job, JobType, Link, Response, StatusCode, TimestampMillis}, }, state::{CONTEXT, TaskLocalContext}, }; @@ -121,7 +121,7 @@ impl ogcapi::drivers::JobHandler for JobHandler { let user_id = CONTEXT.user_id()?; let results: Vec = Job::filter(Job::fields().user_id().eq(user_id)) - .order_by(job_updated_field().desc()) + .order_by(Job::fields().updated().desc()) .limit(limit) .offset(offset) .exec(&mut self.db.db()) @@ -269,7 +269,7 @@ impl TryInto for Job { #[cfg(test)] mod tests { use super::*; - use crate::{auth::User, db::tests::with_temp_db, state::TaskContext}; + use crate::{auth::User, state::TaskContext}; use ogcapi::drivers::JobHandler as _; fn mock_user() -> User { @@ -294,153 +294,104 @@ mod tests { } } - #[tokio::test(flavor = "multi_thread")] - async fn it_registers_jobs() { - with_temp_db(|db_pool| async move { - let handler = JobHandler::new(db_pool).await.unwrap(); - CONTEXT - .scope(TaskContext::new(mock_user()), async move { - let status_info = mock_status_info(); - let result = handler.register(&status_info, ApiResponse::Raw).await; - assert!(result.is_ok()); - let job_id = result.unwrap(); - assert!(!job_id.is_empty()); - }) - .await; - }) - .await; + #[crate::test(task_context = TaskContext::new(mock_user()))] + async fn it_registers_jobs(db: DbHandle) { + let handler = JobHandler::new(db).await.unwrap(); + let status_info = mock_status_info(); + let result = handler.register(&status_info, ApiResponse::Raw).await; + assert!(result.is_ok()); + let job_id = result.unwrap(); + assert!(!job_id.is_empty()); } - #[tokio::test(flavor = "multi_thread")] - async fn test_update() { - with_temp_db(|db_pool| async move { - let handler = JobHandler::new(db_pool).await.unwrap(); - CONTEXT - .scope(TaskContext::new(mock_user()), async move { - let status_info = mock_status_info(); - // Register first - let job_id = handler - .register(&status_info, ApiResponse::Raw) - .await - .unwrap(); - // Update - let mut updated_info = status_info.clone(); - updated_info.job_id = job_id; - updated_info.status = ApiStatusCode::Running; - let result = handler.update(&updated_info).await; - assert!(result.is_ok()); - }) - .await; - }) - .await; + #[crate::test(task_context = TaskContext::new(mock_user()))] + async fn test_update(db: DbHandle) { + let handler = JobHandler::new(db).await.unwrap(); + let status_info = mock_status_info(); + // Register first + let job_id = handler + .register(&status_info, ApiResponse::Raw) + .await + .unwrap(); + // Update + let mut updated_info = status_info.clone(); + updated_info.job_id = job_id; + updated_info.status = ApiStatusCode::Running; + let result = handler.update(&updated_info).await; + assert!(result.is_ok()); } - #[tokio::test(flavor = "multi_thread")] - async fn test_status_list() { - with_temp_db(|db_pool| async move { - let handler = JobHandler::new(db_pool).await.unwrap(); - CONTEXT - .scope(TaskContext::new(mock_user()), async move { - // Register a job - let status_info = mock_status_info(); - handler - .register(&status_info, ApiResponse::Raw) - .await - .unwrap(); - // List - let result = handler.status_list(0, 10).await; - assert!(result.is_ok()); - let list = result.unwrap(); - assert!(!list.is_empty()); - }) - .await; - }) - .await; + #[crate::test(task_context = TaskContext::new(mock_user()))] + async fn test_status_list(db: DbHandle) { + let handler = JobHandler::new(db).await.unwrap(); + // Register a job + let status_info = mock_status_info(); + handler + .register(&status_info, ApiResponse::Raw) + .await + .unwrap(); + // List + let result = handler.status_list(0, 10).await; + assert!(result.is_ok()); + let list = result.unwrap(); + assert!(!list.is_empty()); } - #[tokio::test(flavor = "multi_thread")] - async fn test_status() { - with_temp_db(|db_pool| async move { - let handler = JobHandler::new(db_pool).await.unwrap(); - CONTEXT - .scope(TaskContext::new(mock_user()), async move { - let status_info = mock_status_info(); - let job_id = handler - .register(&status_info, ApiResponse::Raw) - .await - .unwrap(); - let result = handler.status(&job_id).await; - assert!(result.is_ok()); - let status = result.unwrap(); - assert!(status.is_some()); - assert_eq!(status.unwrap().job_id, job_id); - }) - .await; - }) - .await; + #[crate::test(task_context = TaskContext::new(mock_user()))] + async fn test_status(db: DbHandle) { + let handler = JobHandler::new(db).await.unwrap(); + let status_info = mock_status_info(); + let job_id = handler + .register(&status_info, ApiResponse::Raw) + .await + .unwrap(); + let result = handler.status(&job_id).await; + assert!(result.is_ok()); + let status = result.unwrap(); + assert!(status.is_some()); + assert_eq!(status.unwrap().job_id, job_id); } - #[tokio::test(flavor = "multi_thread")] - async fn test_finish() { - with_temp_db(|db_pool| async move { - let handler = JobHandler::new(db_pool).await.unwrap(); - CONTEXT - .scope(TaskContext::new(mock_user()), async move { - let status_info = mock_status_info(); - let job_id = handler - .register(&status_info, ApiResponse::Raw) - .await - .unwrap(); - let result = handler - .finish( - &job_id, - &ApiStatusCode::Successful, - Some("done".to_string()), - vec![], - None, - ) - .await; - assert!(result.is_ok()); - }) - .await; - }) - .await; + #[crate::test(task_context = TaskContext::new(mock_user()))] + async fn test_finish(db: DbHandle) { + let handler = JobHandler::new(db).await.unwrap(); + let status_info = mock_status_info(); + let job_id = handler + .register(&status_info, ApiResponse::Raw) + .await + .unwrap(); + let result = handler + .finish( + &job_id, + &ApiStatusCode::Successful, + Some("done".to_string()), + vec![], + None, + ) + .await; + assert!(result.is_ok()); } - #[tokio::test(flavor = "multi_thread")] - async fn test_dismiss() { - with_temp_db(|db_pool| async move { - let handler = JobHandler::new(db_pool).await.unwrap(); - CONTEXT - .scope(TaskContext::new(mock_user()), async move { - let status_info = mock_status_info(); - let job_id = handler - .register(&status_info, ApiResponse::Raw) - .await - .unwrap(); - let result = handler.dismiss(&job_id).await; - assert!(result.is_ok()); - let dismissed = result.unwrap(); - assert!(dismissed.is_some()); - assert_eq!(dismissed.unwrap().status, ApiStatusCode::Dismissed); - }) - .await; - }) - .await; + #[crate::test(task_context = TaskContext::new(mock_user()))] + async fn test_dismiss(db: DbHandle) { + let handler = JobHandler::new(db).await.unwrap(); + let status_info = mock_status_info(); + let job_id = handler + .register(&status_info, ApiResponse::Raw) + .await + .unwrap(); + let result = handler.dismiss(&job_id).await; + assert!(result.is_ok()); + let dismissed = result.unwrap(); + assert!(dismissed.is_some()); + assert_eq!(dismissed.unwrap().status, ApiStatusCode::Dismissed); } - #[tokio::test(flavor = "multi_thread")] - async fn test_results_no_job() { - with_temp_db(|db_pool| async move { - let handler = JobHandler::new(db_pool).await.unwrap(); - CONTEXT - .scope(TaskContext::new(mock_user()), async move { - let non_existent_id = "00000000-0000-0000-0000-000000000000"; - let result = handler.results(non_existent_id).await; - assert!(matches!(result.unwrap(), ProcessResult::NoSuchJob)); - }) - .await; - }) - .await; + #[crate::test(task_context = TaskContext::new(mock_user()))] + async fn test_results_no_job(db: DbHandle) { + let handler = JobHandler::new(db).await.unwrap(); + let non_existent_id = "00000000-0000-0000-0000-000000000000"; + let result = handler.results(non_existent_id).await; + assert!(matches!(result.unwrap(), ProcessResult::NoSuchJob)); } } diff --git a/backend/src/lib.rs b/backend/src/lib.rs index b826fca..2096f4e 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -10,6 +10,7 @@ mod server; mod state; mod util; +pub use biois_macros::test; pub use config::CONFIG; pub use server::server; pub use util::setup_tracing; diff --git a/backend/src/processes/biodiversity_sensitive_areas/mod.rs b/backend/src/processes/biodiversity_sensitive_areas/mod.rs index b5e0070..5afc09d 100644 --- a/backend/src/processes/biodiversity_sensitive_areas/mod.rs +++ b/backend/src/processes/biodiversity_sensitive_areas/mod.rs @@ -953,10 +953,7 @@ fn feature_to_site_input( #[cfg(test)] mod tests { use super::*; - use crate::{ - db::tests::with_temp_db, - processes::parameters::{GeoJsonInputMediaType, Hectare}, - }; + use crate::processes::parameters::{GeoJsonInputMediaType, Hectare}; use approx::abs_diff_ne; use geojson::FeatureCollection; use ogcapi::types::processes::Input; @@ -1068,224 +1065,213 @@ mod tests { serde_json::from_value(json).unwrap(); } - #[tokio::test(flavor = "multi_thread")] - async fn process_summary_has_expected_inputs_and_outputs() { - with_temp_db(|mut db| async move { - create_schema_and_insert_test_site(&mut db).await; - - let schema = db.schema_name().to_string(); - let p = BiodiversitySensitiveAreasProcess::new(db, schema.leak()) - .await - .unwrap(); - let process = p.process().expect("to produce process description"); - - // summary id / version - assert_eq!(process.summary.id, "biodiversity-sensitive-areas"); - assert_eq!(process.summary.version, "0.1.0"); - - // job control options contain sync and async execute - let mut has_sync = false; - let mut has_async = false; - for opt in &process.summary.job_control_options { - match opt { - JobControlOptions::SyncExecute => has_sync = true, - JobControlOptions::AsyncExecute => has_async = true, - JobControlOptions::Dismiss => todo!(), - } - } - assert!(has_sync, "expected SyncExecute in job_control_options"); - assert!(has_async, "expected AsyncExecute in job_control_options"); + #[crate::test] + async fn process_summary_has_expected_inputs_and_outputs(mut db: DbHandle) { + create_schema_and_insert_test_site(&mut db).await; - for key in [ - input_keys::SITES, - input_keys::LOCATION_NAME_FIELD, - input_keys::SITE_TYPE_FIELD, - input_keys::UNIT_FOR_AREA, - ] { - assert!( - process.inputs.contains_key(key), - "expected input key `{key}` in process inputs" - ); + let schema = db.schema_name().to_string(); + let p = BiodiversitySensitiveAreasProcess::new(db, schema.leak()) + .await + .unwrap(); + let process = p.process().expect("to produce process description"); + + // summary id / version + assert_eq!(process.summary.id, "biodiversity-sensitive-areas"); + assert_eq!(process.summary.version, "0.1.0"); + + // job control options contain sync and async execute + let mut has_sync = false; + let mut has_async = false; + for opt in &process.summary.job_control_options { + match opt { + JobControlOptions::SyncExecute => has_sync = true, + JobControlOptions::AsyncExecute => has_async = true, + JobControlOptions::Dismiss => todo!(), } + } + assert!(has_sync, "expected SyncExecute in job_control_options"); + assert!(has_async, "expected AsyncExecute in job_control_options"); + + for key in [ + input_keys::SITES, + input_keys::LOCATION_NAME_FIELD, + input_keys::SITE_TYPE_FIELD, + input_keys::UNIT_FOR_AREA, + ] { + assert!( + process.inputs.contains_key(key), + "expected input key `{key}` in process inputs" + ); + } - for key in [ - output_keys::BIODIVERSITY_SENSITIVE_AREAS, - output_keys::INPUTS, - output_keys::ERRORS, - output_keys::DOCUMENTATION_SOURCES, - ] { - assert!( - process.outputs.contains_key(key), - "expected output key `{key}` in process outputs" - ); - } - }) - .await; + for key in [ + output_keys::BIODIVERSITY_SENSITIVE_AREAS, + output_keys::INPUTS, + output_keys::ERRORS, + output_keys::DOCUMENTATION_SOURCES, + ] { + assert!( + process.outputs.contains_key(key), + "expected output key `{key}` in process outputs" + ); + } } - #[tokio::test(flavor = "multi_thread")] + #[crate::test] #[allow( clippy::too_many_lines, reason = "This test is verbose due to the detailed assertions on the process outputs." )] - async fn it_computes_biodiversity_sensitive_areas() { - with_temp_db(|mut db| async move { - create_schema_and_insert_test_site(&mut db).await; - - // crate::util::setup_tracing( - // level: crate::config::LogLevel::Debug, - // } - // .into(), - // ); - - let inputs = BiodiversitySensitiveAreasProcessInputs { - sites: FeatureCollectionGeoJsonInput { - value: json!({ - "type": "FeatureCollection", - "features": [ - { - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [8.773_665_480_497_84, 50.803_270_291_022_386], - [8.773_649_409_958_182, 50.802_437_463_615_604], - [8.774_613_642_351_255, 50.802_412_072_303_04], - [8.774_597_571_811_597, 50.803_255_056_507_936], - [8.773_665_480_497_84, 50.803_270_291_022_386] - ] - ] - }, - "properties": { - "location": "Marburger Unistadion", - "siteType": "office" - } - }, - { - "type": "Feature", - "geometry": { - "type": "Point", - "coordinates": [8.770_273_718_309_227, 50.807_468_318_244_67] - }, - "properties": { - "location": "Garten des Gedenkens", - "siteType": "other" - } - }, - { - "type": "Feature", - "geometry": { - "type": "Polygon", - "coordinates": [ - [ - [8.754_139_485_384, 50.809_101_655_468], - [8.754_266_459_025, 50.808_497_270_648], - [8.755_374_371_521, 50.809_035_957_506], - [8.754_139_485_384, 50.809_101_655_468] - ] + async fn it_computes_biodiversity_sensitive_areas(mut db: DbHandle) { + create_schema_and_insert_test_site(&mut db).await; + + // crate::util::setup_tracing( + // level: crate::config::LogLevel::Debug, + // } + // .into(), + // ); + + let inputs = BiodiversitySensitiveAreasProcessInputs { + sites: FeatureCollectionGeoJsonInput { + value: json!({ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [8.773_665_480_497_84, 50.803_270_291_022_386], + [8.773_649_409_958_182, 50.802_437_463_615_604], + [8.774_613_642_351_255, 50.802_412_072_303_04], + [8.774_597_571_811_597, 50.803_255_056_507_936], + [8.773_665_480_497_84, 50.803_270_291_022_386] ] - }, - "properties": { - "location": "Auf dem Dammelsberg", - "siteType": "office" - } - }, - { - "type": "Feature", - "geometry": { - "type": "Point", - "coordinates": [8.770_273_718_309_227, 50.807_468_318_244_67] - }, - "id": "the-error-feature", - "properties": { - "siteType": "other" - } - } - ] - }) - .to_string() - .as_str() - .parse::() - .unwrap() - .into(), - media_type: GeoJsonInputMediaType::GeoJson, - }, - location_name_field: "location".into(), - site_type_field: "siteType".into(), - unit_for_area: UnitForArea::Hectare, - }; - - let schema = db.schema_name().to_string(); - let (site_rows, errors) = compute_biodiversity_sensitive_areas( - &mut db, - &schema, - inputs.sites, - inputs.location_name_field.as_ref(), - inputs.site_type_field.as_ref(), - inputs.unit_for_area, - ) - .await - .unwrap(); + ] + }, + "properties": { + "location": "Marburger Unistadion", + "siteType": "office" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [8.770_273_718_309_227, 50.807_468_318_244_67] + }, + "properties": { + "location": "Garten des Gedenkens", + "siteType": "other" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Polygon", + "coordinates": [ + [ + [8.754_139_485_384, 50.809_101_655_468], + [8.754_266_459_025, 50.808_497_270_648], + [8.755_374_371_521, 50.809_035_957_506], + [8.754_139_485_384, 50.809_101_655_468] + ] + ] + }, + "properties": { + "location": "Auf dem Dammelsberg", + "siteType": "office" + } + }, + { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": [8.770_273_718_309_227, 50.807_468_318_244_67] + }, + "id": "the-error-feature", + "properties": { + "siteType": "other" + } + } + ] + }) + .to_string() + .as_str() + .parse::() + .unwrap() + .into(), + media_type: GeoJsonInputMediaType::GeoJson, + }, + location_name_field: "location".into(), + site_type_field: "siteType".into(), + unit_for_area: UnitForArea::Hectare, + }; - let site_row_outputs = site_row_into_output(site_rows, inputs.unit_for_area); - - let expected = vec![ - SiteRowOutput { - location: "Garten des Gedenkens".into(), - area: None, - site_in_biodiversity_sensitive_area: false, - site_near_biodiversity_sensitive_area: true, - biodiversity_sensitive_area: Area::Hectare(Hectare(36.307_653_383_984_075)), - specification: "Type \"Other\" with buffer distance of 20 km".into(), - intersecting_biodiversity_sensitive_areas: vec![], - nearby_biodiversity_sensitive_areas: vec![ - "Dammelsberg und Köhlersgrund (DE5118301)".into(), - "Fohnbach und Gleibach (DE5317307)".into(), - ], - }, - SiteRowOutput { - location: "Auf dem Dammelsberg".into(), - area: Some(Area::Hectare(Hectare(0.289_339_552_615_731_47))), - site_in_biodiversity_sensitive_area: true, - site_near_biodiversity_sensitive_area: true, - biodiversity_sensitive_area: Area::Hectare(Hectare(21.794_987_588_368_837)), - specification: "Type \"Office\" (was point) with buffer distance of 5 km" - .into(), - intersecting_biodiversity_sensitive_areas: vec![ - "Dammelsberg und Köhlersgrund (DE5118301)".into(), - ], - nearby_biodiversity_sensitive_areas: vec![ - "Dammelsberg und Köhlersgrund (DE5118301)".into(), - ], - }, - SiteRowOutput { - location: "Marburger Unistadion".into(), - area: Some(Area::Hectare(Hectare(0.623_035_886_691_041_7))), - site_in_biodiversity_sensitive_area: false, - site_near_biodiversity_sensitive_area: true, - biodiversity_sensitive_area: Area::Hectare(Hectare(21.794_987_588_368_837)), - specification: "Type \"Office\" (was point) with buffer distance of 5 km" - .into(), - intersecting_biodiversity_sensitive_areas: vec![], - nearby_biodiversity_sensitive_areas: vec![ - "Dammelsberg und Köhlersgrund (DE5118301)".into(), - ], - }, - ]; + let schema = db.schema_name().to_string(); + let (site_rows, errors) = compute_biodiversity_sensitive_areas( + &mut db, + &schema, + inputs.sites, + inputs.location_name_field.as_ref(), + inputs.site_type_field.as_ref(), + inputs.unit_for_area, + ) + .await + .unwrap(); - if abs_diff_ne!(site_row_outputs.data, expected, epsilon = 1e-6,) { - assert_eq!(site_row_outputs.data, expected); // pretty assertions - } + let site_row_outputs = site_row_into_output(site_rows, inputs.unit_for_area); + + let expected = vec![ + SiteRowOutput { + location: "Garten des Gedenkens".into(), + area: None, + site_in_biodiversity_sensitive_area: false, + site_near_biodiversity_sensitive_area: true, + biodiversity_sensitive_area: Area::Hectare(Hectare(36.307_653_383_984_075)), + specification: "Type \"Other\" with buffer distance of 20 km".into(), + intersecting_biodiversity_sensitive_areas: vec![], + nearby_biodiversity_sensitive_areas: vec![ + "Dammelsberg und Köhlersgrund (DE5118301)".into(), + "Fohnbach und Gleibach (DE5317307)".into(), + ], + }, + SiteRowOutput { + location: "Auf dem Dammelsberg".into(), + area: Some(Area::Hectare(Hectare(0.289_339_552_615_731_47))), + site_in_biodiversity_sensitive_area: true, + site_near_biodiversity_sensitive_area: true, + biodiversity_sensitive_area: Area::Hectare(Hectare(21.794_987_588_368_837)), + specification: "Type \"Office\" (was point) with buffer distance of 5 km".into(), + intersecting_biodiversity_sensitive_areas: vec![ + "Dammelsberg und Köhlersgrund (DE5118301)".into(), + ], + nearby_biodiversity_sensitive_areas: vec![ + "Dammelsberg und Köhlersgrund (DE5118301)".into(), + ], + }, + SiteRowOutput { + location: "Marburger Unistadion".into(), + area: Some(Area::Hectare(Hectare(0.623_035_886_691_041_7))), + site_in_biodiversity_sensitive_area: false, + site_near_biodiversity_sensitive_area: true, + biodiversity_sensitive_area: Area::Hectare(Hectare(21.794_987_588_368_837)), + specification: "Type \"Office\" (was point) with buffer distance of 5 km".into(), + intersecting_biodiversity_sensitive_areas: vec![], + nearby_biodiversity_sensitive_areas: vec![ + "Dammelsberg und Köhlersgrund (DE5118301)".into(), + ], + }, + ]; - assert_eq!( - errors, - vec![ - "Feature `the-error-feature` is missing location property `location`" - .to_string(), - ] - ); - }) - .await; + if abs_diff_ne!(site_row_outputs.data, expected, epsilon = 1e-6,) { + assert_eq!(site_row_outputs.data, expected); // pretty assertions + } + + assert_eq!( + errors, + vec!["Feature `the-error-feature` is missing location property `location`".to_string(),] + ); } } diff --git a/backend/src/processes/habitat_distance.rs b/backend/src/processes/habitat_distance.rs index f6eb97e..4243180 100644 --- a/backend/src/processes/habitat_distance.rs +++ b/backend/src/processes/habitat_distance.rs @@ -329,7 +329,6 @@ async fn compute_habitat_distance( #[cfg(test)] mod tests { use super::*; - use crate::db::tests::with_temp_db; use ogcapi::types::processes::Input; async fn create_schema_and_insert_test_site(db: &mut DbHandle) { @@ -380,71 +379,64 @@ mod tests { let _inputs: HabitatDistanceProcessInputs = serde_json::from_value(json).unwrap(); } - #[tokio::test(flavor = "multi_thread")] - async fn it_computes_the_nearest_habitat() { + #[crate::test] + async fn it_computes_the_nearest_habitat(mut db: DbHandle) { // crate::util::setup_tracing_for_tests(); - with_temp_db(|mut db| async move { - // create schema / table and insert a test site - create_schema_and_insert_test_site(&mut db).await; - - // compute the habitat distance - let schema = db.schema_name().to_string(); - let outputs = - compute_habitat_distance(&mut db, &schema, &PointType::from((8.46, 50.49))) - .await - .unwrap(); - - assert_eq!(outputs.habitat_code.unwrap(), "DE5417402"); - assert_eq!( - outputs.habitat_name.unwrap(), - "Feldflur bei Hüttenberg und Schöffengrund" - ); - // distance should be very small (point exactly matches) - assert_eq!(outputs.distance_m.unwrap(), 1415); - }) - .await; + // create schema / table and insert a test site + create_schema_and_insert_test_site(&mut db).await; + + // compute the habitat distance + let schema = db.schema_name().to_string(); + let outputs = compute_habitat_distance(&mut db, &schema, &PointType::from((8.46, 50.49))) + .await + .unwrap(); + + assert_eq!(outputs.habitat_code.unwrap(), "DE5417402"); + assert_eq!( + outputs.habitat_name.unwrap(), + "Feldflur bei Hüttenberg und Schöffengrund" + ); + // distance should be very small (point exactly matches) + assert_eq!(outputs.distance_m.unwrap(), 1415); } - #[tokio::test(flavor = "multi_thread")] - async fn process_summary_has_expected_inputs_and_outputs() { - with_temp_db(|mut db| async move { - create_schema_and_insert_test_site(&mut db).await; - - let schema = db.schema_name().to_string(); - let p = HabitatDistanceProcess::new(db, schema.leak()) - .await - .unwrap(); - let process = p.process().expect("to produce process description"); - - // summary id / version - assert_eq!(process.summary.id, "habitatDistance"); - assert_eq!(process.summary.version, "0.1.0"); - - // job control options contain sync and async execute - let mut has_sync = false; - let mut has_async = false; - for opt in &process.summary.job_control_options { - match opt { - JobControlOptions::SyncExecute => has_sync = true, - JobControlOptions::AsyncExecute => has_async = true, - JobControlOptions::Dismiss => todo!(), - } + #[crate::test] + async fn process_summary_has_expected_inputs_and_outputs(mut db: DbHandle) { + create_schema_and_insert_test_site(&mut db).await; + + let schema = db.schema_name().to_string(); + let p = HabitatDistanceProcess::new(db, schema.leak()) + .await + .unwrap(); + let process = p.process().expect("to produce process description"); + + // summary id / version + assert_eq!(process.summary.id, "habitatDistance"); + assert_eq!(process.summary.version, "0.1.0"); + + // job control options contain sync and async execute + let mut has_sync = false; + let mut has_async = false; + for opt in &process.summary.job_control_options { + match opt { + JobControlOptions::SyncExecute => has_sync = true, + JobControlOptions::AsyncExecute => has_async = true, + JobControlOptions::Dismiss => todo!(), } - assert!(has_sync, "expected SyncExecute in job_control_options"); - assert!(has_async, "expected AsyncExecute in job_control_options"); + } + assert!(has_sync, "expected SyncExecute in job_control_options"); + assert!(has_async, "expected AsyncExecute in job_control_options"); - // inputs contain only coordinate - assert!(process.inputs.contains_key("coordinate")); + // inputs contain only coordinate + assert!(process.inputs.contains_key("coordinate")); - // outputs contain habitatCode, habitatName and habitatDistance - assert!(process.outputs.contains_key("habitatCode")); - assert!(process.outputs.contains_key("habitatName")); - assert!(process.outputs.contains_key("habitatDistance")); + // outputs contain habitatCode, habitatName and habitatDistance + assert!(process.outputs.contains_key("habitatCode")); + assert!(process.outputs.contains_key("habitatName")); + assert!(process.outputs.contains_key("habitatDistance")); - // some basic checks for descriptions and schema presence - let habitat_distance_output = &process.outputs["habitatDistance"]; - assert!(habitat_distance_output.schema.is_object()); - }) - .await; + // some basic checks for descriptions and schema presence + let habitat_distance_output = &process.outputs["habitatDistance"]; + assert!(habitat_distance_output.schema.is_object()); } } diff --git a/backend/src/processes/land_use_sealed_area/mod.rs b/backend/src/processes/land_use_sealed_area/mod.rs index f24bf38..0db6352 100644 --- a/backend/src/processes/land_use_sealed_area/mod.rs +++ b/backend/src/processes/land_use_sealed_area/mod.rs @@ -524,10 +524,7 @@ impl From for ExecuteResults { #[cfg(test)] mod tests { use super::*; - use crate::{ - auth::User, db::tests::with_temp_db, processes::parameters::GeoJsonInputMediaType, - state::TaskContext, - }; + use crate::{auth::User, processes::parameters::GeoJsonInputMediaType, state::TaskContext}; use geoengine_api_client::models::{ BoundingBox2D, CollectionType, Coordinate2D, DataId, DatasetNameResponse, FeatureDataType, GeoJson, IdResponse, InternalDataId, Measurement, OperatorQuota, Provenance, @@ -664,59 +661,56 @@ mod tests { assert!(deserialized.previous_year_data.is_some()); } - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] - async fn process_summary_has_expected_inputs_and_outputs() { - with_temp_db(async move |db| { - let process = LandUseSealedAreaProcess::new(db) - .process(None) - .await - .expect("to produce process description"); - - // summary id / version - assert_eq!(process.summary.id, "land-use-sealed-area"); - assert_eq!(process.summary.version, "0.1.0"); - - // job control options contain sync and async execute - let mut has_sync = false; - let mut has_async = false; - for opt in &process.summary.job_control_options { - match opt { - JobControlOptions::SyncExecute => has_sync = true, - JobControlOptions::AsyncExecute => has_async = true, - JobControlOptions::Dismiss => {} - } - } - assert!(has_sync, "expected SyncExecute in job_control_options"); - assert!(has_async, "expected AsyncExecute in job_control_options"); - - // Check required inputs - for key in [ - input_keys::SITES, - input_keys::LOCATION_NAME_FIELD, - input_keys::SITE_TYPE_FIELD, - input_keys::UNIT_FOR_AREA, - ] { - assert!( - process.inputs.contains_key(key), - "expected input key `{key}` in process inputs" - ); + #[crate::test] + async fn process_summary_has_expected_inputs_and_outputs(db: DbHandle) { + let process = LandUseSealedAreaProcess::new(db) + .process(None) + .await + .expect("to produce process description"); + + // summary id / version + assert_eq!(process.summary.id, "land-use-sealed-area"); + assert_eq!(process.summary.version, "0.1.0"); + + // job control options contain sync and async execute + let mut has_sync = false; + let mut has_async = false; + for opt in &process.summary.job_control_options { + match opt { + JobControlOptions::SyncExecute => has_sync = true, + JobControlOptions::AsyncExecute => has_async = true, + JobControlOptions::Dismiss => {} } + } + assert!(has_sync, "expected SyncExecute in job_control_options"); + assert!(has_async, "expected AsyncExecute in job_control_options"); + + // Check required inputs + for key in [ + input_keys::SITES, + input_keys::LOCATION_NAME_FIELD, + input_keys::SITE_TYPE_FIELD, + input_keys::UNIT_FOR_AREA, + ] { + assert!( + process.inputs.contains_key(key), + "expected input key `{key}` in process inputs" + ); + } - // Check required outputs - for key in [ - OutputKeys::LAND_USE_SUMMARY, - OutputKeys::SITE_LAND_USE_TABLE, - OutputKeys::INPUTS, - OutputKeys::ERRORS, - OutputKeys::DOCUMENTATION_SOURCES, - ] { - assert!( - process.outputs.contains_key(key), - "expected output key `{key}` in process outputs" - ); - } - }) - .await; + // Check required outputs + for key in [ + OutputKeys::LAND_USE_SUMMARY, + OutputKeys::SITE_LAND_USE_TABLE, + OutputKeys::INPUTS, + OutputKeys::ERRORS, + OutputKeys::DOCUMENTATION_SOURCES, + ] { + assert!( + process.outputs.contains_key(key), + "expected output key `{key}` in process outputs" + ); + } } #[test] @@ -882,17 +876,17 @@ mod tests { assert!(results.is_empty()); } - #[tokio::test(flavor = "multi_thread")] - async fn it_provides_correct_process_metadata() { - with_temp_db(async move |db| { - let process = LandUseSealedAreaProcess::new(db); - assert_eq!(process.id(), "land-use-sealed-area"); - assert_eq!(process.version(), "0.1.0"); - }) - .await; + #[crate::test] + async fn it_provides_correct_process_metadata(db: DbHandle) { + let process = LandUseSealedAreaProcess::new(db); + assert_eq!(process.id(), "land-use-sealed-area"); + assert_eq!(process.version(), "0.1.0"); } - #[tokio::test(flavor = "multi_thread")] + #[crate::test(task_context = TaskContext::new(User { + id: Uuid::from_u128(42), + session_token: Uuid::from_u128(42).into(), + }))] #[allow( clippy::too_many_lines, reason = "Test is verbose due to detailed mocking and assertions" @@ -902,160 +896,173 @@ mod tests { clippy::excessive_precision, reason = "Ok for coordinates in test data" )] - async fn it_runs_the_process() { - with_temp_db(async move |db| { - - let user = User { - id: Uuid::from_u128(42), - session_token: Uuid::from_u128(42).into(), - }; - - CONTEXT.scope(TaskContext::new(user), async { - CONTEXT.set_job_id(Uuid::from_u128(0x0000_0000_0000_0000_0000)).unwrap(); - - // Start httptest server and mock the external Geo Engine endpoints - let server = Server::run(); - - server.expect( - Expectation::matching(request::method_path("POST", "//upload")) - .respond_with(json_encoded( - IdResponse::new( - Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap() - ) - )), - ); - server.expect( - Expectation::matching(request::method_path("POST", "//dataset")) - .respond_with(json_encoded( - DatasetNameResponse::new( - "test-dataset".to_string() - ) - )), - ); - server.expect( - Expectation::matching(request::method_path("POST", "//workflow")) + async fn it_runs_the_process(db: DbHandle) { + CONTEXT + .set_job_id(Uuid::from_u128(0x0000_0000_0000_0000_0000)) + .unwrap(); + + // Start httptest server and mock the external Geo Engine endpoints + let server = Server::run(); + + server.expect( + Expectation::matching(request::method_path("POST", "//upload")).respond_with( + json_encoded(IdResponse::new( + Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + )), + ), + ); + server.expect( + Expectation::matching(request::method_path("POST", "//dataset")).respond_with( + json_encoded(DatasetNameResponse::new("test-dataset".to_string())), + ), + ); + server.expect( + Expectation::matching(request::method_path("POST", "//workflow")) .times(3) - .respond_with( - cycle(vec![ - Box::new(json_encoded( - IdResponse::new( - Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap() - ) - )), - Box::new(json_encoded( - IdResponse::new( - Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap() - ) - )), - Box::new(json_encoded( - IdResponse::new( - Uuid::parse_str("00000000-0000-0000-0000-000000000004").unwrap() - ) - )), - ]) - ), - ); - server.expect( - Expectation::matching(request::method_path("GET", "//workflow/00000000-0000-0000-0000-000000000003/metadata")) - .respond_with(json_encoded( - TypedResultDescriptor::Vector( - Box::new(TypedVectorResultDescriptor { - r#type: Default::default(), - data_type:VectorDataType::MultiPolygon, - spatial_reference:"EPSG:4326".to_string(), - columns:[("name".to_string(),VectorColumnInfo{data_type:FeatureDataType::Text,measurement:Box::new(Measurement::Unitless(Box::default()))}),("siteType".to_string(),VectorColumnInfo{data_type:FeatureDataType::Text,measurement:Box::new(Measurement::Unitless(Box::default()))})].into_iter().collect(), - bbox:Some(Box::new(BoundingBox2D{lower_left_coordinate:Coordinate2D::new(8.770,50.813).into(),upper_right_coordinate:Coordinate2D::new(8.774,50.813).into()})), - time: None, - }) - ) - )), - ); - - server.expect( - Expectation::matching(request::method_path("GET", "//wfs/00000000-0000-0000-0000-000000000002")) - .respond_with(json_encoded(GeoJson { - r#type: CollectionType::FeatureCollection, - features: vec![ - json!({ - "type": "Feature", - "properties": { - "name": "Musizierhaus", - "siteType": "site", - "area": 1500.0, - "fractionSealed": 1.0 - } - }), - json!({ - "type": "Feature", - "properties": { - "name": "Im Garten", - "siteType": "site", - "area": 2000.0, - "fractionSealed": 0.75 - } - }), - json!({ - "type": "Feature", - "properties": { - "name": "Teil der Zentralbib", - "siteType": "site", - "area": 1800.0, - "fractionSealed": 0.8294 - } - }), - json!({ - "type": "Feature", - "properties": { - "name": "Garten im Musizierhaus", - "siteType": "natureOnSite", - "area": 500.0, - "fractionSealed": 0.0 - } - }), - json!({ - "type": "Feature", - "properties": { - "name": "Im Alten Botanischen Garten", - "siteType": "natureOffSite", - "area": 3000.0, - "fractionSealed": 0.0 - } - }), - ], - }).append_header("x-computation-id", "00000000-0000-0000-0000-000000000003")), - ); + .respond_with(cycle(vec![ + Box::new(json_encoded(IdResponse::new( + Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap(), + ))), + Box::new(json_encoded(IdResponse::new( + Uuid::parse_str("00000000-0000-0000-0000-000000000003").unwrap(), + ))), + Box::new(json_encoded(IdResponse::new( + Uuid::parse_str("00000000-0000-0000-0000-000000000004").unwrap(), + ))), + ])), + ); + server.expect( + Expectation::matching(request::method_path( + "GET", + "//workflow/00000000-0000-0000-0000-000000000003/metadata", + )) + .respond_with(json_encoded(TypedResultDescriptor::Vector(Box::new( + TypedVectorResultDescriptor { + r#type: Default::default(), + data_type: VectorDataType::MultiPolygon, + spatial_reference: "EPSG:4326".to_string(), + columns: [ + ( + "name".to_string(), + VectorColumnInfo { + data_type: FeatureDataType::Text, + measurement: Box::new(Measurement::Unitless(Box::default())), + }, + ), + ( + "siteType".to_string(), + VectorColumnInfo { + data_type: FeatureDataType::Text, + measurement: Box::new(Measurement::Unitless(Box::default())), + }, + ), + ] + .into_iter() + .collect(), + bbox: Some(Box::new(BoundingBox2D { + lower_left_coordinate: Coordinate2D::new(8.770, 50.813).into(), + upper_right_coordinate: Coordinate2D::new(8.774, 50.813).into(), + })), + time: None, + }, + )))), + ); - server.expect( - Expectation::matching(request::method_path("GET", "//workflow/00000000-0000-0000-0000-000000000004/provenance")) - .respond_with(json_encoded(vec![ProvenanceEntry { - provenance: Box::new(Provenance { - citation: "CITATION".to_string(), - license: "LICENSE".to_string(), - uri: "URI".to_string() + server.expect( + Expectation::matching(request::method_path( + "GET", + "//wfs/00000000-0000-0000-0000-000000000002", + )) + .respond_with( + json_encoded(GeoJson { + r#type: CollectionType::FeatureCollection, + features: vec![ + json!({ + "type": "Feature", + "properties": { + "name": "Musizierhaus", + "siteType": "site", + "area": 1500.0, + "fractionSealed": 1.0 + } }), - data: vec![ - DataId::Internal(Box::new(InternalDataId{ - r#type: Default::default(), - dataset_id: Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap() - })), - ] - }])), - ); + json!({ + "type": "Feature", + "properties": { + "name": "Im Garten", + "siteType": "site", + "area": 2000.0, + "fractionSealed": 0.75 + } + }), + json!({ + "type": "Feature", + "properties": { + "name": "Teil der Zentralbib", + "siteType": "site", + "area": 1800.0, + "fractionSealed": 0.8294 + } + }), + json!({ + "type": "Feature", + "properties": { + "name": "Garten im Musizierhaus", + "siteType": "natureOnSite", + "area": 500.0, + "fractionSealed": 0.0 + } + }), + json!({ + "type": "Feature", + "properties": { + "name": "Im Alten Botanischen Garten", + "siteType": "natureOffSite", + "area": 3000.0, + "fractionSealed": 0.0 + } + }), + ], + }) + .append_header("x-computation-id", "00000000-0000-0000-0000-000000000003"), + ), + ); - server.expect( - Expectation::matching(request::method_path("GET", "//quota/computations/00000000-0000-0000-0000-000000000003")) - .respond_with(json_encoded(vec![OperatorQuota::new( - "OPERATOR-NAME".to_string(), - "OPERATOR-PATH".to_string(), - 100, - )])), - ); + server.expect( + Expectation::matching(request::method_path( + "GET", + "//workflow/00000000-0000-0000-0000-000000000004/provenance", + )) + .respond_with(json_encoded(vec![ProvenanceEntry { + provenance: Box::new(Provenance { + citation: "CITATION".to_string(), + license: "LICENSE".to_string(), + uri: "URI".to_string(), + }), + data: vec![DataId::Internal(Box::new(InternalDataId { + r#type: Default::default(), + dataset_id: Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap(), + }))], + }])), + ); + + server.expect( + Expectation::matching(request::method_path( + "GET", + "//quota/computations/00000000-0000-0000-0000-000000000003", + )) + .respond_with(json_encoded(vec![OperatorQuota::new( + "OPERATOR-NAME".to_string(), + "OPERATOR-PATH".to_string(), + 100, + )])), + ); - // Build API configuration pointing to the mock server - let mut api_config = Configuration::new(); - api_config.base_path = server.url_str("/"); + // Build API configuration pointing to the mock server + let mut api_config = Configuration::new(); + api_config.base_path = server.url_str("/"); - let inputs = serde_json::from_value::(json!({ + let inputs = serde_json::from_value::(json!({ "sites": { "value": { "type": "FeatureCollection", @@ -1142,187 +1149,182 @@ mod tests { } })).unwrap(); - let requested_outputs = [ - OutputKeys::LAND_USE_SUMMARY, - OutputKeys::SITE_LAND_USE_TABLE, - OutputKeys::INPUTS, - OutputKeys::ERRORS, - OutputKeys::DOCUMENTATION_SOURCES, - ] - .map(|key| { - ( - key.to_string(), - Output { - format: None, - transmission_mode: Default::default(), - }, - ) - }) - .into_iter() - .collect(); - let requested_outputs = OutputKeys::from_requested_outputs(&requested_outputs).unwrap(); - - - let process = LandUseSealedAreaProcess::new(db); - let result = process - .execute(inputs.clone(), requested_outputs, api_config) - .await - .unwrap(); - - - assert_eq!( - serde_json::to_value(&result).unwrap(), - json!({ - "landUseSummary": { - "name": "Land Use", - "data": [ + let requested_outputs = [ + OutputKeys::LAND_USE_SUMMARY, + OutputKeys::SITE_LAND_USE_TABLE, + OutputKeys::INPUTS, + OutputKeys::ERRORS, + OutputKeys::DOCUMENTATION_SOURCES, + ] + .map(|key| { + ( + key.to_string(), + Output { + format: None, + transmission_mode: Default::default(), + }, + ) + }) + .into_iter() + .collect(); + let requested_outputs = OutputKeys::from_requested_outputs(&requested_outputs).unwrap(); + + let process = LandUseSealedAreaProcess::new(db); + let result = process + .execute(inputs.clone(), requested_outputs, api_config) + .await + .unwrap(); + + assert_eq!( + serde_json::to_value(&result).unwrap(), + json!({ + "landUseSummary": { + "name": "Land Use", + "data": [ + { + "landUseType": "Total sealed area", + "previousYear": 1500.0, + "reportingYear": 4492.92, + "percentageChange": "199.53 %" + }, + { + "landUseType": "Total nature-oriented area on-site", + "previousYear": 200.0, + "reportingYear": 500.0, + "percentageChange": "150 %" + }, + { + "landUseType": "Total nature-oriented area off-site", + "previousYear": 800.0, + "reportingYear": 3000.0, + "percentageChange": "275 %" + }, + { + "landUseType": "Total use of land", + "previousYear": 2500.0, + "reportingYear": 8800.0, + "percentageChange": "252 %" + } + ], + "schema": { + "fields": [ { - "landUseType": "Total sealed area", - "previousYear": 1500.0, - "reportingYear": 4492.92, - "percentageChange": "199.53 %" + "name": "landUseType", + "type": "string", + "title": "Land-use type" }, { - "landUseType": "Total nature-oriented area on-site", - "previousYear": 200.0, - "reportingYear": 500.0, - "percentageChange": "150 %" + "name": "previousYear", + "type": "number", + "title": "Previous year (m²)" }, { - "landUseType": "Total nature-oriented area off-site", - "previousYear": 800.0, - "reportingYear": 3000.0, - "percentageChange": "275 %" + "name": "reportingYear", + "type": "number", + "title": "Reporting year (m²)" }, { - "landUseType": "Total use of land", - "previousYear": 2500.0, - "reportingYear": 8800.0, - "percentageChange": "252 %" + "name": "percentageChange", + "type": "string", + "title": "% change" } ], - "schema": { - "fields": [ - { - "name": "landUseType", - "type": "string", - "title": "Land-use type" - }, - { - "name": "previousYear", - "type": "number", - "title": "Previous year (m²)" - }, - { - "name": "reportingYear", - "type": "number", - "title": "Reporting year (m²)" - }, - { - "name": "percentageChange", - "type": "string", - "title": "% change" - } - ], - "primaryKey": [ - "landUseType" - ] + "primaryKey": [ + "landUseType" + ] + } + }, + "siteLandUseTable": { + "name": "Site Land Use", + "data": [ + { + "location": "Musizierhaus", + "landUseType": "site", + "area": 1500.0, + "sealedArea": 1500.0 + }, + { + "location": "Im Garten", + "landUseType": "site", + "area": 2000.0, + "sealedArea": 1500.0 + }, + { + "location": "Teil der Zentralbib", + "landUseType": "site", + "area": 1800.0, + "sealedArea": 1492.92 + }, + { + "location": "Garten im Musizierhaus", + "landUseType": "natureOnSite", + "area": 500.0, + "sealedArea": 0.0 + }, + { + "location": "Im Alten Botanischen Garten", + "landUseType": "natureOffSite", + "area": 3000.0, + "sealedArea": 0.0 } - }, - "siteLandUseTable": { - "name": "Site Land Use", - "data": [ - { - "location": "Musizierhaus", - "landUseType": "site", - "area": 1500.0, - "sealedArea": 1500.0 - }, + ], + "schema": { + "fields": [ { - "location": "Im Garten", - "landUseType": "site", - "area": 2000.0, - "sealedArea": 1500.0 + "name": "location", + "type": "string", + "title": "Location" }, { - "location": "Teil der Zentralbib", - "landUseType": "site", - "area": 1800.0, - "sealedArea": 1492.92 + "name": "landUseType", + "type": "string", + "title": "Land-use type" }, { - "location": "Garten im Musizierhaus", - "landUseType": "natureOnSite", - "area": 500.0, - "sealedArea": 0.0 + "name": "area", + "type": "number", + "title": "Area (m²)" }, { - "location": "Im Alten Botanischen Garten", - "landUseType": "natureOffSite", - "area": 3000.0, - "sealedArea": 0.0 + "name": "sealedArea", + "type": "number", + "title": "Sealed area (m²)" } ], - "schema": { - "fields": [ - { - "name": "location", - "type": "string", - "title": "Location" - }, - { - "name": "landUseType", - "type": "string", - "title": "Land-use type" - }, - { - "name": "area", - "type": "number", - "title": "Area (m²)" - }, - { - "name": "sealedArea", - "type": "number", - "title": "Sealed area (m²)" - } - ], - "primaryKey": [ - "location" - ] + "primaryKey": [ + "location" + ] + } + }, + "inputs": serde_json::to_value(&inputs).unwrap(), + "errors": [], + "documentationSources": { + "name": "Documentation Sources", + "data": [ + { + "data": "CITATION", + "documentation_source": "URI: URI\nLicense: LICENSE" } - }, - "inputs": serde_json::to_value(&inputs).unwrap(), - "errors": [], - "documentationSources": { - "name": "Documentation Sources", - "data": [ + ], + "schema": { + "fields": [ { - "data": "CITATION", - "documentation_source": "URI: URI\nLicense: LICENSE" + "name": "data", + "type": "string", + "title": "Data" + }, + { + "name": "documentation_source", + "type": "string", + "title": "Documentation Source" } ], - "schema": { - "fields": [ - { - "name": "data", - "type": "string", - "title": "Data" - }, - { - "name": "documentation_source", - "type": "string", - "title": "Documentation Source" - } - ], - "primaryKey": [ - "data" - ] - } + "primaryKey": [ + "data" + ] } - }) - ); - }).await; - }) - .await; + } + }) + ); } } diff --git a/backend/src/processes/ndvi.rs b/backend/src/processes/ndvi.rs index 12d57c0..e3b7fc1 100644 --- a/backend/src/processes/ndvi.rs +++ b/backend/src/processes/ndvi.rs @@ -770,8 +770,6 @@ fn k_ndvi_expression() -> String { #[cfg(test)] mod tests { - use crate::db::tests::with_temp_db; - use super::*; use geoengine_api_client::apis::configuration::Configuration as ApiConfiguration; use httptest::matchers::*; @@ -846,42 +844,39 @@ mod tests { assert!((k_ndvi - 0.456).abs() < 1e-12); } - #[tokio::test(flavor = "multi_thread", worker_threads = 1)] - async fn process_summary_has_expected_inputs_and_outputs() { - with_temp_db(async move |db| { - let p = NDVIProcess::new(db); - let process = p.process().expect("to produce process description"); - - // summary id / version - assert_eq!(process.summary.id, "ndvi"); - assert_eq!(process.summary.version, "0.1.0"); - - // job control options contain sync and async execute - let mut has_sync = false; - let mut has_async = false; - for opt in &process.summary.job_control_options { - match opt { - JobControlOptions::SyncExecute => has_sync = true, - JobControlOptions::AsyncExecute => has_async = true, - JobControlOptions::Dismiss => todo!(), - } + #[crate::test] + async fn process_summary_has_expected_inputs_and_outputs(db: DbHandle) { + let p = NDVIProcess::new(db); + let process = p.process().expect("to produce process description"); + + // summary id / version + assert_eq!(process.summary.id, "ndvi"); + assert_eq!(process.summary.version, "0.1.0"); + + // job control options contain sync and async execute + let mut has_sync = false; + let mut has_async = false; + for opt in &process.summary.job_control_options { + match opt { + JobControlOptions::SyncExecute => has_sync = true, + JobControlOptions::AsyncExecute => has_async = true, + JobControlOptions::Dismiss => todo!(), } - assert!(has_sync, "expected SyncExecute in job_control_options"); - assert!(has_async, "expected AsyncExecute in job_control_options"); + } + assert!(has_sync, "expected SyncExecute in job_control_options"); + assert!(has_async, "expected AsyncExecute in job_control_options"); - // inputs contain coordinate, year, month - assert!(process.inputs.contains_key("coordinate")); - assert!(process.inputs.contains_key("year")); - assert!(process.inputs.contains_key("month")); + // inputs contain coordinate, year, month + assert!(process.inputs.contains_key("coordinate")); + assert!(process.inputs.contains_key("year")); + assert!(process.inputs.contains_key("month")); - // outputs contain ndvi and k_ndvi - assert!(process.outputs.contains_key("ndvi")); - assert!(process.outputs.contains_key("kNdvi")); + // outputs contain ndvi and k_ndvi + assert!(process.outputs.contains_key("ndvi")); + assert!(process.outputs.contains_key("kNdvi")); - // some basic checks for descriptions and schema presence - let ndvi_output = &process.outputs["ndvi"]; - assert!(ndvi_output.schema.is_object()); - }) - .await; + // some basic checks for descriptions and schema presence + let ndvi_output = &process.outputs["ndvi"]; + assert!(ndvi_output.schema.is_object()); } } diff --git a/justfile b/justfile index effc274..6de4f9d 100644 --- a/justfile +++ b/justfile @@ -181,6 +181,13 @@ test-backend-with-coverage-report: _clear --all-features \ --html +# Run the backend tests. Usage: `just test-backend`. +[group('backend')] +[group('test')] +[working-directory('backend')] +test-backend-macros filter="": _clear + cargo test -p biois-macros --locked -- {{ filter }} --nocapture + # Run the frontend tests. Usage: `just test-frontend`. [group('frontend')] [group('test')] From a1485703e5a087304395bec3056200dcd74add72 Mon Sep 17 00:00:00 2001 From: Christian Beilschmidt Date: Thu, 13 Aug 2026 09:08:04 +0200 Subject: [PATCH 06/11] feat: database migrations --- .github/workflows/ci.yml | 6 +- backend/Cargo.lock | 7 +- backend/Cargo.toml | 36 +++-- backend/README.md | 9 +- backend/Toasty.toml | 5 + backend/database/history.toml | 11 ++ .../migrations/0000_initial.sql} | 0 .../database/migrations/0001_migration.sql | 69 +++++++++ backend/database/snapshots/0001_snapshot.toml | 143 ++++++++++++++++++ backend/macros/Cargo.toml | 3 + backend/macros/src/lib.rs | 2 +- backend/macros/src/test.rs | 2 +- .../down.sql | 7 - backend/src/bin/database_cli.rs | 15 ++ backend/src/credits/handler.rs | 17 ++- backend/src/credits/mod.rs | 4 +- backend/src/credits/{db.rs => queue.rs} | 2 +- backend/src/db/mod.rs | 119 +++++++++------ backend/src/db/model.rs | 19 ++- backend/src/db/tests.rs | 7 +- backend/src/jobs.rs | 5 +- backend/src/lib.rs | 2 +- backend/src/processes/habitat_distance.rs | 4 +- backend/src/processes/parameters/units.rs | 4 + backend/src/processes/util.rs | 12 +- backend/src/server.rs | 4 +- justfile | 72 +++++++-- 27 files changed, 468 insertions(+), 118 deletions(-) create mode 100644 backend/Toasty.toml create mode 100644 backend/database/history.toml rename backend/{migrations/2026-01-16-132117-0000_create_jobs/up.sql => database/migrations/0000_initial.sql} (100%) create mode 100644 backend/database/migrations/0001_migration.sql create mode 100644 backend/database/snapshots/0001_snapshot.toml delete mode 100644 backend/migrations/2026-01-16-132117-0000_create_jobs/down.sql create mode 100644 backend/src/bin/database_cli.rs rename backend/src/credits/{db.rs => queue.rs} (99%) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 630f52b..c894f2c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -47,10 +47,8 @@ jobs: run: | service postgresql start psql postgres://geoengine:geoengine@localhost -c "CREATE DATABASE biois;" - - name: Diesel Check - run: | - just install-diesel-cli - just lint-backend-diesel-cli + - name: Check for missing migrations + run: just lint-backend-toasty env: DATABASE_URL: postgres://geoengine:geoengine@localhost/biois diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d378615..cd8150d 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -324,7 +324,7 @@ checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" [[package]] name = "biois" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "approx 0.6.0-rc2", @@ -1442,8 +1442,9 @@ dependencies = [ [[package]] name = "geoengine-api-client" -version = "0.9.3" -source = "git+https://github.com/geo-engine/geoengine?branch=fix%2Frust-api-wfs-computation-id#3ffa70e64ac8374435d7b31fba0b095e2f512325" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fa718487ebe0957cfec69a03bbd73342aa97526caebfad6790c70750dc44eee" dependencies = [ "reqwest", "serde", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index 028f53b..f8f50b8 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -6,7 +6,7 @@ pretty_assertions = "1.4" [package] name = "biois" -version = "0.1.0" +version = "0.2.0" publish = false authors = [ "Christian Beilschmidt ", @@ -18,7 +18,7 @@ repository = "https://github.com/geo-engine/BioIS" edition = "2024" default-run = "biois" -[lints.clippy] +[workspace.lints.clippy] cargo = { level = "warn", priority = 0 } complexity = { level = "warn", priority = 0 } style = { level = "warn", priority = 0 } @@ -27,20 +27,12 @@ pedantic = { level = "warn", priority = 0 } correctness = "deny" # disable some pedantic lints -result_large_err = { level = "allow", priority = 1 } # TODO: investigate this -cast_possible_truncation = { level = "allow", priority = 1 } -cast_possible_wrap = { level = "allow", priority = 1 } -cast_precision_loss = { level = "allow", priority = 1 } -cast_sign_loss = { level = "allow", priority = 1 } default_trait_access = { level = "allow", priority = 1 } missing_errors_doc = { level = "allow", priority = 1 } module_name_repetitions = { level = "allow", priority = 1 } -must_use_candidate = { level = "allow", priority = 1 } non_ascii_literal = { level = "allow", priority = 1 } option_if_let_else = { level = "allow", priority = 1 } similar_names = { level = "allow", priority = 1 } -single_match_else = { level = "allow", priority = 1 } -type_repetition_in_bounds = { level = "allow", priority = 1 } wildcard_imports = { level = "allow", priority = 1 } # enable some restriction lints @@ -53,6 +45,21 @@ unwrap_used = { level = "warn", priority = 1 } # disable some cargo lints multiple_crate_versions = { level = "allow", priority = 1 } +[[bin]] +name = "biois" +path = "src/main.rs" +bench = false + +[[bin]] +name = "openapi-generator" +path = "src/bin/openapi.rs" +bench = false + +[[bin]] +name = "database-cli" +path = "src/bin/database_cli.rs" +bench = false + [dependencies] anyhow = "1.0" approx = { version = "0.6.0-rc2", features = ["derive", "vec_impl"] } @@ -63,8 +70,7 @@ chrono = { version = "0.4", features = ["serde"] } config = "0.15" futures = "0.3" geo-types = "0.7" -# geoengine-api-client = "0.9.3" -geoengine-api-client = { git = "https://github.com/geo-engine/geoengine", branch = "fix/rust-api-wfs-computation-id" } +geoengine-api-client = "0.9.4" geojson = "1.0" indoc = "2.0" nom = "8.0" @@ -81,6 +87,8 @@ serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" tempfile = "3.27" toasty = { version = "0.10", features = ["postgresql", "serde"] } +toasty-cli = "0.10" + tokio = { version = "1.49", features = ["rt-multi-thread", "macros"] } tower = "0.5" tracing = "0.1" @@ -95,4 +103,6 @@ wkt = "0.14" [dev-dependencies] httptest = "0.16" pretty_assertions = { workspace = true } -toasty-cli = "0.10" + +[lints] +workspace = true diff --git a/backend/README.md b/backend/README.md index 026e518..446833f 100644 --- a/backend/README.md +++ b/backend/README.md @@ -31,18 +31,17 @@ It provides APIs and processing capabilities for computing biodiversity indicato ## Schema generation -The database schema is managed using Diesel migrations. +The database schema is managed using [`toasty`](https://tokio-rs.github.io/toasty/) migrations. To generate or update the schema, use the following commands: ```bash -diesel migration generate -diesel migration run +just database-cli migration generate ``` -This reverts all migrations and reapplies them, updating the `schema.rs` file accordingly: +More commands can be found in the help section of the `toasty` CLI: ```bash -diesel migration redo --all +just database-cli migration ``` ## Configuration diff --git a/backend/Toasty.toml b/backend/Toasty.toml new file mode 100644 index 0000000..b892ed0 --- /dev/null +++ b/backend/Toasty.toml @@ -0,0 +1,5 @@ +[migration] +path = "database" +prefix_style = "Sequential" +checksums = true +statement_breakpoints = true diff --git a/backend/database/history.toml b/backend/database/history.toml new file mode 100644 index 0000000..5fb235f --- /dev/null +++ b/backend/database/history.toml @@ -0,0 +1,11 @@ +version = 1 + +[[migrations]] +id = 540260129757782932 +name = "0000_initial.sql" +snapshot_name = "" + +[[migrations]] +id = 540260129757782933 +name = "0001_migration.sql" +snapshot_name = "0001_snapshot.toml" diff --git a/backend/migrations/2026-01-16-132117-0000_create_jobs/up.sql b/backend/database/migrations/0000_initial.sql similarity index 100% rename from backend/migrations/2026-01-16-132117-0000_create_jobs/up.sql rename to backend/database/migrations/0000_initial.sql diff --git a/backend/database/migrations/0001_migration.sql b/backend/database/migrations/0001_migration.sql new file mode 100644 index 0000000..36732e5 --- /dev/null +++ b/backend/database/migrations/0001_migration.sql @@ -0,0 +1,69 @@ +-- CREATE TYPE "Response" AS ENUM ('raw', 'document'); +-- CREATE TYPE "JobType" AS ENUM ('process'); +-- CREATE TYPE "StatusCode" AS ENUM ('accepted', 'running', 'successful', 'failed', 'dismissed'); + +CREATE TABLE "credits" ( + "timestamp" BIGINT NOT NULL, + "job_id" UUID NOT NULL, + "computation_id" UUID NOT NULL, + "credits" BIGINT, + PRIMARY KEY ("job_id", "computation_id") +); + +CREATE INDEX "index_credits_by_job_id" ON "credits" ("job_id"); + +ALTER TABLE "jobs" RENAME TO "jobs_old"; + +CREATE TABLE "jobs" ( + "job_id" UUID NOT NULL, + "process_id" TEXT, + "job_type" "JobType" NOT NULL, + "status" "StatusCode" NOT NULL, + "message" TEXT, + "created" BIGINT NOT NULL, + "finished" BIGINT, + "updated" BIGINT NOT NULL, + "progress" SMALLINT, + "links" JSONB NOT NULL, + "response" "Response" NOT NULL, + "results" JSONB, + "user_id" UUID NOT NULL, + PRIMARY KEY ("job_id") +); + +INSERT INTO "jobs" ( + "job_id", + "process_id", + "job_type", + "status", + "message", + "created", + "finished", + "updated", + "progress", + "links", + "response", + "results", + "user_id" +) +SELECT + "job_id"::UUID, + "process_id", + "job_type", + "status", + "message", + (EXTRACT(EPOCH FROM "created") * 1000)::BIGINT, -- Unix timestamp in milliseconds + CASE + WHEN "finished" IS NULL THEN NULL + ELSE (EXTRACT(EPOCH FROM "finished") * 1000)::BIGINT -- Unix timestamp in milliseconds + END, + (EXTRACT(EPOCH FROM "updated") * 1000)::BIGINT, -- Unix timestamp in milliseconds + "progress", + to_jsonb("links"), + "response", + "results", + "user_id" +FROM "jobs_old"; + +-- Drop manually after checking that the migration worked as expected. This is a safety measure to prevent accidental data loss. +-- DROP TABLE "jobs_old"; diff --git a/backend/database/snapshots/0001_snapshot.toml b/backend/database/snapshots/0001_snapshot.toml new file mode 100644 index 0000000..bc47969 --- /dev/null +++ b/backend/database/snapshots/0001_snapshot.toml @@ -0,0 +1,143 @@ +version = 1 + +[schema] + +[[schema.tables]] +id = 0 +name = "jobs" +primary_key = { columns = [{ table = 0, index = 0 }], index = { table = 0, index = 0 } } + +[[schema.tables.columns]] +id = { table = 0, index = 0 } +name = "job_id" +ty = "Uuid" +storage_ty = "Uuid" + +[[schema.tables.columns]] +id = { table = 0, index = 1 } +name = "process_id" +ty = "String" +storage_ty = "Text" +nullable = true + +[[schema.tables.columns]] +id = { table = 0, index = 2 } +name = "job_type" +ty = "String" +storage_ty = { Enum = { name = "JobType", variants = [{ name = "process" }] } } + +[[schema.tables.columns]] +id = { table = 0, index = 3 } +name = "status" +ty = "String" +storage_ty = { Enum = { name = "StatusCode", variants = [{ name = "accepted" }, { name = "running" }, { name = "successful" }, { name = "failed" }, { name = "dismissed" }] } } + +[[schema.tables.columns]] +id = { table = 0, index = 4 } +name = "message" +ty = "String" +storage_ty = "Text" +nullable = true + +[[schema.tables.columns]] +id = { table = 0, index = 5 } +name = "created" +ty = "I64" +storage_ty = { Integer = 8 } + +[[schema.tables.columns]] +id = { table = 0, index = 6 } +name = "finished" +ty = "I64" +storage_ty = { Integer = 8 } +nullable = true + +[[schema.tables.columns]] +id = { table = 0, index = 7 } +name = "updated" +ty = "I64" +storage_ty = { Integer = 8 } + +[[schema.tables.columns]] +id = { table = 0, index = 8 } +name = "progress" +ty = "I16" +storage_ty = { Integer = 2 } +nullable = true + +[[schema.tables.columns]] +id = { table = 0, index = 9 } +name = "links" +ty = { List = "Object" } +storage_ty = { Document = { binary = true } } + +[[schema.tables.columns]] +id = { table = 0, index = 10 } +name = "response" +ty = "String" +storage_ty = { Enum = { name = "Response", variants = [{ name = "raw" }, { name = "document" }] } } + +[[schema.tables.columns]] +id = { table = 0, index = 11 } +name = "results" +ty = "String" +storage_ty = "Jsonb" +nullable = true + +[[schema.tables.columns]] +id = { table = 0, index = 12 } +name = "user_id" +ty = "Uuid" +storage_ty = "Uuid" + +[[schema.tables.indices]] +id = { table = 0, index = 0 } +name = "index_jobs_by_job_id" +on = 0 +columns = [{ column = { table = 0, index = 0 }, op = "Eq", scope = "Partition" }] +unique = true +primary_key = true + +[[schema.tables]] +id = 1 +name = "credits" +primary_key = { columns = [{ table = 1, index = 1 }, { table = 1, index = 2 }], index = { table = 1, index = 0 } } + +[[schema.tables.columns]] +id = { table = 1, index = 0 } +name = "timestamp" +ty = "I64" +storage_ty = { Integer = 8 } + +[[schema.tables.columns]] +id = { table = 1, index = 1 } +name = "job_id" +ty = "Uuid" +storage_ty = "Uuid" + +[[schema.tables.columns]] +id = { table = 1, index = 2 } +name = "computation_id" +ty = "Uuid" +storage_ty = "Uuid" + +[[schema.tables.columns]] +id = { table = 1, index = 3 } +name = "credits" +ty = "U64" +storage_ty = { UnsignedInteger = 8 } +nullable = true + +[[schema.tables.indices]] +id = { table = 1, index = 0 } +name = "index_credits_by_job_id_and_computation_id" +on = 1 +columns = [{ column = { table = 1, index = 1 }, op = "Eq", scope = "Partition" }, { column = { table = 1, index = 2 }, op = "Eq", scope = "Local" }] +unique = true +primary_key = true + +[[schema.tables.indices]] +id = { table = 1, index = 1 } +name = "index_credits_by_job_id" +on = 1 +columns = [{ column = { table = 1, index = 1 }, op = "Eq", scope = "Partition" }] diff --git a/backend/macros/Cargo.toml b/backend/macros/Cargo.toml index faab19a..733dccb 100644 --- a/backend/macros/Cargo.toml +++ b/backend/macros/Cargo.toml @@ -14,3 +14,6 @@ syn = { version = "2.0", features = ["full", "parsing", "extra-traits"] } [dev-dependencies] pretty_assertions = { workspace = true } + +[lints] +workspace = true diff --git a/backend/macros/src/lib.rs b/backend/macros/src/lib.rs index 4cfbeb1..a012f13 100644 --- a/backend/macros/src/lib.rs +++ b/backend/macros/src/lib.rs @@ -20,6 +20,6 @@ mod test; pub fn test(args: TokenStream, item: TokenStream) -> TokenStream { let args = parse_macro_input!(args as TestArgs); let input = parse_macro_input!(item as ItemFn); - let expanded = crate::test::test(args, input); + let expanded = crate::test::test(&args, &input); TokenStream::from(expanded) } diff --git a/backend/macros/src/test.rs b/backend/macros/src/test.rs index 661c4bb..9102e82 100644 --- a/backend/macros/src/test.rs +++ b/backend/macros/src/test.rs @@ -2,7 +2,7 @@ use proc_macro2::TokenStream; use quote::quote; use syn::{Attribute, Error as SynError, FnArg, ItemFn, parse::Parse}; -pub fn test(args: TestArgs, input: ItemFn) -> TokenStream { +pub fn test(args: &TestArgs, input: &ItemFn) -> TokenStream { let fn_attrs = input.attrs.iter().filter(|attr| !is_test_attr(attr)); let vis = &input.vis; let asyncness = &input.sig.asyncness; diff --git a/backend/migrations/2026-01-16-132117-0000_create_jobs/down.sql b/backend/migrations/2026-01-16-132117-0000_create_jobs/down.sql deleted file mode 100644 index d835ba7..0000000 --- a/backend/migrations/2026-01-16-132117-0000_create_jobs/down.sql +++ /dev/null @@ -1,7 +0,0 @@ --- safety-assured:start -DROP TABLE jobs; --- safety-assured:end -DROP TYPE IF EXISTS "Link" CASCADE; -DROP TYPE IF EXISTS "Response" CASCADE; -DROP TYPE IF EXISTS "JobType" CASCADE; -DROP TYPE IF EXISTS "StatusCode" CASCADE; diff --git a/backend/src/bin/database_cli.rs b/backend/src/bin/database_cli.rs new file mode 100644 index 0000000..ff6410d --- /dev/null +++ b/backend/src/bin/database_cli.rs @@ -0,0 +1,15 @@ +use anyhow::Result; +use biois::{CONFIG, db::DbHandle}; +use toasty_cli::{Config, ToastyCli}; + +#[tokio::main] +async fn main() -> Result<()> { + let toasty_config = Config::load()?; + + let db = DbHandle::from_config(&CONFIG.database).await?; + + let cli = ToastyCli::with_config(db.db(), toasty_config); + cli.parse_and_run().await?; + + Ok(()) +} diff --git a/backend/src/credits/handler.rs b/backend/src/credits/handler.rs index 664bb47..f6da353 100644 --- a/backend/src/credits/handler.rs +++ b/backend/src/credits/handler.rs @@ -90,7 +90,7 @@ pub struct GetCreditsParams { pub struct GetCreditsResponse { pub year: u16, pub month: u8, - pub credits_used: u32, + pub credits_used: u64, pub details: Vec, // Add this field to include the details of credits } @@ -98,29 +98,29 @@ pub struct GetCreditsResponse { #[serde(rename_all = "camelCase")] pub struct CreditsForJob { pub job_id: Uuid, - pub credits_used: u32, + pub credits_used: u64, } /// A trait to sum credits used for a list of credits. /// /// TODO: Use `toasty`'s aggregate queries () once available. trait SumCredits { - fn sum(&self) -> u32; + fn sum(&self) -> u64; fn from_credits(credits_list: &[Credits]) -> Self; } impl SumCredits for Vec { - fn sum(&self) -> u32 { + fn sum(&self) -> u64 { self.iter().map(|c| c.credits_used).sum() } fn from_credits(credits_list: &[Credits]) -> Self { // Since UUID v7 is time-ordered, we can use a BTreeMap to aggregate credits by job_id. - let mut map = BTreeMap::::new(); + let mut map = BTreeMap::::new(); for c in credits_list { let entry = map.entry(c.job_id).or_insert(0); - *entry += c.credits.unwrap_or(0) as u32; + *entry += c.credits.unwrap_or(0); } map.into_iter() .map(|(job_id, credits_used)| CreditsForJob { @@ -160,7 +160,10 @@ mod tests { fn year_and_month() -> (u16, u8) { let current_date = Utc::now(); - (current_date.year() as u16, current_date.month() as u8) + ( + u16::try_from(current_date.year()).unwrap(), + u8::try_from(current_date.month()).unwrap(), + ) } #[crate::test(task_context = TaskContext::new(mock_user()))] diff --git a/backend/src/credits/mod.rs b/backend/src/credits/mod.rs index cd9f019..fe9f190 100644 --- a/backend/src/credits/mod.rs +++ b/backend/src/credits/mod.rs @@ -1,5 +1,5 @@ -mod db; mod handler; +mod queue; -pub use db::{add_credits_pending, add_credits_used}; pub use handler::router; +pub use queue::{add_credits_pending, add_credits_used}; diff --git a/backend/src/credits/db.rs b/backend/src/credits/queue.rs similarity index 99% rename from backend/src/credits/db.rs rename to backend/src/credits/queue.rs index 9817236..ac66ebd 100644 --- a/backend/src/credits/db.rs +++ b/backend/src/credits/queue.rs @@ -208,7 +208,7 @@ impl CreditsLookup { async fn update_credits(&self) -> Result<()> { fn quotas_sum<'q>(quotas: impl IntoIterator) -> u64 { - quotas.into_iter().map(|q| q.count as u64).sum() + quotas.into_iter().map(|q| q.count.cast_unsigned()).sum() } let credits = self.quotas.as_ref().map(quotas_sum); diff --git a/backend/src/db/mod.rs b/backend/src/db/mod.rs index ae720fb..f137cdd 100644 --- a/backend/src/db/mod.rs +++ b/backend/src/db/mod.rs @@ -1,6 +1,7 @@ use anyhow::{Context, Result}; use std::{ ops::{Deref, DerefMut}, + sync::Arc, time::Duration, }; use tracing::{info, instrument}; @@ -15,7 +16,7 @@ pub mod util; pub struct DbHandle { db: toasty::Db, #[allow(unused, reason = "used at least in tests")] - schema: String, + schema: Arc, } impl Deref for DbHandle { @@ -39,71 +40,95 @@ impl AsMut for DbHandle { } impl DbHandle { - /// Returns the name of the schema used by this database handle. - #[cfg(test)] - pub fn schema_name(&self) -> &str { - &self.schema - } - - pub fn db(&self) -> toasty::Db { - self.db.clone() - } -} - -/// Create a database connection and initialize the schema -#[instrument()] -pub async fn setup_db(config: &crate::config::Database) -> Result { - let mut db = toasty::Db::builder() - .models(toasty::models!(model::Job)) - // .table_name_prefix(&format!("{}.", config.schema)) - .max_pool_size(if cfg!(test) { 1 } else { 8 }) - .pool_wait_timeout(Some(Duration::from_secs(5))) - .pool_create_timeout(Some(Duration::from_secs(10))) - .pool_health_check_interval(Some(Duration::from_mins(1))) - .connect(&config.connection_string()) - .await - .context("Failed to connect to database")?; + const MIGRATIONS: toasty::migration::MigrationSet = toasty::embed_migrations!("database"); + + /// Creates a new database handle from the given configuration. + pub async fn from_config(config: &crate::config::Database) -> Result { + let db = toasty::Db::builder() + .models(toasty::models!(model::Job)) + .max_pool_size(if cfg!(test) { 1 } else { 8 }) + .pool_wait_timeout(Some(Duration::from_secs(5))) + .pool_create_timeout(Some(Duration::from_secs(10))) + .pool_health_check_interval(Some(Duration::from_mins(1))) + .connect(&config.connection_string()) + .await + .context("Failed to connect to database")?; - on_startup(&mut db, config).await?; + let mut this = Self { + db, + schema: Arc::from(config.schema.as_str()), + }; + this.setup(config).await?; - Ok(DbHandle { - db, - schema: config.schema.clone(), - }) -} + Ok(this) + } -async fn on_startup(db: &mut toasty::Db, config: &crate::config::Database) -> Result<()> { - // For development, use push_schema to quickly set up tables - // For production, migrations should be applied separately via CLI + /// Initializes the database schema, dropping and recreating it if `clear_database_on_start` is true. + /// + /// On tests, the schema is always dropped and recreated to ensure a clean state. + #[instrument()] + async fn setup(&mut self, config: &crate::config::Database) -> Result<()> { + if cfg!(test) || config.clear_database_on_start { + // TODO: do not do this if `clear_database_on_start` was false before restarting the server. + + self.drop_and_create_schema().await?; + } + + if cfg!(test) { + self.as_mut() + .push_schema() + .await + .context("Failed to push schema to database")?; + } else { + self.apply_migrations().await?; + } + + Ok(()) + } - if cfg!(test) || config.clear_database_on_start { - // TODO: do not do this if `clear_database_on_start` was false before restarting the server. + async fn drop_and_create_schema(&mut self) -> Result<()> { + info!("Clearing database schema '{}'", self.schema); - info!("Clearing database schema '{}'", config.schema); - // Drop and recreate schema toasty::sql::statement(format!( "DROP SCHEMA IF EXISTS {schema} CASCADE;", - schema = config.schema + schema = self.schema )) - .exec(db) + .exec(self.as_mut()) .await .context("Failed to clear database schema")?; - info!("Creating database schema '{}'", config.schema); + info!("Creating database schema '{}'", self.schema); + toasty::sql::statement(format!( "CREATE SCHEMA IF NOT EXISTS {schema};", - schema = config.schema + schema = self.schema )) - .exec(db) + .exec(self.as_mut()) .await .context("Failed to create database schema")?; - db.push_schema() + Ok(()) + } + + async fn apply_migrations(&mut self) -> Result<()> { + let report = Self::MIGRATIONS + .apply(self.as_mut()) .await - .context("Failed to push schema to database")?; + .context("Failed to apply migrations")?; + + info!("Applied {} database migrations", report.applied()); + Ok(()) } - // TODO: In production, use proper migrations + /// Returns the name of the schema used by this database handle. + #[cfg(test)] + #[must_use] + pub fn schema_name(&self) -> &str { + &self.schema + } - Ok(()) + #[must_use] + pub fn db(&self) -> toasty::Db { + self.db.clone() + } } diff --git a/backend/src/db/model.rs b/backend/src/db/model.rs index 2a20dcd..314c2f4 100644 --- a/backend/src/db/model.rs +++ b/backend/src/db/model.rs @@ -15,7 +15,7 @@ use uuid::Uuid; #[serde(rename_all = "PascalCase")] #[from_owned(ogcapi::types::processes::StatusCode)] #[owned_into(ogcapi::types::processes::StatusCode)] -#[column(type = text)] +// #[column(type = enum("StatusCode"))] pub enum StatusCode { Accepted, Running, @@ -29,6 +29,7 @@ pub enum StatusCode { #[serde(rename_all = "PascalCase")] #[from_owned(ogcapi::types::processes::JobType)] #[owned_into(ogcapi::types::processes::JobType)] +// #[column(type = enum("JobType"))] pub enum JobType { Process, } @@ -38,6 +39,7 @@ pub enum JobType { #[serde(rename_all = "PascalCase")] #[from_owned(ogcapi::types::processes::Response)] #[owned_into(ogcapi::types::processes::Response)] +// #[column(type = enum("Response"))] pub enum Response { Raw, Document, @@ -84,12 +86,15 @@ pub struct Job { pub message: Option, /// Job creation timestamp (stored as Unix timestamp in milliseconds) + #[default(TimestampMillis::now())] pub created: TimestampMillis, /// Job completion timestamp (stored as Unix timestamp in milliseconds) + #[default(None)] pub finished: Option, /// Last update timestamp (stored as Unix timestamp in milliseconds) + #[update(TimestampMillis::now())] pub updated: TimestampMillis, /// Progress percentage (0-100) @@ -121,6 +126,13 @@ pub struct Job { )] pub struct TimestampMillis(i64); +impl TimestampMillis { + #[must_use] + pub fn now() -> Self { + Self(Utc::now().timestamp_millis()) + } +} + impl From> for TimestampMillis { fn from(dt: DateTime) -> Self { Self(dt.timestamp_millis()) @@ -165,14 +177,17 @@ pub struct Credits { pub struct ComputationId(Uuid); impl ComputationId { + #[must_use] pub fn some(id: Uuid) -> Self { Self(id) } + #[must_use] pub fn none() -> Self { Self(Uuid::nil()) } + #[must_use] pub fn get(&self) -> Option { if self.is_none() { return None; @@ -180,10 +195,12 @@ impl ComputationId { Some(self.0) } + #[must_use] pub fn is_none(&self) -> bool { self.0.is_nil() } + #[must_use] pub fn is_some(&self) -> bool { !self.is_none() } diff --git a/backend/src/db/tests.rs b/backend/src/db/tests.rs index f1f05e3..73f665b 100644 --- a/backend/src/db/tests.rs +++ b/backend/src/db/tests.rs @@ -1,7 +1,4 @@ -use crate::{ - CONFIG, - db::{DbHandle, setup_db}, -}; +use crate::{CONFIG, db::DbHandle}; use std::{ panic::AssertUnwindSafe, sync::{Arc, OnceLock}, @@ -67,7 +64,7 @@ pub(crate) async fn setup_test_db() -> (OwnedSemaphorePermit, DbHandle) { let mut db_config = CONFIG.database.clone(); db_config.schema = test_schema(); - let db_pool = setup_db(&db_config).await.unwrap(); + let db_pool = DbHandle::from_config(&db_config).await.unwrap(); (permit, db_pool) } diff --git a/backend/src/jobs.rs b/backend/src/jobs.rs index 12cbfd5..60d0b13 100644 --- a/backend/src/jobs.rs +++ b/backend/src/jobs.rs @@ -260,7 +260,10 @@ impl TryInto for Job { created: Some(self.created.try_into()?), updated: Some(self.updated.try_into()?), finished: self.finished.map(TryInto::try_into).transpose()?, - progress: self.progress.map(|p| p as u8), + progress: self + .progress + .map(|p| u8::try_from(p).context("Progress value out of range for u8")) + .transpose()?, links: self.links.into_iter().map(Into::into).collect(), }) } diff --git a/backend/src/lib.rs b/backend/src/lib.rs index 2096f4e..f95ea9f 100644 --- a/backend/src/lib.rs +++ b/backend/src/lib.rs @@ -2,7 +2,7 @@ mod auth; mod collection_transactions; mod config; mod credits; -mod db; +pub mod db; mod handler; mod jobs; mod processes; diff --git a/backend/src/processes/habitat_distance.rs b/backend/src/processes/habitat_distance.rs index 4243180..4a8c07a 100644 --- a/backend/src/processes/habitat_distance.rs +++ b/backend/src/processes/habitat_distance.rs @@ -1,6 +1,6 @@ use crate::{ db::{DbHandle, util::RecordValueExt}, - processes::parameters::PointGeoJsonInput, + processes::{parameters::PointGeoJsonInput, util::round_nearest_i64}, }; use anyhow::{Context, Result}; use geojson::PointType; @@ -322,7 +322,7 @@ async fn compute_habitat_distance( Ok(HabitatDistanceProcessOutputs { habitat_code: Some(table.sitecode), habitat_name: Some(table.sitename), - distance_m: Some(table.distance_m.round() as i64), + distance_m: Some(round_nearest_i64(table.distance_m)), }) } diff --git a/backend/src/processes/parameters/units.rs b/backend/src/processes/parameters/units.rs index 186a945..8deee2e 100644 --- a/backend/src/processes/parameters/units.rs +++ b/backend/src/processes/parameters/units.rs @@ -273,6 +273,10 @@ impl<'de> Deserialize<'de> for Percentage { Ok(Percentage::from_percent(value)) } + #[allow( + clippy::cast_precision_loss, + reason = "We want to allow deserialization from integers, but some cannot be represented as f64 without loss of precision." + )] fn visit_i64(self, value: i64) -> Result where E: serde::de::Error, diff --git a/backend/src/processes/util.rs b/backend/src/processes/util.rs index f9b203f..af3dbf0 100644 --- a/backend/src/processes/util.rs +++ b/backend/src/processes/util.rs @@ -115,8 +115,10 @@ pub fn year_range_from_time_descriptor(time_descriptor: &TimeDescriptor) -> Resu let end_exclusive = DateTime::::from_timestamp_millis(time_interval.end).context("Invalid end time")?; - let start_year = start_inclusive.year() as u16; - let end_year_exclusive = end_exclusive.year() as u16; + let start_year = + u16::try_from(start_inclusive.year()).context("Start year out of range for u16")?; + let end_year_exclusive = + u16::try_from(end_exclusive.year()).context("End year out of range for u16")?; let end_year_inclusive = end_year_exclusive.saturating_sub(1).max(start_year); Ok((Year::new(start_year), Year::new(end_year_inclusive))) @@ -134,6 +136,12 @@ pub fn set_min_max_in_schema(schema: &mut schemars::Schema, min: i64, max: i64) Ok(()) } +/// Rounds a floating-point number to the nearest integer and returns it as an i64. +#[allow(clippy::cast_possible_truncation)] +pub fn round_nearest_i64(value: f64) -> i64 { + value.round() as i64 +} + #[cfg(test)] mod tests { use super::*; diff --git a/backend/src/server.rs b/backend/src/server.rs index b22036b..6389364 100644 --- a/backend/src/server.rs +++ b/backend/src/server.rs @@ -3,7 +3,7 @@ use crate::{ collection_transactions::NoCollectionTransactions, config::CONFIG, credits, - db::setup_db, + db::DbHandle, handler, jobs::JobHandler, processes::{ @@ -35,7 +35,7 @@ pub struct AppState { /// Create and configure the OGC API service, including routes, state, and OpenAPI documentation. pub async fn server() -> anyhow::Result { - let db_pool = setup_db(&CONFIG.database).await?; + let db_pool = DbHandle::from_config(&CONFIG.database).await?; let mut misc_router = OpenApiRouter::new() .routes(routes!(handler::health_handler)) diff --git a/justfile b/justfile index 6de4f9d..edabb7c 100644 --- a/justfile +++ b/justfile @@ -9,14 +9,6 @@ _clear: ### INSTALL ### -[group('backend')] -[group('install')] -[working-directory('backend')] -install-diesel-cli: - @echo "Installing Diesel CLI and Diesel Guard…" - cargo install diesel_cli --no-default-features --features postgres - cargo install diesel-guard --no-default-features - [group('backend')] [group('install')] [working-directory('backend')] @@ -126,14 +118,62 @@ lint-backend-sqlfluff: _clear [group('backend')] [group('lint')] [working-directory('backend')] -lint-backend-diesel-cli: _clear - @echo "Running Diesel CLI…" - diesel migration run --locked-schema - diesel-guard check migrations +lint-backend-toasty: _clear + #!/usr/bin/env python3 + import difflib + import json + import subprocess + import sys + import tomllib + from pathlib import Path + + def eprint(*args, **kwargs): + """Print to stderr.""" + print(*args, file=sys.stderr, **kwargs) + + eprint("Checking toasty's migrations…") + + snapshot_dir = Path("database/snapshots") + snapshot_files = sorted(snapshot_dir.glob("*.toml")) + if not snapshot_files: + eprint("No snapshot files found in database/snapshots") + raise SystemExit(1) + + latest_file = snapshot_files[-1] + expected = tomllib.loads(latest_file.read_text()) + + generated = subprocess.check_output( + ["cargo", "run", "--bin", "database-cli", "--", "migration", "snapshot"], + text=True, + ) + generated = "\n".join( + line for line in generated.splitlines() if line.strip() and "Current Schema Snapshot" not in line + ) + actual = tomllib.loads(generated) + + if expected != actual: + eprint(f"Snapshot mismatch: generated output differs from {latest_file.name}") + + # tomllib has no writer, so we use json.dumps to pretty-print the dicts for diffing + expected_text = json.dumps(expected, indent=2, sort_keys=True, default=str).splitlines() + actual_text = json.dumps(actual, indent=2, sort_keys=True, default=str).splitlines() + + diff = difflib.unified_diff( + expected_text, + actual_text, + fromfile=str(latest_file), + tofile="generated snapshot", + lineterm="", + ) + for line in diff: + eprint(line) + raise SystemExit(1) + + eprint(f"Snapshot matches {latest_file.name}") [group('backend')] [group('lint')] -lint-backend: lint-backend-rustfmt lint-backend-clippy lint-backend-sqlfluff lint-backend-diesel-cli +lint-backend: lint-backend-rustfmt lint-backend-clippy lint-backend-sqlfluff lint-backend-toasty [group('frontend')] [group('lint')] @@ -278,3 +318,9 @@ check-no-changes-in-git-repo: else echo "No uncommitted changes found in git repository." fi + +[group('backend')] +[working-directory('backend')] +database-cli +ARGS="": _clear + cargo run --bin database-cli -- {{ ARGS }} + \ No newline at end of file From e4c39e3283ba846043af05470962a6675ef72213 Mon Sep 17 00:00:00 2001 From: Christian Beilschmidt Date: Thu, 13 Aug 2026 12:20:37 +0200 Subject: [PATCH 07/11] fix: lints and tests --- .../typescript/models/AdditionalParameter.ts | 2 +- .../typescript/models/AdditionalParameters.ts | 2 +- api-client/typescript/models/AnyField.ts | 2 +- api-client/typescript/models/ArrayField.ts | 2 +- .../typescript/models/AuthCodeResponse.ts | 2 +- ...BiodiversitySensitiveAreasProcessInputs.ts | 2 +- ...iodiversitySensitiveAreasProcessOutputs.ts | 2 +- ...BiodiversitySensitiveAreasProcessParams.ts | 2 +- api-client/typescript/models/BooleanField.ts | 2 +- api-client/typescript/models/BoundingBox.ts | 2 +- api-client/typescript/models/Conformance.ts | 2 +- api-client/typescript/models/Constraints.ts | 2 +- api-client/typescript/models/Constraints1.ts | 2 +- api-client/typescript/models/Constraints10.ts | 2 +- .../typescript/models/Constraints10Enum.ts | 2 +- api-client/typescript/models/Constraints11.ts | 2 +- api-client/typescript/models/Constraints12.ts | 2 +- .../typescript/models/Constraints12Enum.ts | 2 +- api-client/typescript/models/Constraints13.ts | 2 +- api-client/typescript/models/Constraints14.ts | 2 +- .../typescript/models/Constraints1Enum.ts | 2 +- .../typescript/models/Constraints1Minimum.ts | 2 +- api-client/typescript/models/Constraints2.ts | 2 +- api-client/typescript/models/Constraints3.ts | 2 +- api-client/typescript/models/Constraints4.ts | 2 +- api-client/typescript/models/Constraints5.ts | 2 +- api-client/typescript/models/Constraints6.ts | 2 +- api-client/typescript/models/Constraints7.ts | 2 +- api-client/typescript/models/Constraints8.ts | 2 +- api-client/typescript/models/Constraints9.ts | 2 +- api-client/typescript/models/CreditsForJob.ts | 4 +- api-client/typescript/models/DataResource.ts | 2 +- api-client/typescript/models/DateField.ts | 2 +- api-client/typescript/models/DateTimeField.ts | 2 +- .../typescript/models/DescriptionType.ts | 2 +- api-client/typescript/models/DurationField.ts | 2 +- api-client/typescript/models/Exception.ts | 2 +- api-client/typescript/models/Execute.ts | 2 +- .../models/FeatureCollectionGeoJsonInput.ts | 2 +- api-client/typescript/models/Format.ts | 2 +- .../typescript/models/GeoJSONFeature.ts | 2 +- .../models/GeoJSONFeatureCollection.ts | 2 +- .../models/GeoJSONFeatureGeometry.ts | 2 +- .../typescript/models/GeoJSONFeatureId.ts | 2 +- api-client/typescript/models/GeoJSONField.ts | 2 +- .../models/GeoJSONGeometryCollection.ts | 2 +- ...eoJSONGeometryCollectionGeometriesInner.ts | 2 +- .../typescript/models/GeoJSONLineString.ts | 2 +- .../models/GeoJSONMultiLineString.ts | 2 +- .../typescript/models/GeoJSONMultiPoint.ts | 2 +- .../typescript/models/GeoJSONMultiPolygon.ts | 2 +- api-client/typescript/models/GeoJSONPoint.ts | 2 +- .../typescript/models/GeoJSONPolygon.ts | 2 +- .../models/GeoJsonInputMediaType.ts | 2 +- api-client/typescript/models/GeoPointField.ts | 2 +- .../typescript/models/GetCreditsResponse.ts | 4 +- .../models/HabitatDistanceProcessInputs.ts | 2 +- .../models/HabitatDistanceProcessOutputs.ts | 2 +- .../models/HabitatDistanceProcessParams.ts | 2 +- .../typescript/models/InlineOrRefData.ts | 2 +- api-client/typescript/models/Input.ts | 2 +- .../typescript/models/InputDescription.ts | 2 +- api-client/typescript/models/InputValue.ts | 2 +- api-client/typescript/models/IntegerField.ts | 2 +- .../typescript/models/JobControlOptions.ts | 2 +- api-client/typescript/models/JobList.ts | 2 +- api-client/typescript/models/JobType.ts | 2 +- .../typescript/models/JsonInputMediaType.ts | 2 +- .../models/JsonInputPreviousLandUseSummary.ts | 2 +- .../JsonInputPreviousLandUseSummaryValue.ts | 2 +- .../models/LandUseSealedAreaProcessInputs.ts | 2 +- .../models/LandUseSealedAreaProcessOutputs.ts | 2 +- .../models/LandUseSealedAreaProcessParams.ts | 2 +- api-client/typescript/models/LandingPage.ts | 2 +- api-client/typescript/models/License.ts | 2 +- api-client/typescript/models/Link.ts | 2 +- api-client/typescript/models/MaxOccurs.ts | 2 +- api-client/typescript/models/Metadata.ts | 2 +- .../typescript/models/NDVIProcessInputs.ts | 2 +- .../typescript/models/NDVIProcessOutputs.ts | 2 +- .../typescript/models/NDVIProcessParams.ts | 2 +- api-client/typescript/models/NumberField.ts | 2 +- api-client/typescript/models/ObjectField.ts | 2 +- api-client/typescript/models/Output.ts | 2 +- .../typescript/models/OutputDescription.ts | 2 +- api-client/typescript/models/Path.ts | 2 +- .../typescript/models/PointGeoJsonInput.ts | 2 +- api-client/typescript/models/Process.ts | 2 +- api-client/typescript/models/ProcessList.ts | 2 +- .../typescript/models/ProcessSummary.ts | 2 +- .../typescript/models/QualifiedInputValue.ts | 2 +- api-client/typescript/models/Response.ts | 2 +- api-client/typescript/models/Results.ts | 2 +- api-client/typescript/models/Schema.ts | 2 +- api-client/typescript/models/Source.ts | 2 +- api-client/typescript/models/StatusCode.ts | 2 +- api-client/typescript/models/StatusInfo.ts | 2 +- api-client/typescript/models/StringField.ts | 2 +- .../models/StringFieldCategories.ts | 2 +- .../models/StringFieldMissingValues.ts | 2 +- .../StringFieldMissingValuesAnyOfInner.ts | 2 +- api-client/typescript/models/Subscriber.ts | 2 +- api-client/typescript/models/TableDialect.ts | 2 +- .../typescript/models/TableSchemaField.ts | 2 +- .../models/TableSchemaForeignKey.ts | 2 +- .../models/TableSchemaForeignKeyOneOf.ts | 2 +- .../models/TableSchemaForeignKeyOneOf1.ts | 2 +- .../TableSchemaForeignKeyOneOf1Reference.ts | 2 +- .../TableSchemaForeignKeyOneOfReference.ts | 2 +- .../models/TableSchemaPrimaryKey.ts | 2 +- api-client/typescript/models/TimeField.ts | 2 +- .../typescript/models/TransmissionMode.ts | 2 +- api-client/typescript/models/UnitForArea.ts | 2 +- api-client/typescript/models/UserInfo.ts | 2 +- api-client/typescript/models/UserSession.ts | 2 +- api-client/typescript/models/YearField.ts | 2 +- .../typescript/models/YearMonthField.ts | 2 +- backend/Cargo.lock | 1 + backend/Cargo.toml | 14 ++- backend/Dockerfile | 14 +-- backend/README.md | 4 +- .../database/migrations/0001_migration.sql | 22 +++-- backend/src/bin/biois_cli.rs | 88 +++++++++++++++++++ backend/src/bin/database_cli.rs | 15 ---- justfile | 8 +- openapi.json | 6 +- 126 files changed, 245 insertions(+), 165 deletions(-) create mode 100644 backend/src/bin/biois_cli.rs delete mode 100644 backend/src/bin/database_cli.rs diff --git a/api-client/typescript/models/AdditionalParameter.ts b/api-client/typescript/models/AdditionalParameter.ts index 2894c60..8c6d691 100644 --- a/api-client/typescript/models/AdditionalParameter.ts +++ b/api-client/typescript/models/AdditionalParameter.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/AdditionalParameters.ts b/api-client/typescript/models/AdditionalParameters.ts index 5346496..b106d4f 100644 --- a/api-client/typescript/models/AdditionalParameters.ts +++ b/api-client/typescript/models/AdditionalParameters.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/AnyField.ts b/api-client/typescript/models/AnyField.ts index 9c00d5a..ac2dde8 100644 --- a/api-client/typescript/models/AnyField.ts +++ b/api-client/typescript/models/AnyField.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/ArrayField.ts b/api-client/typescript/models/ArrayField.ts index 2892fce..68130f2 100644 --- a/api-client/typescript/models/ArrayField.ts +++ b/api-client/typescript/models/ArrayField.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/AuthCodeResponse.ts b/api-client/typescript/models/AuthCodeResponse.ts index e5d1f39..978cec6 100644 --- a/api-client/typescript/models/AuthCodeResponse.ts +++ b/api-client/typescript/models/AuthCodeResponse.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/BiodiversitySensitiveAreasProcessInputs.ts b/api-client/typescript/models/BiodiversitySensitiveAreasProcessInputs.ts index 7645759..26b6d1e 100644 --- a/api-client/typescript/models/BiodiversitySensitiveAreasProcessInputs.ts +++ b/api-client/typescript/models/BiodiversitySensitiveAreasProcessInputs.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/BiodiversitySensitiveAreasProcessOutputs.ts b/api-client/typescript/models/BiodiversitySensitiveAreasProcessOutputs.ts index b53e15a..ec9f8b6 100644 --- a/api-client/typescript/models/BiodiversitySensitiveAreasProcessOutputs.ts +++ b/api-client/typescript/models/BiodiversitySensitiveAreasProcessOutputs.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/BiodiversitySensitiveAreasProcessParams.ts b/api-client/typescript/models/BiodiversitySensitiveAreasProcessParams.ts index 9e89c07..88a241a 100644 --- a/api-client/typescript/models/BiodiversitySensitiveAreasProcessParams.ts +++ b/api-client/typescript/models/BiodiversitySensitiveAreasProcessParams.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/BooleanField.ts b/api-client/typescript/models/BooleanField.ts index 764eebf..94d1cce 100644 --- a/api-client/typescript/models/BooleanField.ts +++ b/api-client/typescript/models/BooleanField.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/BoundingBox.ts b/api-client/typescript/models/BoundingBox.ts index e6809f6..1811b1c 100644 --- a/api-client/typescript/models/BoundingBox.ts +++ b/api-client/typescript/models/BoundingBox.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Conformance.ts b/api-client/typescript/models/Conformance.ts index b6d7aae..5df4b9e 100644 --- a/api-client/typescript/models/Conformance.ts +++ b/api-client/typescript/models/Conformance.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Constraints.ts b/api-client/typescript/models/Constraints.ts index ec6dcb2..fb0042b 100644 --- a/api-client/typescript/models/Constraints.ts +++ b/api-client/typescript/models/Constraints.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Constraints1.ts b/api-client/typescript/models/Constraints1.ts index 2b39b9a..7729d3e 100644 --- a/api-client/typescript/models/Constraints1.ts +++ b/api-client/typescript/models/Constraints1.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Constraints10.ts b/api-client/typescript/models/Constraints10.ts index 0647c8a..126776d 100644 --- a/api-client/typescript/models/Constraints10.ts +++ b/api-client/typescript/models/Constraints10.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Constraints10Enum.ts b/api-client/typescript/models/Constraints10Enum.ts index 447044a..2174582 100644 --- a/api-client/typescript/models/Constraints10Enum.ts +++ b/api-client/typescript/models/Constraints10Enum.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Constraints11.ts b/api-client/typescript/models/Constraints11.ts index 83f0807..4580646 100644 --- a/api-client/typescript/models/Constraints11.ts +++ b/api-client/typescript/models/Constraints11.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Constraints12.ts b/api-client/typescript/models/Constraints12.ts index 038da7f..56674ed 100644 --- a/api-client/typescript/models/Constraints12.ts +++ b/api-client/typescript/models/Constraints12.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Constraints12Enum.ts b/api-client/typescript/models/Constraints12Enum.ts index e58ff88..bfa2399 100644 --- a/api-client/typescript/models/Constraints12Enum.ts +++ b/api-client/typescript/models/Constraints12Enum.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Constraints13.ts b/api-client/typescript/models/Constraints13.ts index b5bb364..3507fe8 100644 --- a/api-client/typescript/models/Constraints13.ts +++ b/api-client/typescript/models/Constraints13.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Constraints14.ts b/api-client/typescript/models/Constraints14.ts index 3cc2a07..d8723d2 100644 --- a/api-client/typescript/models/Constraints14.ts +++ b/api-client/typescript/models/Constraints14.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Constraints1Enum.ts b/api-client/typescript/models/Constraints1Enum.ts index 55c5421..5db3f42 100644 --- a/api-client/typescript/models/Constraints1Enum.ts +++ b/api-client/typescript/models/Constraints1Enum.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Constraints1Minimum.ts b/api-client/typescript/models/Constraints1Minimum.ts index 1a49922..3d39b52 100644 --- a/api-client/typescript/models/Constraints1Minimum.ts +++ b/api-client/typescript/models/Constraints1Minimum.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Constraints2.ts b/api-client/typescript/models/Constraints2.ts index 9033769..854e7f9 100644 --- a/api-client/typescript/models/Constraints2.ts +++ b/api-client/typescript/models/Constraints2.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Constraints3.ts b/api-client/typescript/models/Constraints3.ts index 449d365..6db7435 100644 --- a/api-client/typescript/models/Constraints3.ts +++ b/api-client/typescript/models/Constraints3.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Constraints4.ts b/api-client/typescript/models/Constraints4.ts index 575010f..36ef5c2 100644 --- a/api-client/typescript/models/Constraints4.ts +++ b/api-client/typescript/models/Constraints4.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Constraints5.ts b/api-client/typescript/models/Constraints5.ts index 6005480..b18ebec 100644 --- a/api-client/typescript/models/Constraints5.ts +++ b/api-client/typescript/models/Constraints5.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Constraints6.ts b/api-client/typescript/models/Constraints6.ts index a5b661b..522c932 100644 --- a/api-client/typescript/models/Constraints6.ts +++ b/api-client/typescript/models/Constraints6.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Constraints7.ts b/api-client/typescript/models/Constraints7.ts index 8b421ed..76f7ff4 100644 --- a/api-client/typescript/models/Constraints7.ts +++ b/api-client/typescript/models/Constraints7.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Constraints8.ts b/api-client/typescript/models/Constraints8.ts index f0e5867..812abc0 100644 --- a/api-client/typescript/models/Constraints8.ts +++ b/api-client/typescript/models/Constraints8.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Constraints9.ts b/api-client/typescript/models/Constraints9.ts index bc7c794..929c8ee 100644 --- a/api-client/typescript/models/Constraints9.ts +++ b/api-client/typescript/models/Constraints9.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/CreditsForJob.ts b/api-client/typescript/models/CreditsForJob.ts index 39fe71a..6ee3994 100644 --- a/api-client/typescript/models/CreditsForJob.ts +++ b/api-client/typescript/models/CreditsForJob.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). @@ -31,7 +31,7 @@ export class CreditsForJob { "name": "creditsUsed", "baseName": "creditsUsed", "type": "number", - "format": "int32" + "format": "int64" } ]; static getAttributeTypeMap() { diff --git a/api-client/typescript/models/DataResource.ts b/api-client/typescript/models/DataResource.ts index 720555d..bbc45ff 100644 --- a/api-client/typescript/models/DataResource.ts +++ b/api-client/typescript/models/DataResource.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/DateField.ts b/api-client/typescript/models/DateField.ts index 76c5815..3921e1e 100644 --- a/api-client/typescript/models/DateField.ts +++ b/api-client/typescript/models/DateField.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/DateTimeField.ts b/api-client/typescript/models/DateTimeField.ts index 4d8a8b5..101d776 100644 --- a/api-client/typescript/models/DateTimeField.ts +++ b/api-client/typescript/models/DateTimeField.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/DescriptionType.ts b/api-client/typescript/models/DescriptionType.ts index 34e1300..b13c1c9 100644 --- a/api-client/typescript/models/DescriptionType.ts +++ b/api-client/typescript/models/DescriptionType.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/DurationField.ts b/api-client/typescript/models/DurationField.ts index 5951e5b..ea776eb 100644 --- a/api-client/typescript/models/DurationField.ts +++ b/api-client/typescript/models/DurationField.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Exception.ts b/api-client/typescript/models/Exception.ts index 8556b29..a3dede6 100644 --- a/api-client/typescript/models/Exception.ts +++ b/api-client/typescript/models/Exception.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Execute.ts b/api-client/typescript/models/Execute.ts index efce27d..27890f4 100644 --- a/api-client/typescript/models/Execute.ts +++ b/api-client/typescript/models/Execute.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/FeatureCollectionGeoJsonInput.ts b/api-client/typescript/models/FeatureCollectionGeoJsonInput.ts index 1b01325..9dc6ac7 100644 --- a/api-client/typescript/models/FeatureCollectionGeoJsonInput.ts +++ b/api-client/typescript/models/FeatureCollectionGeoJsonInput.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Format.ts b/api-client/typescript/models/Format.ts index b2baedc..7a2b3ef 100644 --- a/api-client/typescript/models/Format.ts +++ b/api-client/typescript/models/Format.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/GeoJSONFeature.ts b/api-client/typescript/models/GeoJSONFeature.ts index 56f9cbf..43f2d16 100644 --- a/api-client/typescript/models/GeoJSONFeature.ts +++ b/api-client/typescript/models/GeoJSONFeature.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/GeoJSONFeatureCollection.ts b/api-client/typescript/models/GeoJSONFeatureCollection.ts index 880f830..3508dbc 100644 --- a/api-client/typescript/models/GeoJSONFeatureCollection.ts +++ b/api-client/typescript/models/GeoJSONFeatureCollection.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/GeoJSONFeatureGeometry.ts b/api-client/typescript/models/GeoJSONFeatureGeometry.ts index 48b4c1f..23bd406 100644 --- a/api-client/typescript/models/GeoJSONFeatureGeometry.ts +++ b/api-client/typescript/models/GeoJSONFeatureGeometry.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/GeoJSONFeatureId.ts b/api-client/typescript/models/GeoJSONFeatureId.ts index cd44acc..61426d6 100644 --- a/api-client/typescript/models/GeoJSONFeatureId.ts +++ b/api-client/typescript/models/GeoJSONFeatureId.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/GeoJSONField.ts b/api-client/typescript/models/GeoJSONField.ts index 1fa41e1..16d1ccf 100644 --- a/api-client/typescript/models/GeoJSONField.ts +++ b/api-client/typescript/models/GeoJSONField.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/GeoJSONGeometryCollection.ts b/api-client/typescript/models/GeoJSONGeometryCollection.ts index 40b78b4..d195a93 100644 --- a/api-client/typescript/models/GeoJSONGeometryCollection.ts +++ b/api-client/typescript/models/GeoJSONGeometryCollection.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/GeoJSONGeometryCollectionGeometriesInner.ts b/api-client/typescript/models/GeoJSONGeometryCollectionGeometriesInner.ts index 40d7215..b4ea16f 100644 --- a/api-client/typescript/models/GeoJSONGeometryCollectionGeometriesInner.ts +++ b/api-client/typescript/models/GeoJSONGeometryCollectionGeometriesInner.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/GeoJSONLineString.ts b/api-client/typescript/models/GeoJSONLineString.ts index b8f7539..aa2c29f 100644 --- a/api-client/typescript/models/GeoJSONLineString.ts +++ b/api-client/typescript/models/GeoJSONLineString.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/GeoJSONMultiLineString.ts b/api-client/typescript/models/GeoJSONMultiLineString.ts index 8c7ebd1..be94b17 100644 --- a/api-client/typescript/models/GeoJSONMultiLineString.ts +++ b/api-client/typescript/models/GeoJSONMultiLineString.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/GeoJSONMultiPoint.ts b/api-client/typescript/models/GeoJSONMultiPoint.ts index e26e53c..733e047 100644 --- a/api-client/typescript/models/GeoJSONMultiPoint.ts +++ b/api-client/typescript/models/GeoJSONMultiPoint.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/GeoJSONMultiPolygon.ts b/api-client/typescript/models/GeoJSONMultiPolygon.ts index c0c0484..98875f0 100644 --- a/api-client/typescript/models/GeoJSONMultiPolygon.ts +++ b/api-client/typescript/models/GeoJSONMultiPolygon.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/GeoJSONPoint.ts b/api-client/typescript/models/GeoJSONPoint.ts index 5656787..1411e3e 100644 --- a/api-client/typescript/models/GeoJSONPoint.ts +++ b/api-client/typescript/models/GeoJSONPoint.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/GeoJSONPolygon.ts b/api-client/typescript/models/GeoJSONPolygon.ts index 6d9da09..18e6779 100644 --- a/api-client/typescript/models/GeoJSONPolygon.ts +++ b/api-client/typescript/models/GeoJSONPolygon.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/GeoJsonInputMediaType.ts b/api-client/typescript/models/GeoJsonInputMediaType.ts index 38ad4c5..c12b05b 100644 --- a/api-client/typescript/models/GeoJsonInputMediaType.ts +++ b/api-client/typescript/models/GeoJsonInputMediaType.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/GeoPointField.ts b/api-client/typescript/models/GeoPointField.ts index a46b34e..2bbce01 100644 --- a/api-client/typescript/models/GeoPointField.ts +++ b/api-client/typescript/models/GeoPointField.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/GetCreditsResponse.ts b/api-client/typescript/models/GetCreditsResponse.ts index 2bbf5f9..bba171d 100644 --- a/api-client/typescript/models/GetCreditsResponse.ts +++ b/api-client/typescript/models/GetCreditsResponse.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). @@ -40,7 +40,7 @@ export class GetCreditsResponse { "name": "creditsUsed", "baseName": "creditsUsed", "type": "number", - "format": "int32" + "format": "int64" }, { "name": "details", diff --git a/api-client/typescript/models/HabitatDistanceProcessInputs.ts b/api-client/typescript/models/HabitatDistanceProcessInputs.ts index 4396e1d..b0ae49f 100644 --- a/api-client/typescript/models/HabitatDistanceProcessInputs.ts +++ b/api-client/typescript/models/HabitatDistanceProcessInputs.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/HabitatDistanceProcessOutputs.ts b/api-client/typescript/models/HabitatDistanceProcessOutputs.ts index c4e0981..3588be2 100644 --- a/api-client/typescript/models/HabitatDistanceProcessOutputs.ts +++ b/api-client/typescript/models/HabitatDistanceProcessOutputs.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/HabitatDistanceProcessParams.ts b/api-client/typescript/models/HabitatDistanceProcessParams.ts index 5cd8375..b67954a 100644 --- a/api-client/typescript/models/HabitatDistanceProcessParams.ts +++ b/api-client/typescript/models/HabitatDistanceProcessParams.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/InlineOrRefData.ts b/api-client/typescript/models/InlineOrRefData.ts index 89eb185..5d72098 100644 --- a/api-client/typescript/models/InlineOrRefData.ts +++ b/api-client/typescript/models/InlineOrRefData.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Input.ts b/api-client/typescript/models/Input.ts index d35f7b1..8d27143 100644 --- a/api-client/typescript/models/Input.ts +++ b/api-client/typescript/models/Input.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/InputDescription.ts b/api-client/typescript/models/InputDescription.ts index e9e9829..b1be50f 100644 --- a/api-client/typescript/models/InputDescription.ts +++ b/api-client/typescript/models/InputDescription.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/InputValue.ts b/api-client/typescript/models/InputValue.ts index b1fa805..4c4a180 100644 --- a/api-client/typescript/models/InputValue.ts +++ b/api-client/typescript/models/InputValue.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/IntegerField.ts b/api-client/typescript/models/IntegerField.ts index 3ec4c2c..9c88e99 100644 --- a/api-client/typescript/models/IntegerField.ts +++ b/api-client/typescript/models/IntegerField.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/JobControlOptions.ts b/api-client/typescript/models/JobControlOptions.ts index 419309b..a665915 100644 --- a/api-client/typescript/models/JobControlOptions.ts +++ b/api-client/typescript/models/JobControlOptions.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/JobList.ts b/api-client/typescript/models/JobList.ts index 825ec87..9e0a0b3 100644 --- a/api-client/typescript/models/JobList.ts +++ b/api-client/typescript/models/JobList.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/JobType.ts b/api-client/typescript/models/JobType.ts index acf0c8d..99e88a8 100644 --- a/api-client/typescript/models/JobType.ts +++ b/api-client/typescript/models/JobType.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/JsonInputMediaType.ts b/api-client/typescript/models/JsonInputMediaType.ts index 07c52ed..0f47f33 100644 --- a/api-client/typescript/models/JsonInputMediaType.ts +++ b/api-client/typescript/models/JsonInputMediaType.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/JsonInputPreviousLandUseSummary.ts b/api-client/typescript/models/JsonInputPreviousLandUseSummary.ts index dbb900a..b8d87ed 100644 --- a/api-client/typescript/models/JsonInputPreviousLandUseSummary.ts +++ b/api-client/typescript/models/JsonInputPreviousLandUseSummary.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/JsonInputPreviousLandUseSummaryValue.ts b/api-client/typescript/models/JsonInputPreviousLandUseSummaryValue.ts index 36ba773..2693d83 100644 --- a/api-client/typescript/models/JsonInputPreviousLandUseSummaryValue.ts +++ b/api-client/typescript/models/JsonInputPreviousLandUseSummaryValue.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/LandUseSealedAreaProcessInputs.ts b/api-client/typescript/models/LandUseSealedAreaProcessInputs.ts index 6fbed68..2f696b8 100644 --- a/api-client/typescript/models/LandUseSealedAreaProcessInputs.ts +++ b/api-client/typescript/models/LandUseSealedAreaProcessInputs.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/LandUseSealedAreaProcessOutputs.ts b/api-client/typescript/models/LandUseSealedAreaProcessOutputs.ts index 737c176..80a2ac1 100644 --- a/api-client/typescript/models/LandUseSealedAreaProcessOutputs.ts +++ b/api-client/typescript/models/LandUseSealedAreaProcessOutputs.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/LandUseSealedAreaProcessParams.ts b/api-client/typescript/models/LandUseSealedAreaProcessParams.ts index 5e779ab..4780568 100644 --- a/api-client/typescript/models/LandUseSealedAreaProcessParams.ts +++ b/api-client/typescript/models/LandUseSealedAreaProcessParams.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/LandingPage.ts b/api-client/typescript/models/LandingPage.ts index c819447..3dfd491 100644 --- a/api-client/typescript/models/LandingPage.ts +++ b/api-client/typescript/models/LandingPage.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/License.ts b/api-client/typescript/models/License.ts index 45aa1df..c0400b8 100644 --- a/api-client/typescript/models/License.ts +++ b/api-client/typescript/models/License.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Link.ts b/api-client/typescript/models/Link.ts index c89ce9e..876a74b 100644 --- a/api-client/typescript/models/Link.ts +++ b/api-client/typescript/models/Link.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/MaxOccurs.ts b/api-client/typescript/models/MaxOccurs.ts index 656ca76..7cccdff 100644 --- a/api-client/typescript/models/MaxOccurs.ts +++ b/api-client/typescript/models/MaxOccurs.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Metadata.ts b/api-client/typescript/models/Metadata.ts index b54b020..960a40d 100644 --- a/api-client/typescript/models/Metadata.ts +++ b/api-client/typescript/models/Metadata.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/NDVIProcessInputs.ts b/api-client/typescript/models/NDVIProcessInputs.ts index f5cd750..b342177 100644 --- a/api-client/typescript/models/NDVIProcessInputs.ts +++ b/api-client/typescript/models/NDVIProcessInputs.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/NDVIProcessOutputs.ts b/api-client/typescript/models/NDVIProcessOutputs.ts index bf2d684..14155cc 100644 --- a/api-client/typescript/models/NDVIProcessOutputs.ts +++ b/api-client/typescript/models/NDVIProcessOutputs.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/NDVIProcessParams.ts b/api-client/typescript/models/NDVIProcessParams.ts index de56b5f..14b7803 100644 --- a/api-client/typescript/models/NDVIProcessParams.ts +++ b/api-client/typescript/models/NDVIProcessParams.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/NumberField.ts b/api-client/typescript/models/NumberField.ts index 0026fd8..fef5cd6 100644 --- a/api-client/typescript/models/NumberField.ts +++ b/api-client/typescript/models/NumberField.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/ObjectField.ts b/api-client/typescript/models/ObjectField.ts index 344b958..6378594 100644 --- a/api-client/typescript/models/ObjectField.ts +++ b/api-client/typescript/models/ObjectField.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Output.ts b/api-client/typescript/models/Output.ts index 85574ab..d645302 100644 --- a/api-client/typescript/models/Output.ts +++ b/api-client/typescript/models/Output.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/OutputDescription.ts b/api-client/typescript/models/OutputDescription.ts index 96780af..8d4c709 100644 --- a/api-client/typescript/models/OutputDescription.ts +++ b/api-client/typescript/models/OutputDescription.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Path.ts b/api-client/typescript/models/Path.ts index 632b7cb..18997f9 100644 --- a/api-client/typescript/models/Path.ts +++ b/api-client/typescript/models/Path.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/PointGeoJsonInput.ts b/api-client/typescript/models/PointGeoJsonInput.ts index 4581b79..4785262 100644 --- a/api-client/typescript/models/PointGeoJsonInput.ts +++ b/api-client/typescript/models/PointGeoJsonInput.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Process.ts b/api-client/typescript/models/Process.ts index 4ddfe5b..fcd0530 100644 --- a/api-client/typescript/models/Process.ts +++ b/api-client/typescript/models/Process.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/ProcessList.ts b/api-client/typescript/models/ProcessList.ts index a2d46a1..4e32166 100644 --- a/api-client/typescript/models/ProcessList.ts +++ b/api-client/typescript/models/ProcessList.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/ProcessSummary.ts b/api-client/typescript/models/ProcessSummary.ts index 8c9c9bc..1d779d7 100644 --- a/api-client/typescript/models/ProcessSummary.ts +++ b/api-client/typescript/models/ProcessSummary.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/QualifiedInputValue.ts b/api-client/typescript/models/QualifiedInputValue.ts index 79ebd61..cb1cfa0 100644 --- a/api-client/typescript/models/QualifiedInputValue.ts +++ b/api-client/typescript/models/QualifiedInputValue.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Response.ts b/api-client/typescript/models/Response.ts index bae8a21..58994e4 100644 --- a/api-client/typescript/models/Response.ts +++ b/api-client/typescript/models/Response.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Results.ts b/api-client/typescript/models/Results.ts index cf2a090..8437f54 100644 --- a/api-client/typescript/models/Results.ts +++ b/api-client/typescript/models/Results.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Schema.ts b/api-client/typescript/models/Schema.ts index a8a1018..74348d5 100644 --- a/api-client/typescript/models/Schema.ts +++ b/api-client/typescript/models/Schema.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Source.ts b/api-client/typescript/models/Source.ts index 0234390..9cf58eb 100644 --- a/api-client/typescript/models/Source.ts +++ b/api-client/typescript/models/Source.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/StatusCode.ts b/api-client/typescript/models/StatusCode.ts index c358c40..b56569f 100644 --- a/api-client/typescript/models/StatusCode.ts +++ b/api-client/typescript/models/StatusCode.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/StatusInfo.ts b/api-client/typescript/models/StatusInfo.ts index 2c21e03..3b7be3b 100644 --- a/api-client/typescript/models/StatusInfo.ts +++ b/api-client/typescript/models/StatusInfo.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/StringField.ts b/api-client/typescript/models/StringField.ts index 4266e33..aab03b8 100644 --- a/api-client/typescript/models/StringField.ts +++ b/api-client/typescript/models/StringField.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/StringFieldCategories.ts b/api-client/typescript/models/StringFieldCategories.ts index 992dabf..efc4aab 100644 --- a/api-client/typescript/models/StringFieldCategories.ts +++ b/api-client/typescript/models/StringFieldCategories.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/StringFieldMissingValues.ts b/api-client/typescript/models/StringFieldMissingValues.ts index 3275c25..04f13a7 100644 --- a/api-client/typescript/models/StringFieldMissingValues.ts +++ b/api-client/typescript/models/StringFieldMissingValues.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/StringFieldMissingValuesAnyOfInner.ts b/api-client/typescript/models/StringFieldMissingValuesAnyOfInner.ts index d33e51e..20e3b40 100644 --- a/api-client/typescript/models/StringFieldMissingValuesAnyOfInner.ts +++ b/api-client/typescript/models/StringFieldMissingValuesAnyOfInner.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/Subscriber.ts b/api-client/typescript/models/Subscriber.ts index 582ebb6..72832d3 100644 --- a/api-client/typescript/models/Subscriber.ts +++ b/api-client/typescript/models/Subscriber.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/TableDialect.ts b/api-client/typescript/models/TableDialect.ts index ef30c8e..d9d18d4 100644 --- a/api-client/typescript/models/TableDialect.ts +++ b/api-client/typescript/models/TableDialect.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/TableSchemaField.ts b/api-client/typescript/models/TableSchemaField.ts index 6d0b264..099f9c9 100644 --- a/api-client/typescript/models/TableSchemaField.ts +++ b/api-client/typescript/models/TableSchemaField.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/TableSchemaForeignKey.ts b/api-client/typescript/models/TableSchemaForeignKey.ts index 201b4ca..582fe03 100644 --- a/api-client/typescript/models/TableSchemaForeignKey.ts +++ b/api-client/typescript/models/TableSchemaForeignKey.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/TableSchemaForeignKeyOneOf.ts b/api-client/typescript/models/TableSchemaForeignKeyOneOf.ts index de8703a..f00beb2 100644 --- a/api-client/typescript/models/TableSchemaForeignKeyOneOf.ts +++ b/api-client/typescript/models/TableSchemaForeignKeyOneOf.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/TableSchemaForeignKeyOneOf1.ts b/api-client/typescript/models/TableSchemaForeignKeyOneOf1.ts index ad47d5b..d6f0806 100644 --- a/api-client/typescript/models/TableSchemaForeignKeyOneOf1.ts +++ b/api-client/typescript/models/TableSchemaForeignKeyOneOf1.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/TableSchemaForeignKeyOneOf1Reference.ts b/api-client/typescript/models/TableSchemaForeignKeyOneOf1Reference.ts index c2ca5fa..32a5b8e 100644 --- a/api-client/typescript/models/TableSchemaForeignKeyOneOf1Reference.ts +++ b/api-client/typescript/models/TableSchemaForeignKeyOneOf1Reference.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/TableSchemaForeignKeyOneOfReference.ts b/api-client/typescript/models/TableSchemaForeignKeyOneOfReference.ts index 67553e8..54fea76 100644 --- a/api-client/typescript/models/TableSchemaForeignKeyOneOfReference.ts +++ b/api-client/typescript/models/TableSchemaForeignKeyOneOfReference.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/TableSchemaPrimaryKey.ts b/api-client/typescript/models/TableSchemaPrimaryKey.ts index bbfba2f..8d69df4 100644 --- a/api-client/typescript/models/TableSchemaPrimaryKey.ts +++ b/api-client/typescript/models/TableSchemaPrimaryKey.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/TimeField.ts b/api-client/typescript/models/TimeField.ts index 35bec7c..a336319 100644 --- a/api-client/typescript/models/TimeField.ts +++ b/api-client/typescript/models/TimeField.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/TransmissionMode.ts b/api-client/typescript/models/TransmissionMode.ts index 91aa517..27faccb 100644 --- a/api-client/typescript/models/TransmissionMode.ts +++ b/api-client/typescript/models/TransmissionMode.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/UnitForArea.ts b/api-client/typescript/models/UnitForArea.ts index 5e745a7..de9fde9 100644 --- a/api-client/typescript/models/UnitForArea.ts +++ b/api-client/typescript/models/UnitForArea.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/UserInfo.ts b/api-client/typescript/models/UserInfo.ts index 0ce7db8..326aae2 100644 --- a/api-client/typescript/models/UserInfo.ts +++ b/api-client/typescript/models/UserInfo.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/UserSession.ts b/api-client/typescript/models/UserSession.ts index 2b2426a..4377a31 100644 --- a/api-client/typescript/models/UserSession.ts +++ b/api-client/typescript/models/UserSession.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/YearField.ts b/api-client/typescript/models/YearField.ts index 4e89a32..13cc3ff 100644 --- a/api-client/typescript/models/YearField.ts +++ b/api-client/typescript/models/YearField.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/api-client/typescript/models/YearMonthField.ts b/api-client/typescript/models/YearMonthField.ts index fa14088..7fb53dd 100644 --- a/api-client/typescript/models/YearMonthField.ts +++ b/api-client/typescript/models/YearMonthField.ts @@ -2,7 +2,7 @@ * BioIS API * API for the BioIS service, providing access to geospatial processing and job management. * - * OpenAPI spec version: 0.1.0 + * OpenAPI spec version: 0.2.0 * Contact: info@geoengine.de * * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). diff --git a/backend/Cargo.lock b/backend/Cargo.lock index cd8150d..d0d659b 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -332,6 +332,7 @@ dependencies = [ "axum", "biois-macros", "chrono", + "clap", "config", "futures", "geo-types", diff --git a/backend/Cargo.toml b/backend/Cargo.toml index f8f50b8..c8e7abf 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -16,7 +16,7 @@ authors = [ license-file = "LICENSE" repository = "https://github.com/geo-engine/BioIS" edition = "2024" -default-run = "biois" +default-run = "biois-server" [workspace.lints.clippy] cargo = { level = "warn", priority = 0 } @@ -46,18 +46,13 @@ unwrap_used = { level = "warn", priority = 1 } multiple_crate_versions = { level = "allow", priority = 1 } [[bin]] -name = "biois" +name = "biois-server" path = "src/main.rs" bench = false [[bin]] -name = "openapi-generator" -path = "src/bin/openapi.rs" -bench = false - -[[bin]] -name = "database-cli" -path = "src/bin/database_cli.rs" +name = "biois-cli" +path = "src/bin/biois_cli.rs" bench = false [dependencies] @@ -67,6 +62,7 @@ async-trait = "0.1" axum = { version = "0.8", features = ["multipart"] } biois-macros = { path = "macros" } chrono = { version = "0.4", features = ["serde"] } +clap = { version = "4.6", features = ["derive"] } config = "0.15" futures = "0.3" geo-types = "0.7" diff --git a/backend/Dockerfile b/backend/Dockerfile index e8138a2..e96b3d3 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -8,9 +8,11 @@ WORKDIR /usr/src/biois # Cache dependencies: copy manifest first COPY \ + build.rs \ Cargo.toml \ Cargo.lock \ rust-toolchain.toml \ + Toasty.toml \ ./ # create dummy src to allow caching of cargo registry build step # This is a best practice so that code changes don't invalidate the entire cargo build cache @@ -20,13 +22,13 @@ RUN rm -rf src # Copy full source and build the release binary COPY conf ./conf -COPY migrations ./migrations +COPY database ./database +COPY macros ./macros COPY src ./src -COPY build.rs ./build.rs -RUN cargo build --release --bin biois +RUN cargo build --release --bin biois-server # Strip the binary to reduce size if possible -RUN strip target/release/biois || true +RUN strip target/release/biois-server || true # Create an empty directory to use as workdir in the final image RUN mkdir /emptydir @@ -35,7 +37,7 @@ RUN mkdir /emptydir FROM gcr.io/distroless/cc-debian13:nonroot # Copy ca-certificates and the built binary -COPY --from=builder /usr/src/biois/target/release/biois /usr/local/bin/biois +COPY --from=builder /usr/src/biois/target/release/biois-server /usr/local/bin/biois-server COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ COPY --from=builder /emptydir /biois @@ -45,4 +47,4 @@ WORKDIR /biois USER nonroot EXPOSE 4040 -ENTRYPOINT ["/usr/local/bin/biois"] +ENTRYPOINT ["/usr/local/bin/biois-server"] diff --git a/backend/README.md b/backend/README.md index 446833f..1a4601a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -35,13 +35,13 @@ The database schema is managed using [`toasty`](https://tokio-rs.github.io/toast To generate or update the schema, use the following commands: ```bash -just database-cli migration generate +just biois-cli database migration generate ``` More commands can be found in the help section of the `toasty` CLI: ```bash -just database-cli migration +just biois-cli database migration ``` ## Configuration diff --git a/backend/database/migrations/0001_migration.sql b/backend/database/migrations/0001_migration.sql index 36732e5..4287c94 100644 --- a/backend/database/migrations/0001_migration.sql +++ b/backend/database/migrations/0001_migration.sql @@ -1,6 +1,10 @@ +-- Quoted types are generated by toasty-cli +-- noqa: disable=RF06 + -- CREATE TYPE "Response" AS ENUM ('raw', 'document'); -- CREATE TYPE "JobType" AS ENUM ('process'); --- CREATE TYPE "StatusCode" AS ENUM ('accepted', 'running', 'successful', 'failed', 'dismissed'); +-- CREATE TYPE "StatusCode" AS +-- ENUM ('accepted', 'running', 'successful', 'failed', 'dismissed'); CREATE TABLE "credits" ( "timestamp" BIGINT NOT NULL, @@ -52,18 +56,22 @@ SELECT "job_type", "status", "message", - (EXTRACT(EPOCH FROM "created") * 1000)::BIGINT, -- Unix timestamp in milliseconds + -- Unix timestamp in milliseconds + (EXTRACT(EPOCH FROM "created") * 1000)::BIGINT AS "created", CASE WHEN "finished" IS NULL THEN NULL - ELSE (EXTRACT(EPOCH FROM "finished") * 1000)::BIGINT -- Unix timestamp in milliseconds - END, - (EXTRACT(EPOCH FROM "updated") * 1000)::BIGINT, -- Unix timestamp in milliseconds + -- Unix timestamp in milliseconds + ELSE (EXTRACT(EPOCH FROM "finished") * 1000)::BIGINT + END AS "finished", + -- Unix timestamp in milliseconds + (EXTRACT(EPOCH FROM "updated") * 1000)::BIGINT AS "updated", "progress", - to_jsonb("links"), + TO_JSONB("links") AS "links", "response", "results", "user_id" FROM "jobs_old"; --- Drop manually after checking that the migration worked as expected. This is a safety measure to prevent accidental data loss. +-- Drop manually after checking that the migration worked as expected. +-- This is a safety measure to prevent accidental data loss. -- DROP TABLE "jobs_old"; diff --git a/backend/src/bin/biois_cli.rs b/backend/src/bin/biois_cli.rs new file mode 100644 index 0000000..296e379 --- /dev/null +++ b/backend/src/bin/biois_cli.rs @@ -0,0 +1,88 @@ +use anyhow::{Context, Result}; +use biois::{CONFIG, db::DbHandle, server, setup_tracing}; +use clap::Parser; +use std::ffi::OsString; +use toasty_cli::{Config, ToastyCli}; + +#[tokio::main] +async fn main() -> Result<()> { + let toasty_config = Config::load()?; + + let db = DbHandle::from_config(&CONFIG.database).await?; + + let toasty_cli = ToastyCli::with_config(db.db(), toasty_config); + + match Cli::parse().command { + Command::Database(cmd) => cmd.run(&toasty_cli).await?, + Command::OpenAPI(cmd) => cmd.run().await?, + } + + Ok(()) +} + +#[derive(Parser, Debug)] +#[command(name = "BioIS CLI")] +#[command(about = "BioIS CLI - BioIS Management Tool")] +#[command(version)] +struct Cli { + #[command(subcommand)] + command: Command, +} + +#[derive(Parser, Debug)] +enum Command { + /// Database migration commands + Database(DatabaseCommand), + + #[command(name = "openapi")] + OpenAPI(OpenAPICommand), +} + +#[derive(Parser, Debug)] +struct DatabaseCommand { + #[arg()] + args: Vec, +} + +impl DatabaseCommand { + async fn run(self, toasty_cli: &ToastyCli) -> Result<()> { + let args = std::iter::once(OsString::from("toasty")).chain(self.args.into_iter()); + toasty_cli.parse_from(args).await + } +} + +#[derive(Parser, Debug)] +struct OpenAPICommand; + +impl OpenAPICommand { + async fn run(self) -> Result<()> { + let _tracing_guard = setup_tracing(CONFIG.logging.clone().into()); + println!("{}", openapi_json().await?); + + Ok(()) + } +} + +async fn openapi_json() -> Result { + let mut service = server().await?; + + service + .get_router_mut() + .get_openapi_mut() + .to_pretty_json() + .context("Failed to serialize OpenAPI spec as JSON") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test(flavor = "multi_thread")] + async fn it_fetches_openapi_spec_successfully() { + let openapi_json: serde_json::Value = + serde_json::from_str(&openapi_json().await.unwrap()).unwrap(); + + assert_eq!(openapi_json["openapi"], "3.1.0"); + assert_eq!(openapi_json["info"]["title"], "BioIS API"); + } +} diff --git a/backend/src/bin/database_cli.rs b/backend/src/bin/database_cli.rs deleted file mode 100644 index ff6410d..0000000 --- a/backend/src/bin/database_cli.rs +++ /dev/null @@ -1,15 +0,0 @@ -use anyhow::Result; -use biois::{CONFIG, db::DbHandle}; -use toasty_cli::{Config, ToastyCli}; - -#[tokio::main] -async fn main() -> Result<()> { - let toasty_config = Config::load()?; - - let db = DbHandle::from_config(&CONFIG.database).await?; - - let cli = ToastyCli::with_config(db.db(), toasty_config); - cli.parse_and_run().await?; - - Ok(()) -} diff --git a/justfile b/justfile index edabb7c..7a87074 100644 --- a/justfile +++ b/justfile @@ -143,7 +143,7 @@ lint-backend-toasty: _clear expected = tomllib.loads(latest_file.read_text()) generated = subprocess.check_output( - ["cargo", "run", "--bin", "database-cli", "--", "migration", "snapshot"], + ["cargo", "run", "--bin", "biois-cli", "--", "database", "migration", "snapshot"], text=True, ) generated = "\n".join( @@ -300,7 +300,7 @@ down-pod: [group('misc')] [working-directory('backend')] generate-openapi-spec: _clear - cargo run --bin openapi > ../openapi.json + cargo run --bin biois-cli -- openapi > ../openapi.json # Run an arbitrary Angular CLI command in the frontend. Usage: `just ng -- build`. [group('frontend')] @@ -321,6 +321,6 @@ check-no-changes-in-git-repo: [group('backend')] [working-directory('backend')] -database-cli +ARGS="": _clear - cargo run --bin database-cli -- {{ ARGS }} +biois-cli +ARGS="": _clear + cargo run --bin biois-cli -- {{ ARGS }} \ No newline at end of file diff --git a/openapi.json b/openapi.json index 4b094f4..f0402be 100644 --- a/openapi.json +++ b/openapi.json @@ -8,7 +8,7 @@ "url": "https://www.geoengine.de/en/", "email": "info@geoengine.de" }, - "version": "0.1.0" + "version": "0.2.0" }, "paths": { "/": { @@ -998,7 +998,7 @@ }, "creditsUsed": { "type": "integer", - "format": "int32", + "format": "int64", "minimum": 0 } } @@ -1150,7 +1150,7 @@ }, "creditsUsed": { "type": "integer", - "format": "int32", + "format": "int64", "minimum": 0 }, "details": { From e37c58ae4a399343a6e322016335914b712b64e3 Mon Sep 17 00:00:00 2001 From: Christian Beilschmidt Date: Thu, 13 Aug 2026 13:30:19 +0200 Subject: [PATCH 08/11] fix: update Dockerfile for macros and improve CLI command argument handling --- backend/Dockerfile | 3 +++ backend/src/bin/biois_cli.rs | 7 ++++++- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index e96b3d3..c284740 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -17,6 +17,8 @@ COPY \ # create dummy src to allow caching of cargo registry build step # This is a best practice so that code changes don't invalidate the entire cargo build cache RUN mkdir src && printf "fn main() { println!(\"cargo build cache\"); }\n" > src/main.rs +RUN mkdir macros && printf "fn main() { println!(\"cargo build cache\"); }\n" > macros/main.rs +COPY macros/Cargo.toml ./macros/Cargo.toml RUN cargo build --release RUN rm -rf src @@ -25,6 +27,7 @@ COPY conf ./conf COPY database ./database COPY macros ./macros COPY src ./src +COPY Toasty.toml ./ RUN cargo build --release --bin biois-server # Strip the binary to reduce size if possible diff --git a/backend/src/bin/biois_cli.rs b/backend/src/bin/biois_cli.rs index 296e379..e16bce0 100644 --- a/backend/src/bin/biois_cli.rs +++ b/backend/src/bin/biois_cli.rs @@ -46,7 +46,7 @@ struct DatabaseCommand { impl DatabaseCommand { async fn run(self, toasty_cli: &ToastyCli) -> Result<()> { - let args = std::iter::once(OsString::from("toasty")).chain(self.args.into_iter()); + let args = std::iter::once(OsString::from("toasty")).chain(self.args); toasty_cli.parse_from(args).await } } @@ -55,8 +55,13 @@ impl DatabaseCommand { struct OpenAPICommand; impl OpenAPICommand { + #[allow( + clippy::print_stdout, + reason = "This is a CLI command, printing to stdout is intended behavior" + )] async fn run(self) -> Result<()> { let _tracing_guard = setup_tracing(CONFIG.logging.clone().into()); + println!("{}", openapi_json().await?); Ok(()) From 5e744c799268d001586503a4c81d1ce4c65dd4a9 Mon Sep 17 00:00:00 2001 From: Christian Beilschmidt Date: Thu, 13 Aug 2026 16:07:04 +0200 Subject: [PATCH 09/11] fix: update Dockerfile to improve caching and structure for Rust build --- backend/Dockerfile | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/backend/Dockerfile b/backend/Dockerfile index c284740..0741021 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -16,11 +16,15 @@ COPY \ ./ # create dummy src to allow caching of cargo registry build step # This is a best practice so that code changes don't invalidate the entire cargo build cache -RUN mkdir src && printf "fn main() { println!(\"cargo build cache\"); }\n" > src/main.rs -RUN mkdir macros && printf "fn main() { println!(\"cargo build cache\"); }\n" > macros/main.rs -COPY macros/Cargo.toml ./macros/Cargo.toml +RUN mkdir src && printf "fn main() { eprintln!(\"cargo build cache\"); }\n" > src/main.rs +RUN mkdir -p src/bin && printf "fn main() { eprintln!(\"cargo build cache\"); }\n" > src/bin/biois_cli.rs +RUN mkdir -p macros/src && touch macros/src/lib.rs +COPY macros/Cargo.toml macros/Cargo.toml RUN cargo build --release -RUN rm -rf src +RUN rm -rf \ + src \ + macros \ + target/release/deps/libbiois_macros-* # Copy full source and build the release binary COPY conf ./conf From a246d4c225d80236ce4106e8c55e69761026eadc Mon Sep 17 00:00:00 2001 From: Christian Beilschmidt Date: Fri, 14 Aug 2026 07:29:10 +0200 Subject: [PATCH 10/11] build: use new toasty dependency for correct enum types --- backend/Cargo.lock | 108 +++++++++++++++++++--------------------- backend/Cargo.toml | 11 ++-- backend/src/db/model.rs | 9 ++-- backend/src/jobs.rs | 10 ++-- mock/.gitignore | 1 - mock/DE5118301.csv | 2 - mock/DE5317307.csv | 2 - mock/README.md | 16 ------ 8 files changed, 67 insertions(+), 92 deletions(-) delete mode 100644 mock/.gitignore delete mode 100644 mock/DE5118301.csv delete mode 100644 mock/DE5317307.csv delete mode 100644 mock/README.md diff --git a/backend/Cargo.lock b/backend/Cargo.lock index d0d659b..008c10f 100644 --- a/backend/Cargo.lock +++ b/backend/Cargo.lock @@ -1753,9 +1753,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -1913,9 +1913,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -1927,9 +1927,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1940,9 +1940,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1954,16 +1954,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1974,15 +1975,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -2249,7 +2250,7 @@ dependencies = [ "bitflags 2.13.1", "libc", "plain", - "redox_syscall 0.9.1", + "redox_syscall 0.9.2", ] [[package]] @@ -2270,9 +2271,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -2847,9 +2848,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.8" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" dependencies = [ "memchr", "ucd-trie", @@ -2857,9 +2858,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.8" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" dependencies = [ "pest", "pest_generator", @@ -2867,9 +2868,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.8" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" dependencies = [ "pest", "pest_meta", @@ -2880,9 +2881,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.8" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" dependencies = [ "pest", ] @@ -3023,9 +3024,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -3212,9 +3213,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07507be7b4a5f9f26eeb41eeaebb1f5a7ff29dfb29739facc21d35bf8b11c21e" +checksum = "f1c93da5bb2c5d4e6c0ef7abeead62c89169a0a4882bfb83ac892f2423aea2fe" dependencies = [ "bitflags 2.13.1", ] @@ -4555,9 +4556,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -4603,8 +4604,7 @@ dependencies = [ [[package]] name = "toasty" version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c526dee282a42d5a2e61b189b8767181586a74a3d6494ef9076fc8d8a1daa7b9" +source = "git+https://github.com/tokio-rs/toasty?branch=main#e3f26668ae2866704ac1bc6a14101ae6c053056a" dependencies = [ "async-trait", "bit-set", @@ -4632,8 +4632,7 @@ dependencies = [ [[package]] name = "toasty-cli" version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b31a55c15b5bd72db9465f00966ade3ac522eb34d80752ecb3038b991240bbf" +source = "git+https://github.com/tokio-rs/toasty?branch=main#e3f26668ae2866704ac1bc6a14101ae6c053056a" dependencies = [ "anyhow", "clap", @@ -4651,8 +4650,7 @@ dependencies = [ [[package]] name = "toasty-core" version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce096128c41a7b5a86b0a3570a8ffa866de889a081281956bb73c6a7ba0b88a" +source = "git+https://github.com/tokio-rs/toasty?branch=main#e3f26668ae2866704ac1bc6a14101ae6c053056a" dependencies = [ "async-trait", "bit-set", @@ -4672,8 +4670,7 @@ dependencies = [ [[package]] name = "toasty-driver-mysql" version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a7a281dccebd99defe5e84ab56e8871cf22296ac3bcca8d333e427697c8b7ea" +source = "git+https://github.com/tokio-rs/toasty?branch=main#e3f26668ae2866704ac1bc6a14101ae6c053056a" dependencies = [ "async-trait", "sqlx-core 0.9.0", @@ -4689,8 +4686,7 @@ dependencies = [ [[package]] name = "toasty-driver-postgresql" version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "695e13090ba6c14ee3c827f539b2301a712127f503aca234c5b7f7af00c504e0" +source = "git+https://github.com/tokio-rs/toasty?branch=main#e3f26668ae2866704ac1bc6a14101ae6c053056a" dependencies = [ "async-trait", "fallible-iterator", @@ -4714,8 +4710,7 @@ dependencies = [ [[package]] name = "toasty-macros" version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a46f85a2a258f59e32789b2de7a5b171e4c68835311dcec9ca71ef6e7901714d" +source = "git+https://github.com/tokio-rs/toasty?branch=main#e3f26668ae2866704ac1bc6a14101ae6c053056a" dependencies = [ "hashbrown 0.17.1", "heck", @@ -4729,8 +4724,7 @@ dependencies = [ [[package]] name = "toasty-sql" version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b44b08035efcf8e087c6377ae18942066b1f8e976cd3821571e45463ecf3d6f" +source = "git+https://github.com/tokio-rs/toasty?branch=main#e3f26668ae2866704ac1bc6a14101ae6c053056a" dependencies = [ "serde", "serde_json", @@ -5427,7 +5421,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -5678,9 +5672,9 @@ dependencies = [ [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "x509-cert" @@ -5797,9 +5791,9 @@ dependencies = [ [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -5808,9 +5802,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "yoke", "zerofrom", @@ -5819,13 +5813,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] diff --git a/backend/Cargo.toml b/backend/Cargo.toml index c8e7abf..512636b 100644 --- a/backend/Cargo.toml +++ b/backend/Cargo.toml @@ -82,9 +82,14 @@ schemars = "1.2" serde = { version = "1.0", features = ["derive", "rc"] } serde_json = "1.0" tempfile = "3.27" -toasty = { version = "0.10", features = ["postgresql", "serde"] } -toasty-cli = "0.10" - +# toasty = { version = "0.10", features = ["postgresql", "serde"] } +toasty = { git = "https://github.com/tokio-rs/toasty", branch = "main", features = [ + "migration", + "postgresql", + "serde", +] } +# toasty-cli = "0.10" +toasty-cli = { git = "https://github.com/tokio-rs/toasty", branch = "main" } tokio = { version = "1.49", features = ["rt-multi-thread", "macros"] } tower = "0.5" tracing = "0.1" diff --git a/backend/src/db/model.rs b/backend/src/db/model.rs index 314c2f4..b6314d3 100644 --- a/backend/src/db/model.rs +++ b/backend/src/db/model.rs @@ -6,6 +6,7 @@ use anyhow::Context; use chrono::{DateTime, Utc}; use o2o::o2o; +use ogcapi::types::processes::ExecuteResults; use serde::{Deserialize, Serialize}; use std::str::FromStr; use uuid::Uuid; @@ -15,7 +16,7 @@ use uuid::Uuid; #[serde(rename_all = "PascalCase")] #[from_owned(ogcapi::types::processes::StatusCode)] #[owned_into(ogcapi::types::processes::StatusCode)] -// #[column(type = enum("StatusCode"))] +#[column(type = enum("StatusCode"))] pub enum StatusCode { Accepted, Running, @@ -29,7 +30,7 @@ pub enum StatusCode { #[serde(rename_all = "PascalCase")] #[from_owned(ogcapi::types::processes::JobType)] #[owned_into(ogcapi::types::processes::JobType)] -// #[column(type = enum("JobType"))] +#[column(type = enum("JobType"))] pub enum JobType { Process, } @@ -39,7 +40,7 @@ pub enum JobType { #[serde(rename_all = "PascalCase")] #[from_owned(ogcapi::types::processes::Response)] #[owned_into(ogcapi::types::processes::Response)] -// #[column(type = enum("Response"))] +#[column(type = enum("Response"))] pub enum Response { Raw, Document, @@ -109,7 +110,7 @@ pub struct Job { /// Job results /// TODO: make enum of all possible result types #[column(type = "jsonb")] - pub results: Option, + pub results: Option>, /// User ID who created the job pub user_id: uuid::Uuid, diff --git a/backend/src/jobs.rs b/backend/src/jobs.rs index 60d0b13..2724f5f 100644 --- a/backend/src/jobs.rs +++ b/backend/src/jobs.rs @@ -160,7 +160,6 @@ impl ogcapi::drivers::JobHandler for JobHandler { ) -> anyhow::Result<()> { let now_ms: TimestampMillis = Utc::now().into(); let job_id = Uuid::parse_str(job_id).context("Invalid job ID format")?; - let results_json = results.map(serde_json::to_value).transpose()?; toasty::update!(Job::filter_by_job_id(job_id) { status: StatusCode::from(status.clone()), @@ -169,7 +168,7 @@ impl ogcapi::drivers::JobHandler for JobHandler { finished: Some(now_ms), progress: Some(100i16), links: links.iter().cloned().map(Into::into).collect::>(), - results: results_json, + results: results.map(toasty::Json), }) .exec(&mut self.db.db()) .await @@ -233,16 +232,13 @@ impl ogcapi::drivers::JobHandler for JobHandler { return Ok(ProcessResult::NoSuchJob); }; - let Some(results_json) = job.results else { + let Some(toasty::Json(results)) = job.results else { return Ok(ProcessResult::NotReady); }; - let results: ExecuteResults = serde_json::from_value(results_json)?; - let response_mode = job.response; - Ok(ProcessResult::Results { results, - response_mode: response_mode.into(), + response_mode: job.response.into(), }) } } diff --git a/mock/.gitignore b/mock/.gitignore deleted file mode 100644 index 50281c0..0000000 --- a/mock/.gitignore +++ /dev/null @@ -1 +0,0 @@ -Natura2000_end2024.gpkg \ No newline at end of file diff --git a/mock/DE5118301.csv b/mock/DE5118301.csv deleted file mode 100644 index 403f105..0000000 --- a/mock/DE5118301.csv +++ /dev/null @@ -1,2 +0,0 @@ -geom,SITECODE,SITENAME,MS,SITETYPE,INSPIRE_ID -"POLYGON ((4233257.1713 3078409.1509,4233263.4523 3078431.9868,4233264.1359 3078434.4576,4233271.0149 3078463.0258,4233280.7104 3078489.1151,4233286.0836 3078505.4758,4233250.2519 3078516.1909,4233188.5052 3078520.3112,4233186.4984 3078519.82870001,4233169.8726 3078515.8462,4233149.9859 3078511.07829999,4233139.5709 3078508.5808,4233130.0991 3078506.31040001,4233099.8483 3078498.3843,4233095.9531 3078496.5575,4233098.3034 3078486.2644,4233101.8249 3078469.4247,4233109.4548 3078441.3978,4233110.5885 3078437.2519,4233114.4138 3078420.6881,4233104.7411 3078418.3505,4233095.5298 3078416.1165,4233081.4579 3078411.669,4233062.6857 3078406.4958,4233043.1034 3078399.8436,4233028.3675 3078393.635,4233003.7204 3078432.286,4233006.3636 3078435.45,4233004.6485 3078438.7638,4232992.5637 3078462.0414,4232990.0987 3078459.4849,4232987.8164 3078457.126,4232985.9246 3078458.4821,4232981.3784 3078461.7346,4232976.3172 3078465.3643,4232972.3752 3078468.1886,4232967.1377 3078461.7898,4232961.1665 3078455.1211,4232955.1499 3078453.2133,4232922.3489 3078426.1506,4232919.4965 3078423.7894,4232866.7838 3078403.38010001,4232865.7584 3078402.9841,4232848.9583 3078396.4838,4232843.67 3078394.4361,4232839.5076 3078392.823,4232827.6756 3078381.7242,4232800.3544 3078356.0864,4232791.5502 3078351.4267,4232776.6725 3078345.08,4232766.2535 3078341.5525,4232762.8541 3078340.8891,4232757.3008 3078338.475,4232678.5085 3078332.2642,4232623.6899 3078320.9945,4232598.572 3078325.7491,4232575.6968 3078324.0625,4232536.1949 3078319.0634,4232449.4514 3078301.0407,4232420.1518 3078297.6119,4232404.5248 3078292.7957,4232397.9844 3078285.4547,4232389.7694 3078276.2405,4232381.4517 3078266.91070001,4232371.955 3078256.2589,4232361.4539 3078244.4819,4232360.612 3078243.5383,4232352.2914 3078234.2106,4232351.4505 3078233.268,4232371.1954 3078224.6567,4232391.2485 3078215.9111,4232401.0612 3078211.6163,4232428.9419 3078199.3992,4232438.4603 3078199.5107,4232439.8637 3078199.5275,4232449.0981 3078199.636,4232525.9501 3078176.0417,4232603.7821 3078155.3104,4232676.5342 3078144.3354,4232676.1464 3078141.6345,4232704.1734 3078137.9691,4232716.9838 3078135.5514,4232720.3376 3078150.3016,4232752.8738 3078143.8242,4232768.0785 3078140.8936,4232785.6448 3078179.6331,4232803.2493 3078176.8917,4232812.7603 3078175.4212,4232829.428 3078194.2644,4232832.3609 3078198.1345,4232845.0492 3078214.842,4232844.5306 3078215.6792,4232858.8114 3078230.3547,4232867.4101 3078224.3064,4232893.5233 3078205.957,4232916.8132 3078189.5865,4232906.1226 3078171.9615,4232936.0302 3078196.5837,4232947.7903 3078206.8034,4232938.2718 3078181.9718,4232940.0014 3078168.6869,4232941.759 3078155.2517,4232949.6338 3078127.5815,4232960.3203 3078105.1732,4232985.8377 3078079.0413,4232995.7687 3078075.3649,4233010.3694 3078070.5274,4233036.8279 3078061.76110001,4233037.4846 3078066.66249999,4233069.9862 3078071.6875,4233099.7917 3078076.2895,4233105.9282 3078077.2375,4233119.7636 3078079.3741,4233126.8512 3078080.469,4233144.8107 3078083.24319999,4233146.5429 3078078.239,4233166.6372 3078084.2942,4233187.0227 3078090.4353,4233189.9248 3078091.3056,4233204.7626 3078097.6528,4233206.4368 3078098.69,4233228.8379 3078112.5842,4233230.4363 3078113.1923,4233231.6529 3078113.6657,4233231.1746 3078114.5323,4233242.9325 3078120.9118,4233244.1907 3078118.5643,4233255.5863 3078123.30860001,4233267.4327 3078129.57680001,4233269.8301 3078127.89380001,4233274.7762 3078131.9863,4233287.6727 3078142.6705,4233288.5931 3078143.428,4233306.3626 3078160.1859,4233320.7891 3078173.79929999,4233325.894 3078182.2201,4233327.5766 3078188.1355,4233331.405 3078201.6012,4233332.4374 3078205.2324,4233318.3387 3078207.6358,4233323.9426 3078232.161,4233324.4172 3078245.0056,4233325.607 3078244.8353,4233339.4664 3078242.8482,4233340.6172 3078242.6834,4233341.157 3078248.5465,4233324.6594 3078271.6545,4233316.5342 3078301.4984,4233309.1018 3078325.6623,4233297.1394 3078351.32840001,4233283.5093 3078366.1364,4233270.5998 3078374.364,4233253.0479 3078383.9153,4233230.1388 3078392.98,4233213.105 3078396.5037,4233230.7347 3078397.09219999,4233231.0825 3078399.8676,4233243.6548 3078396.265,4233252.7846 3078393.2397,4233257.1713 3078409.1509),(4233025.2402 3078336.463,4233029.1383 3078340.7099,4233046.4691 3078353.8836,4233047.5339 3078354.229,4233071.6516 3078361.9792,4233092.3943 3078368.6455,4233114.3162 3078375.9857,4233141.0753 3078384.5497,4233145.7692 3078365.6738,4233151.573 3078342.3623,4233147.2408 3078338.5514,4233146.02 3078329.6773,4233147.2624 3078316.59920001,4233147.988 3078308.90860001,4233147.1795 3078305.3494,4233123.6843 3078291.89020001,4233118.0802 3078288.6868,4233086.4743 3078272.2985,4233083.1304 3078270.5642,4233067.8093 3078261.03330001,4233050.7634 3078250.426,4233041.4665 3078248.5132,4233031.9697 3078275.3056,4233039.4449 3078279.31349999,4233045.2578 3078282.4241,4233052.046 3078282.9812,4233052.6527 3078288.6233,4233047.1707 3078303.2297,4233045.789 3078305.3188,4233045.25 3078306.1362,4233044.2997 3078307.5894,4233025.2402 3078336.463))",DE5118301,Dammelsberg und Köhlersgrund,DE,B, diff --git a/mock/DE5317307.csv b/mock/DE5317307.csv deleted file mode 100644 index 1203f98..0000000 --- a/mock/DE5317307.csv +++ /dev/null @@ -1,2 +0,0 @@ -geom,SITECODE,SITENAME,MS,SITETYPE,INSPIRE_ID -"MULTIPOLYGON (((4223514.0885 3062853.3676,4223522.2607 3062867.097,4223522.7547 3062867.927,4223519.8257 3062884.6048,4223518.5232 3062890.8667,4223483.7534 3062890.0781,4223469.9248 3062889.7644,4223462.6231 3062889.5988,4223461.2618 3062889.5679,4223460.8502 3062876.2026,4223455.2076 3062843.432,4223454.686 3062840.4028,4223454.6505 3062840.1971,4223454.6072 3062839.9456,4223454.5909 3062839.8691,4223454.324 3062839.4812,4223453.3765 3062837.7652,4223453.3572 3062837.6747,4223453.3254 3062837.6284,4223453.2246 3062837.3587,4223453.195 3062837.3134,4223453.1439 3062837.1766,4223453.1311 3062837.1085,4223452.9846 3062836.7165,4223452.7843 3062836.08869999,4223452.6697 3062836.0368,4223452.6193 3062836.0024,4223452.2561 3062835.83809999,4223451.8373 3062835.625,4223451.4343 3062835.0393,4223450.7796 3062834.6147,4223450.0123 3062834.473,4223449.8169 3062834.4913,4223449.2481 3062834.1805,4223448.8824 3062833.9187,4223447.7294 3062832.9545,4223447.6913 3062832.9093,4223447.6644 3062832.8871,4223447.6282 3062832.8345,4223447.3826 3062832.5434,4223447.231 3062832.4382,4223445.8592 3062830.9257,4223445.614 3062830.54439999,4223445.0332 3062829.8339,4223444.9267 3062829.7093,4223444.8969 3062829.6671,4223444.8557 3062829.6167,4223444.6809 3062829.3448,4223444.201 3062828.4427,4223444.0632 3062828.1968,4223443.9338 3062828.0138,4223442.82 3062825.5969,4223442.8001 3062825.5091,4223442.735 3062825.39120001,4223442.7161 3062825.3638,4223442.7107 3062825.3474,4223442.7005 3062825.3288,4223442.6925 3062825.2914,4223442.6442 3062825.1427,4223442.6283 3062825.1196,4223442.4972 3062824.7398,4223442.4339 3062824.64019999,4223442.4249 3062824.614,4223442.0387 3062824.3532,4223440.6611 3062823.2328,4223440.6189 3062823.1715,4223440.5776 3062823.1447,4223440.4085 3062822.96629999,4223440.377 3062822.9449,4223440.2935 3062822.8569,4223440.2678 3062822.8179,4223440.0213 3062822.5577,4223438.4657 3062820.77749999,4223438.1815 3062820.3264,4223437.5355 3062819.5286,4223437.2872 3062819.1345,4223436.5622 3062817.7563,4223436.1607 3062816.647,4223436.0602 3062816.30829999,4223435.9838 3062815.8057,4223435.7922 3062815.15940001,4223435.7594 3062814.9687,4223435.7141 3062814.95520001,4223435.4571 3062814.9194,4223435.2026 3062814.8434,4223434.3654 3062814.5523,4223432.6241 3062813.652,4223432.1331 3062813.3335,4223430.601 3062812.1109,4223430.5266 3062812.0028,4223430.4707 3062811.9665,4223430.2273 3062811.69679999,4223430.1512 3062811.6439,4223430.021 3062811.4996,4223429.9514 3062811.3911,4223429.6024 3062811.0044,4223427.7693 3062808.6875,4223427.5111 3062808.2546,4223427.2944 3062808.0011,4223427.2606 3062807.9533,4223427.1692 3062807.7902,4223427.0235 3062807.6057,4223426.8714 3062807.3507,4223426.4324 3062806.4908,4223426.344 3062806.3658,4223425.5944 3062804.7394,4223424.6019 3062803.2342,4223424.3039 3062802.6816,4223424.2207 3062802.5714,4223422.9715 3062802.4259,4223422.7083 3062802.3594,4223422.2857 3062802.325,4223419.9379 3062801.9068,4223419.3013 3062801.72540001,4223418.9508 3062801.6788,4223418.6589 3062801.5957,4223417.6999 3062801.269,4223416.3444 3062800.6067,4223415.955 3062800.3785,4223415.2039 3062799.8065,4223414.7146 3062799.5198,4223413.0751 3062798.19110001,4223412.7801 3062797.92900001,4223412.7295 3062797.8978,4223412.6279 3062797.8075,4223412.6002 3062797.7692,4223412.3976 3062797.5892,4223412.3717 3062797.5515,4223412.3507 3062797.5343,4223412.296 3062797.4989,4223412.2712 3062797.4695,4223412.2503 3062797.45240001,4223412.2215 3062797.4106,4223412.0627 3062797.2224,4223411.9456 3062797.1428,4223410.9402 3062796.0756,4223410.7491 3062795.78460001,4223409.8596 3062794.7069,4223409.5736 3062794.2712,4223408.7473 3062792.7355,4223408.7385 3062792.7079,4223408.728 3062792.6884,4223408.7192 3062792.6472,4223408.663 3062792.4708,4223408.5992 3062792.3746,4223408.3192 3062791.65779999,4223408.277 3062791.4418,4223407.9625 3062790.5386,4223407.888 3062790.26279999,4223407.8617 3062790.07389999,4223407.8195 3062789.9375,4223407.8023 3062789.8491,4223407.753 3062789.3742,4223407.6156 3062788.8653,4223407.2101 3062785.5379,4223407.1795 3062785.0855,4223407.1594 3062785.002,4223407.1485 3062784.8409,4223407.1587 3062784.7762,4223407.1376 3062784.464,4223407.1784 3062784.2429,4223407.1417 3062784.0703,4223407.1685 3062783.6533,4223407.1464 3062783.5254,4223407.1613 3062783.2928,4223407.2038 3062783.1046,4223407.2414 3062782.5191,4223407.6092 3062779.94710001,4223406.5868 3062778.2947,4223405.208 3062776.7469,4223402.8746 3062774.5475,4223400.2821 3062772.35109999,4223400.1654 3062772.1935,4223399.8288 3062771.9248,4223399.7725 3062771.8883,4223399.7482 3062771.8605,4223399.6961 3062771.8189,4223398.7771 3062770.7923,4223398.5239 3062770.4668,4223398.1438 3062769.8236,4223397.7549 3062769.3236,4223396.002 3062766.2849,4223395.7856 3062765.8532,4223395.725 3062765.77039999,4223395.6464 3062765.6138,4223395.6283 3062765.5396,4223395.4784 3062765.2406,4223395.4653 3062765.1791,4223395.3999 3062765.08400001,4223395.0106 3062764.0156,4223394.9124 3062763.6895,4223394.8415 3062763.2299,4223394.6469 3062762.5839,4223394.3564 3062760.9756,4223393.7076 3062759.67999999,4223393.6651 3062759.5057,4223393.5167 3062759.2322,4223393.4632 3062759.1545,4223393.4451 3062759.1004,4223393.4101 3062759.0359,4223393.3865 3062758.9249,4223393.3694 3062758.874,4223393.3004 3062758.7705,4223392.5465 3062756.8698,4223392.2526 3062756.1287,4223391.0809 3062754.3658,4223390.9542 3062754.1318,4223387.4681 3062749.7144,4223382.7363 3062743.7184,4223381.9645 3062742.7404,4223381.1619 3062741.7235,4223380.9478 3062741.4521,4223380.5609 3062741.25850001,4223380.2422 3062741.099,4223379.8091 3062740.834,4223378.7837 3062740.0376,4223378.4408 3062739.82780001,4223376.6175 3062738.23140001,4223376.5211 3062738.0989,4223376.2797 3062737.9062,4223376.2312 3062737.8748,4223376.2109 3062737.85129999,4223376.1733 3062737.8213,4223375.7936 3062737.3697,4223375.119 3062736.5913,4223375.0971 3062736.56110001,4223375.0623 3062736.50010001,4223374.8899 3062736.2951,4223374.5977 3062735.8673,4223374.58 3062735.835,4223374.5571 3062735.8078,4223374.5542 3062735.8035,4223373.6639 3062734.18469999,4223373.6299 3062734.1251,4223373.6283 3062734.12,4223373.6259 3062734.1156,4223373.6239 3062734.106,4223373.6138 3062734.0743,4223373.5992 3062734.0529,4223372.9783 3062732.5824,4223372.2342 3062731.6264,4223371.0023 3062730.6745,4223368.4656 3062729.5949,4223368.3775 3062729.5363,4223367.6311 3062729.1892,4223367.3973 3062729.0641,4223367.1379 3062728.87429999,4223366.8428 3062728.7195,4223366.812 3062728.699,4223366.7383 3062728.6395,4223366.4373 3062728.47830001,4223362.9818 3062726.0936,4223362.5092 3062725.70530001,4223362.3127 3062725.5892,4223362.1186 3062725.4298,4223361.4942 3062724.8711,4223360.2301 3062723.373,4223359.8983 3062722.8908,4223358.9508 3062721.1749,4223358.5233 3062719.9763,4223358.4184 3062719.61,4223358.3292 3062719.0045,4223358.1466 3062718.36690001,4223358.1276 3062718.243,4223356.7048 3062718.302,4223356.6091 3062718.2857,4223356.4622 3062718.3022,4223356.4272 3062718.3097,4223356.4087 3062718.3083,4223356.3856 3062718.31090001,4223353.2333 3062718.3541,4223352.9388 3062718.33390001,4223352.5263 3062717.8107,4223352.3506 3062717.6157,4223352.2904 3062717.5767,4223352.2548 3062717.5283,4223339.8118 3062706.9006,4223339.7449 3062706.8602,4223339.5741 3062706.7262,4223333.3329 3062702.8196,4223333.2199 3062701.8132,4223333.3089 3062700.3401,4223333.3679 3062699.9025,4223333.5942 3062699.04030001,4223333.6727 3062698.4584,4223334.275 3062696.3783,4223334.4107 3062695.9829,4223334.4216 3062695.9147,4223334.4689 3062695.7767,4223334.4972 3062695.7307,4223334.5905 3062695.4587,4223334.6849 3062695.3131,4223334.7132 3062695.1598,4223334.8568 3062694.8536,4223334.8733 3062694.777,4223334.9488 3062694.61610001,4223335.009 3062694.529,4223335.2161 3062694.0872,4223335.7806 3062693.0479,4223335.2443 3062692.1468,4223331.3857 3062686.4423,4223331.0758 3062685.8758,4223330.8955 3062685.6385,4223330.8077 3062685.4779,4223330.605 3062685.06210001,4223330.5456 3062684.9794,4223327.7845 3062679.2321,4223325.7189 3062675.7094,4223325.4913 3062675.2593,4223325.4221 3062675.1653,4223325.3382 3062674.9994,4223325.3179 3062674.9167,4223325.1596 3062674.6038,4223325.1461 3062674.5401,4223325.0757 3062674.4379,4223324.4813 3062672.57,4223324.3593 3062671.9976,4223324.1407 3062670.0496,4223324.1553 3062669.4939,4223324.1531 3062669.4911,4223322.4601 3062667.3463,4223319.1703 3062663.7012,4223315.9538 3062657.3548,4223304.5538 3062634.8413,4223304.3143 3062634.3684,4223302.5251 3062633.5606,4223302.38 3062633.4617,4223302.3717 3062633.4577,4223302.2615 3062633.4332,4223302.1482 3062633.37830001,4223302.0149 3062633.2849,4223301.5112 3062633.0409,4223297.6979 3062630.8412,4223297.1648 3062630.4381,4223296.663 3062630.1553,4223296.3665 3062629.9311,4223295.4247 3062629.1222,4223295.3825 3062629.0609,4223295.3412 3062629.0341,4223295.1721 3062628.8556,4223295.1405 3062628.8343,4223295.0571 3062628.7462,4223295.0314 3062628.7073,4223294.7849 3062628.4471,4223291.8079 3062625.0403,4223288.1085 3062621.098,4223287.9921 3062620.9203,4223287.501 3062620.3751,4223287.3515 3062620.1856,4223287.207 3062619.9427,4223287.0361 3062619.7354,4223286.9897 3062619.6647,4223286.8587 3062619.4209,4223286.6077 3062619.10250001,4223283.1045 3062613.2053,4223282.868 3062612.7411,4223282.7916 3062612.6374,4223282.7177 3062612.4923,4223282.6954 3062612.4023,4223282.5377 3062612.09270001,4223282.497 3062612.0345,4223280.7319 3062608.0952,4223280.7201 3062608.0415,4223280.6836 3062607.97550001,4223280.6719 3062607.95850001,4223280.6686 3062607.9484,4223280.6629 3062607.938,4223280.6078 3062607.7596,4223280.0932 3062606.1635,4223280.0908 3062606.1526,4223280.0802 3062606.0615,4223280.0499 3062605.9673,4223280.0395 3062605.9202,4223280.0345 3062605.9041,4223280.0236 3062605.8481,4223279.9623 3062605.5706,4223279.9399 3062605.3778,4223279.9174 3062605.3051,4223279.9127 3062605.2808,4223279.7789 3062603.9899,4223279.7278 3062603.5496,4223279.7302 3062603.5207,4223279.728 3062603.49880001,4223279.7356 3062603.4574,4223279.7512 3062603.2732,4223279.7291 3062603.16,4223279.8371 3062597.8438,4223279.8692 3062597.6852,4223279.8783 3062597.5357,4223279.8577 3062597.4179,4223279.8669 3062597.2666,4223279.9051 3062597.0961,4223279.9398 3062596.5259,4223280.0513 3062595.7072,4223279.6675 3062594.63970001,4223276.966 3062591.2004,4223276.8693 3062591.03770001,4223276.6689 3062590.805,4223273.9144 3062586.9694,4223273.6066 3062586.42490001,4223273.3349 3062586.0783,4223271.0295 3062582.1464,4223270.8018 3062581.6963,4223270.7327 3062581.60219999,4223270.6488 3062581.4364,4223270.6285 3062581.3536,4223270.4702 3062581.0407,4223270.4595 3062580.9904,4223270.4378 3062580.9498,4223270.3863 3062580.8749,4223270.369 3062580.8205,4223270.3529 3062580.7903,4223270.3398 3062580.7288,4223270.2283 3062580.3786,4223270.1303 3062580.2264,4223268.258 3062575.0275,4223268.2397 3062574.9264,4223268.1398 3062574.66,4223268.0941 3062574.5181,4223268.0451 3062574.2193,4223267.8467 3062573.6031,4223267.1746 3062570.5496,4223267.1588 3062570.4131,4223267.1203 3062570.2885,4223266.5396 3062567.3239,4223266.4822 3062566.7715,4223266.4763 3062566.73779999,4223266.4712 3062566.6827,4223266.4066 3062566.4348,4223266.3788 3062566.1668,4223266.3262 3062565.2694,4223266.3324 3062565.1956,4223266.3288 3062565.1572,4223266.3415 3062565.0888,4223266.382 3062564.6114,4223266.3467 3062564.409,4223266.568 3062560.8028,4223266.6116 3062560.608,4223266.6306 3062560.2175,4223266.6497 3062560.0465,4223266.746 3062559.6618,4223266.7906 3062559.231,4223266.8103 3062559.1431,4223266.8394 3062559.049,4223266.8652 3062558.8181,4223267.4108 3062556.5494,4223267.3396 3062556.5158,4223267.0175 3062556.3391,4223266.5674 3062556.00620001,4223265.9798 3062555.6838,4223263.1596 3062553.6507,4223262.7255 3062553.2864,4223262.5795 3062553.1991,4223262.4106 3062553.0574,4223262.3408 3062552.9636,4223262.0341 3062552.7062,4223262.0156 3062552.6793,4223261.9102 3062552.5936,4223261.8652 3062552.5645,4223261.8454 3062552.541,4223261.8275 3062552.52650001,4223261.8027 3062552.4903,4223261.6762 3062552.3404,4223261.5762 3062552.27300001,4223259.1663 3062549.7545,4223257.1236 3062547.6805,4223257.0749 3062547.608,4223256.9067 3062547.4227,4223256.8363 3062547.3738,4223256.74 3062547.2677,4223256.6765 3062547.169,4223256.3368 3062546.7946,4223253.771 3062543.587,4223253.4384 3062543.0329,4223253.0056 3062542.4863,4223252.7991 3062542.1422,4223252.1879 3062540.949,4223252.1558 3062540.798,4223252.1157 3062540.7398,4223251.9846 3062540.36000001,4223251.9214 3062540.2605,4223251.8491 3062540.0512,4223251.8195 3062539.882,4223251.6341 3062539.3448,4223250.3047 3062534.31860001,4223250.2137 3062533.6542,4223250.0647 3062533.1183,4223250.015 3062532.75510001,4223249.9234 3062531.53450001,4223249.8771 3062528.1223,4223249.5966 3062526.7579,4223249.421 3062526.4498,4223248.7777 3062525.6159,4223248.3863 3062525.1751,4223246.1328 3062523.7538,4223245.6087 3062523.34099999,4223245.2796 3062523.15120001,4223245.0338 3062522.9575,4223244.2481 3062522.2692,4223242.984 3062520.7711,4223242.6522 3062520.2889,4223242.6094 3062520.2113,4223242.5936 3062520.1924,4223242.5854 3062520.1802,4223242.0032 3062519.1135,4223241.7047 3062518.573,4223241.6999 3062518.5577,4223241.6933 3062518.5457,4223241.6878 3062518.51970001,4223241.657 3062518.4229,4223241.6172 3062518.3639,4223240.7785 3062516.3035,4223240.7431 3062516.1288,4223240.2401 3062514.521,4223240.1295 3062513.97580001,4223239.931 3062512.1243,4223239.9319 3062512.11339999,4223239.9308 3062512.1036,4223239.9343 3062512.0847,4223239.9402 3062512.0157,4223239.9299 3062511.9659,4223239.9304 3062509.3787,4223239.9304 3062509.28550001,4223239.885 3062509.23290001,4223239.8293 3062509.1684,4223239.8244 3062509.1628,4223239.7526 3062509.0796,4223237.2982 3062506.2414,4223237.0305 3062505.9319,4223234.8552 3062503.4164,4223223.0083 3062489.7052,4223222.6195 3062489.2552,4223222.2177 3062488.7901,4223214.0867 3062479.3797,4223199.1589 3062439.2181,4223196.5233 3062432.1272,4223169.2642 3062396.3087,4223167.1234 3062389.39929999,4223150.2488 3062334.93510001,4223151.8889 3062299.8941,4223151.9371 3062298.8643,4223152.0196 3062297.1026,4223152.2818 3062291.50009999,4223152.4193 3062288.5619,4223129.797 3062278.9817,4223128.1262 3062278.2723,4223124.4109 3062276.6947,4223122.5645 3062275.91070001,4223142.1087 3062246.3877,4223144.426 3062242.8873,4223176.8063 3062193.9744,4223180.4235 3062188.5103,4223183.7836 3062183.4339,4223188.0886 3062176.9298,4223189.7824 3062174.3708,4223190.3722 3062173.4798,4223192.676 3062169.99940001,4223187.991 3062168.575,4223171.687 3062163.618,4223168.3672 3062162.6087,4223168.3292 3062162.6033,4223168.2262 3062162.5714,4223164.2926 3062161.6828,4223137.8734 3062155.7148,4223110.3047 3062149.4871,4223128.6279 3062107.4215,4223146.1729 3062067.1422,4223162.8346 3062028.891,4223166.7006 3062020.0156,4223166.7123 3062019.9572,4223166.7445 3062019.9062,4223170.6057 3062009.3897,4223171.4188 3062007.17489999,4223174.4947 3062002.328,4223176.5374 3061993.23379999,4223183.652 3061961.5594,4223185.0415 3061961.7604,4223188.1433 3061949.2015,4223188.1511 3061949.1341,4223188.1747 3061949.0491,4223195.4659 3061905.0707,4223195.4768 3061904.9263,4223195.4886 3061904.8743,4223197.7345 3061870.579,4223197.7386 3061870.4211,4223197.7206 3061870.3367,4223197.7306 3061870.2678,4223196.5707 3061857.4654,4223196.4161 3061856.8555,4223196.4117 3061856.8491,4223196.4101 3061856.84160001,4223193.6631 3061850.5875,4223192.8463 3061847.816,4223191.9116 3061844.6444,4223190.6881 3061838.9322,4223191.7499 3061831.98109999,4223191.3557 3061829.9765,4223190.9723 3061828.0266,4223179.1016 3061811.878,4223164.088 3061788.8091,4223152.565 3061765.8153,4223151.3244 3061761.529,4223154.9116 3061762.16920001,4223144.5402 3061738.2332,4223142.708 3061734.0047,4223142.5925 3061733.52890001,4223140.8729 3061726.441,4223129.1397 3061678.0786,4223127.8552 3061672.784,4223127.8255 3061672.5113,4223127.1019 3061665.8472,4223124.5145 3061642.0205,4223123.1001 3061628.9961,4223120.9542 3061620.738,4223119.6665 3061615.7829,4223119.5861 3061615.4733,4223119.4855 3061612.0167,4223117.6542 3061549.0947,4223117.4112 3061540.7477,4223116.7107 3061528.3365,4223115.1724 3061501.0856,4223114.1737 3061495.7952,4223111.9047 3061483.7761,4223105.2919 3061448.7484,4223101.1526 3061426.8226,4223093.5716 3061428.8423,4223093.477 3061428.8675,4223091.4141 3061429.4171,4223089.4597 3061429.9378,4223084.4912 3061431.2615,4223078.6219 3061432.0538,4223073.4404 3061432.7533,4223043.5276 3061436.7979,4223043.4627 3061436.8416,4223031.2042 3061445.0829,4223030.6407 3061445.3901,4223030.4115 3061445.56409999,4223030.3208 3061445.6135,4223030.2855 3061445.6306,4223030.2414 3061445.6639,4223006.8088 3061458.2859,4223006.4632 3061458.4529,4223006.4183 3061458.48530001,4223006.2999 3061458.5426,4223006.2567 3061458.5528,4223006.0194 3061458.6675,4223005.8899 3061458.6951,4223005.7801 3061458.7706,4223005.5373 3061458.8629,4223005.5014 3061458.8865,4223005.3806 3061458.9325,4223005.3281 3061458.9425,4223004.9744 3061459.0769,4222993.3556 3061462.9297,4222987.7136 3061466.6667,4222987.1583 3061466.9669,4222986.9478 3061467.126,4222986.7124 3061467.2533,4222985.9088 3061467.6426,4222985.8275 3061467.6599,4222985.784 3061467.6898,4222985.5345 3061467.7842,4222985.4964 3061467.8093,4222985.3716 3061467.8565,4222985.3155 3061467.8672,4222984.9522 3061468.0047,4222974.5917 3061471.4086,4222965.3722 3061475.8657,4222965.228 3061475.8998,4222965.0367 3061476.0041,4222964.9726 3061476.0482,4222964.9299 3061476.0623,4222964.8825 3061476.0881,4222964.7957 3061476.1067,4222964.7623 3061476.1177,4222964.6812 3061476.17219999,4222955.823 3061479.7574,4222951.0747 3061481.7511,4222951.0069 3061481.9661,4222950.9685 3061482.0254,4222950.9404 3061482.1772,4222950.0399 3061483.91859999,4222949.7213 3061484.4096,4222948.4984 3061485.9419,4222947.2969 3061486.9938,4222946.9136 3061487.2787,4222945.9626 3061487.8267,4222945.56 3061488.126,4222940.7476 3061490.6276,4222937.2967 3061492.4214,4222936.3551 3061491.8162,4222935.5374 3061491.2905,4222933.9058 3061490.2417,4222933.6369 3061490.5634,4222931.6134 3061492.98459999,4222928.7266 3061496.4386,4222921.188 3061505.4585,4222907.8949 3061517.3178,4222887.3533 3061535.6439,4222861.0334 3061555.6929,4222860.9868 3061555.7285,4222860.7557 3061555.9045,4222854.037 3061561.02250001,4222853.856 3061561.1462,4222851.2938 3061562.8979,4222843.1646 3061568.4555,4222835.817 3061573.4788,4222824.7478 3061581.0464,4222816.8466 3061586.4481,4222778.4054 3061607.5148,4222737.3346 3061630.0225,4222737.33 3061630.02590001,4222737.3147 3061630.0335,4222709.9912 3061645.4139,4222698.151 3061652.0788,4222693.5631 3061654.6613,4222691.053 3061656.07430001,4222688.3011 3061657.62330001,4222669.3314 3061668.3014,4222669.329 3061668.30319999,4222669.3219 3061668.3067,4222643.5774 3061682.9828,4222639.8693 3061685.0967,4222615.1581 3061699.1838,4222610.9625 3061701.57550001,4222607.4663 3061703.5686,4222607.4484 3061703.5825,4222607.4044 3061703.6054,4222588.5367 3061715.2798,4222584.0953 3061715.7856,4222583.6697 3061715.8126,4222583.599 3061715.8296,4222583.4498 3061715.8391,4222583.394 3061715.8302,4222583.101 3061715.84880001,4222583.045 3061715.8385,4222582.9519 3061715.8583,4222575.5511 3061715.9599,4222575.1615 3061715.9653,4222573.7461 3061715.8842,4222573.3253 3061715.8299,4222572.5475 3061715.6284,4222571.9356 3061715.5495,4222564.1144 3061713.3843,4222563.6808 3061713.2384,4222563.5938 3061713.2248,4222563.439 3061713.1728,4222563.382 3061713.13789999,4222563.0827 3061713.0372,4222563.0321 3061713.0044,4222562.928 3061712.9852,4222561.9765 3061712.5472,4222561.6987 3061712.4013,4222561.3892 3061712.1767,4222560.7983 3061711.8662,4222556.3984 3061708.9292,4222553.7333 3061707.7616,4222553.4965 3061707.6022,4222552.1635 3061706.8986,4222551.7195 3061706.5998,4222550.3289 3061705.47060001,4222548.9483 3061704.1273,4222543.7487 3061699.06789999,4222543.6777 3061698.9838,4222542.4846 3061697.5698,4222542.1529 3061697.08770001,4222541.2054 3061695.3717,4222540.6109 3061693.5038,4222540.489 3061692.9314,4222540.2703 3061690.9834,4222540.436 3061689.0301,4222540.5424 3061688.4545,4222541.0859 3061686.571,4222541.9864 3061684.8296,4222542.305 3061684.3386,4222543.5279 3061682.8063,4222545.0262 3061681.5421,4222545.5084 3061681.2103,4222547.2246 3061680.26249999,4222549.0926 3061679.6678,4222549.6651 3061679.5457,4222550.5598 3061679.4452,4222551.6132 3061679.3268,4222553.5665 3061679.4922,4222554.1421 3061679.5985,4222556.0256 3061680.1417,4222557.7668 3061681.042,4222558.2578 3061681.3604,4222559.79 3061682.5831,4222564.8756 3061687.5317,4222566.5583 3061688.2688,4222566.6547 3061688.3337,4222567.2309 3061688.5989,4222567.4275 3061688.7023,4222567.7295 3061688.9214,4222568.1281 3061689.1318,4222568.1319 3061689.1343,4222568.3279 3061689.2373,4222572.0339 3061691.7112,4222576.4919 3061692.9453,4222582.0661 3061692.8688,4222585.2819 3061692.5026,4222588.7752 3061690.22890001,4222593.3491 3061686.7613,4222593.6986 3061686.31200001,4222594.0028 3061685.96270001,4222594.0502 3061685.8849,4222594.1592 3061685.7598,4222594.2148 3061685.7192,4222594.425 3061685.4779,4222594.5728 3061685.3761,4222594.6662 3061685.2321,4222594.9177 3061685.0028,4222594.9629 3061684.9374,4222595.0952 3061684.8168,4222595.1856 3061684.7585,4222595.5482 3061684.4278,4222600.0441 3061680.795,4222600.5905 3061680.4652,4222601.1622 3061680.0113,4222601.5121 3061679.8002,4222602.7265 3061679.17609999,4222602.7541 3061679.1673,4222602.7736 3061679.1568,4222602.8148 3061679.148,4222602.9912 3061679.0918,4222603.0875 3061679.0279,4222608.2546 3061677.0091,4222608.5348 3061676.9543,4222609.975 3061676.5092,4222610.5003 3061676.4065,4222612.2822 3061676.2216,4222618.2007 3061676.1403,4222618.4193 3061676.1154,4222620.6585 3061672.4218,4222621.0713 3061671.8882,4222621.3001 3061671.48739999,4222621.381 3061671.3829,4222621.4938 3061671.2529,4222621.5587 3061671.1422,4222624.7518 3061667.2026,4222625.0139 3061666.90760001,4222625.0451 3061666.857,4222625.1354 3061666.7554,4222625.1737 3061666.7277,4222625.3538 3061666.5251,4222625.3978 3061666.49479999,4222625.4441 3061666.4234,4222626.7629 3061665.2857,4222627.1851 3061664.9812,4222628.3964 3061664.2948,4222628.6813 3061664.0895,4222633.5194 3061661.75049999,4222633.6584 3061661.7177,4222633.8768 3061661.5971,4222633.919 3061661.568,4222633.9448 3061661.5595,4222633.9789 3061661.5407,4222635.2038 3061661.1055,4222635.5782 3061660.9993,4222636.2166 3061660.9061,4222636.8491 3061660.7267,4222641.069 3061660.105,4222645.0454 3061659.05279999,4222650.841 3061655.1591,4222651.4042 3061654.8522,4222651.6325 3061654.679,4222651.718 3061654.6324,4222651.7237 3061654.62960001,4222651.7596 3061654.6024,4222656.9527 3061651.7778,4222657.8552 3061651.2869,4222660.1219 3061650.03790001,4222662.8607 3061648.5287,4222666.3749 3061646.59230001,4222671.9215 3061641.6772,4222674.7024 3061639.2129,4222680.252 3061634.2525,4222682.0616 3061632.6351,4222682.1 3061632.6106,4222682.2434 3061632.476,4222685.6983 3061629.516,4222686.2743 3061628.9242,4222687.0413 3061627.3364,4222687.324 3061626.9327,4222687.8544 3061625.9502,4222688.1301 3061625.5566,4222689.1547 3061624.3184,4222689.2437 3061624.2572,4222689.2768 3061624.2061,4222689.5123 3061623.9895,4222689.5514 3061623.9328,4222689.6735 3061623.8204,4222689.75 3061623.7709,4222690.0909 3061623.4573,4222694.907 3061619.4954,4222695.216 3061619.30699999,4222695.7038 3061618.8958,4222700.5287 3061615.5831,4222701.1018 3061615.267,4222701.3595 3061615.0705,4222701.6156 3061614.9292,4222702.4924 3061614.5,4222702.6167 3061614.4735,4222702.6709 3061614.4362,4222703.0063 3061614.3164,4222703.0807 3061614.2686,4222703.2592 3061614.2048,4222703.3791 3061614.18320001,4222703.8609 3061614.01100001,4222710.8662 3061611.99690001,4222713.6845 3061611.1259,4222715.9006 3061609.0236,4222716.0909 3061608.8981,4222716.7299 3061608.3269,4222716.9388 3061608.16480001,4222717.1333 3061608.05,4222717.2803 3061607.9285,4222717.3575 3061607.8776,4222717.7151 3061607.6848,4222718.094 3061607.39079999,4222719.9207 3061606.343,4222720.4651 3061604.5337,4222720.5905 3061604.1743,4222720.5992 3061604.1208,4222720.6421 3061603.99789999,4222720.665 3061603.9608,4222720.7511 3061603.7141,4222720.7793 3061603.6707,4222720.794 3061603.5912,4222721.6079 3061601.9858,4222721.8932 3061601.53,4222722.8364 3061600.3285,4222722.9817 3061600.0965,4222727.7801 3061594.83619999,4222727.8702 3061594.7721,4222727.9792 3061594.6354,4222728.0012 3061594.6015,4222728.0173 3061594.5877,4222728.0344 3061594.5663,4222728.5207 3061594.156,4222729.3675 3061593.4297,4222729.3867 3061593.416,4222729.4196 3061593.39750001,4222729.5328 3061593.302,4222730.0149 3061592.9702,4222731.7311 3061592.0225,4222733.5992 3061591.4278,4222734.1716 3061591.3057,4222736.1197 3061591.0868,4222739.6903 3061591.0378,4222741.9701 3061591.0065,4222742.9628 3061590.143,4222742.9844 3061590.12940001,4222742.9925 3061590.12230001,4222743.1486 3061590.0234,4222743.5837 3061589.6397,4222747.7021 3061586.5322,4222751.2512 3061582.8849,4222751.3296 3061582.831,4222751.3607 3061582.7831,4222751.5756 3061582.5832,4222751.6075 3061582.5365,4222751.717 3061582.4347,4222751.7779 3061582.395,4222752.0902 3061582.1046,4222755.6211 3061579.1342,4222756.1266 3061578.8223,4222756.8073 3061578.2763,4222757.1801 3061578.0463,4222758.4788 3061577.37080001,4222760.3469 3061576.7761,4222760.9193 3061576.654,4222762.8674 3061576.4351,4222769.0348 3061576.3505,4222769.6049 3061576.3426,4222772.1171 3061574.5174,4222772.7027 3061574.1842,4222773.0494 3061573.9161,4222773.3382 3061573.7518,4222774.3313 3061573.2574,4222775.2894 3061572.90190001,4222775.5816 3061572.8102,4222775.9378 3061572.7531,4222776.571 3061572.55430001,4222782.1527 3061571.4116,4222783.3699 3061571.16239999,4222784.0117 3061571.093,4222784.2995 3061571.0173,4222784.3342 3061571.0135,4222784.3754 3061571.0026,4222788.7558 3061570.5038,4222791.283 3061569.19660001,4222797.3011 3061565.9233,4222797.6597 3061565.7491,4222797.7091 3061565.7133,4222797.8329 3061565.6532,4222797.88 3061565.642,4222798.1264 3061565.5223,4222798.1757 3061565.5118,4222798.1862 3061565.5061,4222798.2502 3061565.4622,4222798.2951 3061565.4479,4222798.3222 3061565.4334,4222798.3781 3061565.4214,4222798.6667 3061565.3296,4222798.8024 3061565.2413,4222808.8164 3061561.53270001,4222808.9281 3061561.5119,4222809.2947 3061561.3749,4222809.4573 3061561.323,4222809.7772 3061561.271,4222810.4007 3061561.0721,4222815.261 3061560.0306,4222818.5612 3061558.2439,4222821.9073 3061554.1156,4222822.1694 3061553.8206,4222822.2006 3061553.77,4222822.2909 3061553.66840001,4222822.3292 3061553.64069999,4222822.5093 3061553.43810001,4222822.6614 3061553.33340001,4222822.7439 3061553.2062,4222823.0137 3061552.9627,4222823.0666 3061552.8866,4222823.211 3061552.7564,4222823.3194 3061552.6868,4222823.7062 3061552.3377,4222827.8819 3061549.0339,4222828.4435 3061548.69890001,4222828.9638 3061548.288,4222829.3016 3061548.0865,4222830.472 3061547.4888,4222831.6825 3061547.0577,4222832.0525 3061546.9522,4222832.6728 3061546.8611,4222833.3083 3061546.6799,4222838.8953 3061545.8398,4222840.3993 3061545.1708,4222842.6719 3061540.4667,4222842.7946 3061540.2914,4222842.9863 3061539.88720001,4222843.0747 3061539.7279,4222843.2966 3061539.42950001,4222843.485 3061539.0805,4222843.5389 3061539.00349999,4222843.6324 3061538.8905,4222843.7631 3061538.655,4222852.5532 3061526.7047,4222852.6888 3061526.5204,4222853.0396 3061526.1064,4222853.1151 3061525.9807,4222853.2485 3061525.8234,4222853.3316 3061525.7618,4222853.5776 3061525.4715,4222853.6341 3061525.4327,4222853.7109 3061525.3142,4222854.4331 3061524.64189999,4222854.6591 3061524.4513,4222854.9418 3061524.2767,4222855.4439 3061523.8535,4222855.4691 3061523.8362,4222859.8991 3061520.7946,4222865.3976 3061516.3516,4222868.5419 3061513.8108,4222868.826 3061513.6394,4222869.0325 3061513.4588,4222869.1233 3061513.39140001,4222869.4067 3061513.2283,4222869.66 3061513.0272,4222869.8229 3061512.9289,4222870.2811 3061512.6934,4222870.4869 3061512.5408,4222876.29 3061509.5393,4222876.4709 3061509.49400001,4222876.6717 3061509.3508,4222877.8833 3061508.7799,4222883.2682 3061502.2372,4222883.5129 3061501.9638,4222883.539 3061501.9218,4222883.6222 3061501.8289,4222883.6547 3061501.8054,4222883.8226 3061501.6179,4222883.864 3061501.5894,4222883.9058 3061501.525,4222885.4042 3061500.2607,4222885.8863 3061499.9289,4222887.6025 3061498.9812,4222888.5851 3061498.618,4222888.8847 3061498.5248,4222889.2624 3061498.46480001,4222889.8999 3061498.2665,4222897.4807 3061496.7548,4222900.9369 3061495.2853,4222904.9088 3061490.5216,4222905.0756 3061490.3971,4222905.3844 3061490.0103,4222905.4226 3061489.9514,4222905.4519 3061489.9256,4222905.4996 3061489.8659,4222917.6669 3061477.3622,4222918.8684 3061476.3102,4222919.2516 3061476.0253,4222920.2026 3061475.4773,4222920.6052 3061475.178,4222925.6656 3061472.5476,4222926.4511 3061472.1393,4222927.1096 3061471.7969,4222930.1414 3061470.22100001,4222930.5186 3061470.0249,4222931.2599 3061468.59160001,4222931.5784 3061468.1005,4222932.2746 3061467.2283,4222933.0434 3061460.8984,4222943.7585 3061457.0019,4222949.0303 3061455.2748,4222951.6027 3061454.3833,4222955.094 3061453.1733,4222962.3686 3061449.45520001,4222964.0229 3061448.6096,4222969.4727 3061445.8242,4222982.01 3061439.41609999,4222982.0168 3061439.4112,4222982.0225 3061439.4097,4223032.4667 3061413.18020001,4223046.8852 3061406.925,4223051.0466 3061406.2368,4223055.5147 3061405.4979,4223056.7586 3061404.7602,4223074.9582 3061401.1074,4223076.331 3061400.8319,4223077.564 3061400.5844,4223081.2989 3061399.8349,4223083.1372 3061399.466,4223084.037 3061399.2855,4223084.8984 3061398.6924,4223084.9787 3061398.6371,4223086.974 3061397.2635,4223087.1065 3061397.17229999,4223100.4453 3061387.9895,4223116.6752 3061376.8159,4223126.9348 3061377.4452,4223128.9032 3061372.8978,4223129.5043 3061371.4995,4223133.1891 3061362.9883,4223139.7636 3061347.8069,4223139.5995 3061346.7691,4223138.7624 3061341.2409,4223150.466 3061329.3473,4223162.0041 3061317.622,4223164.2856 3061315.3035,4223168.9363 3061311.03190001,4223169.0609 3061310.79,4223169.9833 3061308.9994,4223169.9871 3061308.99159999,4223171.6551 3061305.7607,4223174.2633 3061300.7086,4223175.0024 3061299.2769,4223174.8538 3061296.6079,4223173.981 3061294.2376,4223171.9643 3061288.76050001,4223164.6027 3061268.7677,4223163.9878 3061265.4485,4223164.697 3061261.6117,4223164.8729 3061261.3405,4223164.9309 3061261.0714,4223178.7613 3061231.5395,4223180.6509 3061228.8061,4223181.4865 3061228.2311,4223181.3152 3061226.3989,4223180.4243 3061216.8734,4223179.4735 3061206.70770001,4223179.431 3061205.91230001,4223179.4782 3061204.7963,4223181.3778 3061185.5093,4223178.453 3061179.3267,4223178.414 3061179.1587,4223178.3067 3061179.0026,4223177.4936 3061175.1866,4223177.7564 3061172.76920001,4223180.961 3061159.0943,4223181.4074 3061157.6748,4223181.6211 3061157.3454,4223181.6909 3061157.0301,4223181.854 3061156.6922,4223185.5721 3061148.9862,4223187.412 3061146.35740001,4223190.6266 3061144.1454,4223191.5501 3061143.8057,4223190.9137 3061135.8963,4223190.9595 3061135.5919,4223190.8824 3061135.23,4223191.2814 3061133.4532,4223191.2821 3061133.4491,4223191.5916 3061131.3932,4223191.8249 3061131.0334,4223191.9008 3061130.6955,4223192.1212 3061130.2455,4223208.366 3061097.0771,4223210.1802 3061094.5012,4223213.3948 3061092.2892,4223217.2109 3061091.4755,4223219.1986 3061091.8376,4223221.0019 3061092.166,4223223.5748 3061093.1759,4223228.3399 3061087.4733,4223239.5619 3061061.5761,4223236.4686 3061059.57,4223234.257 3061056.3559,4223233.6541 3061053.5263,4223233.4439 3061052.5399,4223233.9353 3061049.3053,4223235.2629 3061045.2318,4223237.5481 3061038.2204,4223239.1045 3061033.445,4223244.9818 3061015.4145,4223248.9752 3061003.1637,4223250.6316 3060998.0821,4223250.785 3060997.8281,4223250.8493 3060997.4803,4223252.9728 3060994.2066,4223256.1873 3060991.99460001,4223257.4964 3060991.7155,4223259.0463 3060986.6293,4223259.3221 3060985.8431,4223261.4456 3060982.56950001,4223264.1836 3060980.6855,4223264.1097 3060979.8238,4223264.8189 3060975.98700001,4223265.2566 3060975.0362,4223269.514 3060966.9342,4223270.2249 3060965.5813,4223265.8065 3060960.8115,4223265.5801 3060960.5671,4223263.7308 3060957.7243,4223262.9555 3060954.6514,4223262.8214 3060953.1317,4223249.1233 3060936.1786,4223245.084 3060931.1795,4223243.6767 3060928.84740001,4223242.8938 3060925.684,4223240.3471 3060893.4804,4223239.041 3060876.9643,4223235.0418 3060871.6261,4223231.6414 3060867.0873,4223230.459 3060865.0442,4223229.6459 3060861.2282,4223229.9599 3060858.60260001,4223233.7538 3060843.8451,4223222.3375 3060824.8249,4223221.7258 3060823.631,4223221.0586 3060821.3862,4223219.4096 3060811.8677,4223218.2769 3060805.3296,4223218.1721 3060804.7246,4223218.1704 3060804.7068,4223218.1671 3060804.6954,4223218.0584 3060804.0569,4223217.9529 3060802.9018,4223217.936 3060802.8273,4223217.9245 3060802.7768,4223217.8179 3060800.0977,4223217.6588 3060796.1007,4223217.3603 3060788.6004,4223217.1012 3060787.3847,4223217.0656 3060784.7604,4223217.0256 3060781.8149,4223216.9942 3060779.5032,4223216.9599 3060776.9751,4223216.2973 3060763.4246,4223216.1827 3060761.0815,4223215.6562 3060755.0188,4223214.8356 3060745.5686,4223214.9552 3060744.7568,4223214.8101 3060743.9384,4223214.8446 3060743.3143,4223214.9735 3060740.9848,4223215.0439 3060739.712,4223215.1867 3060737.1324,4223215.2564 3060735.8729,4223215.4269 3060732.7912,4223215.4427 3060732.7194,4223215.4474 3060732.5655,4223215.4564 3060732.4444,4223215.5132 3060732.1979,4223215.5596 3060731.5738,4223218.0418 3060716.36790001,4223218.1396 3060716.01519999,4223218.1737 3060715.689,4223221.8908 3060699.219,4223222.2584 3060697.5901,4223222.3728 3060697.0832,4223223.0772 3060690.379,4223224.0446 3060681.1707,4223224.3431 3060678.3298,4223224.443 3060677.9251,4223224.5017 3060677.285,4223227.1048 3060664.2158,4223227.0623 3060663.2899,4223225.1732 3060660.8414,4223224.9716 3060660.4986,4223224.6613 3060660.1225,4223223.2885 3060658.0288,4223223.1982 3060657.8608,4223222.9933 3060657.4797,4223222.8436 3060657.28130001,4223222.7532 3060657.11310001,4223222.7202 3060657.0517,4223222.342 3060656.2684,4223222.1366 3060655.6232,4223222.0886 3060655.4723,4223221.7475 3060654.40049999,4223221.6256 3060653.8281,4223221.4069 3060651.8801,4223221.5255 3060650.2029,4223221.603 3060649.7063,4223221.9361 3060648.4883,4223222.0009 3060648.0726,4223223.2296 3060644.403,4223223.2929 3060644.2139,4223223.3822 3060644.0675,4223223.4746 3060643.747,4223223.4856 3060643.6879,4223223.5001 3060643.6589,4223223.5145 3060643.609,4223223.5626 3060643.5079,4223223.598 3060643.43340001,4223223.7541 3060643.1053,4223223.7738 3060643.0147,4223223.8574 3060642.8392,4223223.9309 3060642.7334,4223224.1547 3060642.2629,4223230.8915 3060630.1853,4223234.1289 3060621.8527,4223234.1522 3060621.8166,4223234.1602 3060621.7734,4223234.1924 3060621.7106,4223234.205 3060621.6698,4223234.246 3060621.6065,4223234.4311 3060621.2467,4223234.4724 3060621.05849999,4223236.649 3060616.5043,4223236.8689 3060615.3145,4223237.5749 3060613.8856,4223239.2015 3060611.1636,4223243.176 3060602.8475,4223243.1941 3060602.8064,4223249.2038 3060589.20949999,4223248.9575 3060588.0535,4223251.439 3060583.2709,4223261.5716 3060566.61430001,4223261.7209 3060566.315,4223261.7217 3060566.3106,4223261.7241 3060566.3069,4223261.932 3060565.7785,4223264.1682 3060560.0928,4223264.1794 3060560.0318,4223264.3061 3060559.42170001,4223264.3365 3060558.4264,4223264.8035 3060543.127,4223264.8553 3060540.8205,4223264.8989 3060538.8785,4223265.008 3060521.3433,4223265.2424 3060483.67740001,4223268.5542 3060464.9812,4223269.7483 3060462.5997,4223276.0825 3060449.9662,4223276.1527 3060449.81,4223276.2579 3060449.4517,4223277.5527 3060442.797,4223282.5464 3060417.131,4223283.8644 3060412.3073,4223285.2848 3060407.1086,4223285.9375 3060404.72,4223286.2795 3060403.4682,4223290.199 3060389.123,4223288.4145 3060388.869,4223286.2077 3060388.5549,4223284.1423 3060388.2609,4223264.3964 3060386.1803,4223242.7843 3060384.5143,4223241.4029 3060381.9202,4223241.4251 3060380.4363,4223241.4536 3060378.5353,4223239.666 3060378.3651,4223237.4588 3060378.1549,4223224.0968 3060376.5432,4223216.5411 3060375.6318,4223216.2741 3060375.6176,4223216.2347 3060375.626,4223216.1987 3060375.6201,4223192.0911 3060376.8612,4223179.7636 3060377.4959,4223179.6765 3060377.5162,4223179.3435 3060377.5629,4223148.2776 3060385.9773,4223137.8759 3060388.79470001,4223137.7995 3060388.82130001,4223137.7716 3060388.826,4223112.9793 3060397.0123,4223105.3238 3060397.8086,4223097.3775 3060398.6351,4223071.3908 3060401.338,4223021.6791 3060401.4214,4223014.3955 3060401.4336,4223014.3644 3060401.44,4223014.3337 3060401.43469999,4222978.0283 3060402.61820001,4222958.2938 3060400.5109,4222945.7745 3060397.8022,4222941.4996 3060390.8927,4222941.2377 3060390.4693,4222941.1448 3060383.6273,4222941.6273 3060382.721,4222937.8989 3060381.2129,4222914.5637 3060382.6331,4222914.046 3060381.69540001,4222913.4515 3060379.8275,4222913.3295 3060379.2551,4222913.1109 3060377.3071,4222913.2766 3060375.3538,4222913.3829 3060374.7782,4222913.9265 3060372.8947,4222914.827 3060371.1534,4222915.1455 3060370.6623,4222916.3685 3060369.1301,4222917.8668 3060367.8658,4222918.349 3060367.534,4222920.0652 3060366.5862,4222921.9332 3060365.9915,4222922.5057 3060365.86949999,4222924.4538 3060365.6506,4222924.5794 3060365.6738,4222924.6439 3060365.66,4222925.0008 3060365.6778,4222925.0885 3060365.662,4222925.2785 3060365.6715,4222925.3987 3060365.6976,4222925.9112 3060365.7231,4222942.1027 3060367.5647,4222942.5565 3060367.6787,4222943.1819 3060367.7397,4222950.1319 3060369.2091,4222951.2918 3060369.4544,4222953.8756 3060370.0007,4222953.9333 3060370.0181,4222953.9769 3060370.0225,4222956.7723 3060370.63990001,4222959.0523 3060369.6916,4222989.5843 3060365.8744,4223040.3716 3060364.8341,4223111.5531 3060363.5132,4223111.5559 3060363.5126,4223111.7329 3060363.5018,4223133.3726 3060361.1409,4223135.5775 3060360.8969,4223154.4021 3060358.8142,4223162.3681 3060360.06449999,4223180.3353 3060362.88460001,4223180.5695 3060362.90119999,4223180.6047 3060362.9084,4223210.8823 3060363.5247,4223210.8899 3060363.52320001,4223210.8989 3060363.525,4223242.4577 3060363.905,4223244.9899 3060363.9914,4223247.2956 3060364.07,4223247.9972 3060363.363,4223249.3934 3060361.9558,4223266.3284 3060363.2612,4223266.4069 3060363.2794,4223266.6496 3060363.2905,4223287.6134 3060365.4994,4223287.6702 3060365.5135,4223287.797 3060365.5203,4223290.7435 3060365.87869999,4223292.9241 3060358.4848,4223293.2258 3060357.6126,4223293.2356 3060357.5935,4223294.0921 3060355.9244,4223299.1548 3060348.0104,4223314.4814 3060324.0523,4223315.7386 3060322.46690001,4223317.1839 3060321.2394,4223345.9372 3060301.1787,4223370.3361 3060284.15600001,4223416.3359 3060236.3978,4223416.8451 3060235.2263,4223433.3937 3060197.1454,4223433.6018 3060196.6665,4223452.7741 3060152.5481,4223470.2785 3060112.2678,4223470.813 3060109.9555,4223472.0896 3060104.433,4223476.3916 3060085.8221,4223476.7914 3060084.5439,4223476.8027 3060084.4806,4223479.2013 3060078.2517,4223479.2415 3060078.1474,4223482.0549 3060070.8412,4223496.5192 3060033.2792,4223498.1483 3060029.0484,4223498.2551 3060028.771,4223500.9975 3060021.6495,4223503.6864 3060009.6342,4223504.0079 3060008.1976,4223505.1264 3060003.1999,4223505.5951 3060001.6827,4223506.0467 3060000.7056,4223506.7374 3059999.4007,4223508.401 3059996.2574,4223509.6014 3059993.9895,4223509.7134 3059993.778,4223529.283 3059956.8036,4223530.9549 3059954.5071,4223531.5525 3059953.9415,4223551.9893 3059936.1343,4223572.9329 3059904.3198,4223574.1189 3059902.8441,4223574.8788 3059902.1402,4223584.7126 3059893.93379999,4223587.7173 3059890.92260001,4223588.9256 3059892.0813,4223591.8138 3059894.8509,4223593.2638 3059892.78780001,4223594.4036 3059891.166,4223596.8118 3059872.50240001,4223596.8204 3059872.3518,4223596.8268 3059872.3216,4223598.5478 3059826.5328,4223598.5417 3059826.4999,4223598.5492 3059826.4608,4223598.6225 3059779.9041,4223602.1245 3059780.0291,4223601.8953 3059770.0501,4223601.9888 3059770.0302,4223602.0072 3059770.0319,4223604.0787 3059770.22179999,4223613.2164 3059772.0412,4223616.0013 3059771.2447,4223641.7126 3059763.8916,4223647.021 3059752.41890001,4223649.4648 3059747.1373,4223650.2562 3059745.4269,4223658.1011 3059728.14179999,4223658.8275 3059726.54130001,4223659.7292 3059724.5545,4223660.0973 3059723.7434,4223660.1272 3059723.6996,4223660.1357 3059723.6597,4223661.2824 3059721.1938,4223661.3109 3059721.1525,4223661.4223 3059720.9052,4223661.5396 3059720.6726,4223665.9706 3059711.8918,4223665.9718 3059711.8892,4223672.1562 3059699.6434,4223672.4432 3059699.2413,4223672.8754 3059698.4379,4223679.1562 3059689.4155,4223684.0513 3059682.3838,4223685.37 3059680.4895,4223690.7291 3059672.7911,4223691.7697 3059671.5301,4223691.7904 3059671.5159,4223691.804 3059671.49510001,4223697.1101 3059666.0955,4223699.8002 3059663.358,4223699.8421 3059663.3153,4223701.297 3059661.79110001,4223701.6841 3059661.3856,4223715.5199 3059646.8908,4223715.609 3059646.8289,4223715.6683 3059646.7386,4223716.2075 3059646.1972,4223719.8708 3059642.5194,4223721.3203 3059639.0849,4223723.097 3059634.8754,4223723.1597 3059634.78090001,4223723.1792 3059634.686,4223726.2876 3059627.7259,4223727.6731 3059624.6237,4223727.6757 3059624.6198,4223727.6765 3059624.6161,4223729.4561 3059620.64,4223730.7685 3059617.70789999,4223731.7972 3059615.4096,4223723.7964 3059601.3687,4223723.7833 3059601.3423,4223723.7763 3059601.3333,4223723.665 3059601.136,4223719.6438 3059594.0117,4223711.2026 3059579.0565,4223710.9173 3059578.55110001,4223708.9106 3059575.00130001,4223708.43 3059574.03250001,4223707.6169 3059570.2165,4223708.3261 3059566.3797,4223708.4026 3059566.2086,4223707.8529 3059565.40980001,4223707.615 3059564.2934,4223707.558 3059564.0259,4223718.7728 3059545.0711,4223718.7984 3059545.0189,4223718.8147 3059544.9967,4223735.3422 3059514.1276,4223745.7851 3059500.3506,4223746.0493 3059499.8826,4223746.1911 3059499.1152,4223746.1854 3059499.0883,4223746.1898 3059499.063,4223744.7729 3059463.4056,4223744.7705 3059463.39499999,4223744.763 3059463.2716,4223739.7102 3059416.1869,4223741.475 3059392.7932,4223741.4756 3059392.77390001,4223741.4767 3059392.7691,4223742.2433 3059380.6553,4223742.4085 3059378.0451,4223745.7326 3059372.0012,4223743.9655 3059367.8607,4223743.0543 3059365.7255,4223743.0972 3059365.6462,4223746.81 3059358.7728,4223751.045 3059350.933,4223756.8055 3059340.26899999,4223763.1867 3059328.4561,4223764.0866 3059326.7902,4223769.8003 3059316.21290001,4223771.5961 3059312.06269999,4223773.3779 3059307.9448,4223775.3606 3059303.3628,4223791.1985 3059266.7606,4223793.7983 3059260.7523,4223795.4084 3059257.0314,4223801.0365 3059244.0245,4223818.7174 3059190.8551,4223823.2819 3059177.1287,4223823.4642 3059175.95340001,4223839.1475 3059074.82960001,4223840.1391 3059068.436,4223841.212 3059061.518,4223839.7368 3059059.3741,4223838.9237 3059055.5581,4223839.0024 3059054.16330001,4223839.3534 3059051.3975,4223835.9777 3059049.9782,4223833.3283 3059050.0445,4223841.6674 3059012.3474,4223842.1002 3059007.0281,4223843.6958 3058987.4198,4223843.9729 3058984.0133,4223844.333 3058979.5885,4223844.8561 3058961.9148,4223845.4985 3058940.2097,4223845.5996 3058936.6081,4223846.4009 3058909.43520001,4223846.471 3058909.44430001,4223852.0915 3058910.1771,4223852.6759 3058906.8089,4223854.283 3058897.6515,4223857.4473 3058879.6215,4223861.4251 3058852.915,4223869.0667 3058829.3985,4223871.5184 3058818.1345,4223874.6209 3058803.8805,4223875.3175 3058800.6807,4223879.1191 3058792.668,4223883.2608 3058758.1387,4223887.3684 3058749.8217,4223896.302 3058741.6785,4223900.851 3058737.5318,4223909.9563 3058729.23209999,4223926.3995 3058714.2437,4223928.5222 3058715.1646,4223930.4208 3058725.3593,4223930.8903 3058728.601,4223931.0255 3058729.5345,4223931.7676 3058734.6574,4223931.7762 3058734.717,4223930.9247 3058736.9521,4223929.5173 3058740.6458,4223904.8965 3058805.267,4223904.5921 3058806.0659,4223896.9783 3058831.9095,4223894.306 3058840.9801,4223894.247 3058841.18040001,4223894.1322 3058841.5699,4223892.0222 3058848.7319,4223888.8305 3058859.56539999,4223887.4685 3058864.1884,4223886.0912 3058868.8636,4223884.9102 3058872.8721,4223882.4572 3058896.0875,4223875.8952 3058958.1896,4223875.8433 3058958.3998,4223875.8138 3058958.7871,4223874.8836 3058964.3533,4223867.0371 3059011.3061,4223866.2376 3059016.0899,4223867.4522 3059034.5676,4223868.4555 3059049.8322,4223868.4171 3059050.0752,4223868.4761 3059050.3522,4223868.4163 3059051.5884,4223867.6142 3059058.8312,4223867.6136 3059058.8383,4223867.517 3059059.7037,4223866.6205 3059067.7398,4223864.2701 3059088.8063,4223864.1783 3059089.6289,4223863.1767 3059098.6064,4223862.157 3059107.7466,4223862.1162 3059107.9094,4223862.0948 3059108.2065,4223862.0332 3059108.5946,4223860.6207 3059117.4874,4223860.6197 3059117.4908,4223860.6194 3059117.49530001,4223860.5589 3059117.8745,4223859.044 3059127.3628,4223858.9926 3059127.68710001,4223857.3493 3059138.0439,4223857.2922 3059138.4033,4223855.9483 3059146.857,4223855.9002 3059147.1586,4223853.0916 3059164.78649999,4223853.0399 3059165.112,4223849.9317 3059184.679,4223849.3606 3059186.7561,4223849.3548 3059186.7876,4223844.7242 3059198.4976,4223844.0479 3059200.2079,4223834.8453 3059223.50540001,4223834.186 3059225.1745,4223829.0596 3059238.1568,4223824.4362 3059249.8651,4223823.7634 3059251.5689,4223823.7506 3059251.5887,4223823.7463 3059251.6121,4223823.727 3059251.6603,4223823.1465 3059253.1104,4223823.0902 3059253.197,4223823.0715 3059253.2926,4223819.2268 3059262.3755,4223818.4732 3059264.1556,4223817.5274 3059266.3899,4223810.5193 3059282.946,4223809.718 3059284.8388,4223809.4975 3059285.3596,4223808.382 3059287.9936,4223805.235 3059295.4246,4223804.428 3059297.3301,4223801.0776 3059305.2556,4223800.2917 3059307.1146,4223800.2894 3059307.1181,4223800.2887 3059307.1217,4223800.1378 3059307.478,4223798.5911 3059311.1288,4223798.0909 3059312.3095,4223793.677 3059322.7282,4223792.8989 3059324.5649,4223791.6532 3059327.5226,4223791.6075 3059327.5915,4223791.1396 3059328.56739999,4223790.5313 3059329.642,4223788.9026 3059332.5191,4223786.2816 3059337.1471,4223783.6962 3059341.7123,4223779.7432 3059348.6911,4223778.0899 3059351.6101,4223773.9785 3059358.8693,4223771.6288 3059363.0179,4223770.4374 3059365.1216,4223770.15 3059365.629,4223768.3691 3059368.7749,4223767.6615 3059370.0249,4223765.1006 3059374.5486,4223765.0988 3059374.552,4223762.1026 3059379.8391,4223761.8649 3059380.2586,4223761.7279 3059396.9468,4223761.712 3059399.0253,4223761.6801 3059403.1781,4223761.6305 3059409.6346,4223762.18 3059412.6178,4223762.6849 3059415.3584,4223765.8097 3059432.3208,4223768.8102 3059448.60800001,4223769.1341 3059450.3623,4223769.6714 3059453.2717,4223769.9824 3059454.95549999,4223770.6557 3059458.6013,4223770.7735 3059459.7987,4223770.821 3059460.2814,4223770.3947 3059463.3089,4223767.7094 3059472.19830001,4223769.8461 3059477.4857,4223770.5736 3059481.0963,4223769.8644 3059484.9331,4223768.7177 3059487.0362,4223765.1481 3059492.0448,4223764.8645 3059492.4429,4223764.374 3059501.3291,4223764.2744 3059503.1365,4223764.0932 3059506.42300001,4223763.3982 3059509.5736,4223762.8799 3059510.6745,4223760.1215 3059515.7135,4223758.8197 3059518.0915,4223754.4374 3059526.0933,4223753.5766 3059527.2581,4223753.2217 3059527.8411,4223746.2398 3059535.8932,4223744.1116 3059538.3477,4223743.6505 3059538.8795,4223742.8946 3059539.7513,4223740.795 3059542.1727,4223740.7893 3059542.1768,4223740.7844 3059542.1848,4223740.6004 3059542.39640001,4223736.931 3059546.61450001,4223736.0693 3059549.9697,4223735.9804 3059550.31580001,4223731.8722 3059566.2979,4223732.2402 3059566.9805,4223740.7789 3059582.8069,4223741.1996 3059583.5867,4223741.2656 3059583.70900001,4223748.0729 3059596.3264,4223751.2084 3059602.138,4223753.2912 3059605.9986,4223753.9581 3059605.3031,4223756.0873 3059606.704,4223755.1568 3059609.5284,4223754.321 3059612.0655,4223750.5012 3059623.66,4223749.6059 3059626.3777,4223746.9144 3059634.54729999,4223746.7607 3059635.01400001,4223735.4693 3059653.4102,4223733.3782 3059651.9157,4223730.8949 3059650.1407,4223730.1976 3059649.6423,4223729.7043 3059650.1543,4223700.8185 3059680.1376,4223682.9186 3059705.3251,4223672.2448 3059726.48300001,4223651.3605 3059772.7329,4223648.5604 3059774.9515,4223648.241 3059778.9433,4223647.7037 3059785.6592,4223616.6381 3059795.0703,4223613.8757 3059795.4989,4223613.1051 3059795.4246,4223613.006 3059846.31180001,4223612.9796 3059859.9046,4223612.9854 3059860.6958,4223613.0033 3059863.1275,4223613.0152 3059864.756,4223613.0804 3059873.611,4223613.0891 3059874.8026,4223613.083 3059874.8351,4223613.0067 3059876.1598,4223612.9266 3059876.7782,4223612.3017 3059881.6061,4223611.3134 3059889.242,4223611.134 3059890.6276,4223610.4858 3059895.6357,4223609.8584 3059898.0532,4223608.1949 3059900.82070001,4223607.9963 3059901.0548,4223604.5359 3059905.1352,4223602.7732 3059907.2136,4223602.1185 3059907.9855,4223601.8655 3059908.1727,4223601.7672 3059908.3242,4223601.6586 3059908.4917,4223601.4325 3059908.7034,4223600.9772 3059909.1295,4223599.7889 3059910.142,4223596.737 3059912.7424,4223595.5208 3059913.7787,4223595.3933 3059913.8579,4223595.0991 3059914.1187,4223591.8928 3059916.5639,4223591.6313 3059916.997,4223590.6988 3059918.5416,4223584.4044 3059928.96730001,4223583.7904 3059929.98480001,4223574.39 3059945.5639,4223574.0191 3059946.0444,4223573.7951 3059946.44100001,4223573.707 3059946.5573,4223571.0743 3059950.0281,4223568.5288 3059953.3838,4223568.0764 3059953.9098,4223567.7282 3059954.3146,4223567.1356 3059954.8758,4223562.3835 3059959.02199999,4223551.7699 3059968.2824,4223550.0081 3059969.8195,4223547.0832 3059975.23800001,4223547.0818 3059975.2408,4223546.7469 3059975.8607,4223539.626 3059989.04130001,4223533.6699 3060000.0659,4223528.0076 3060010.5467,4223527.7534 3060011.5727,4223527.7478 3060011.5902,4223527.7467 3060011.5997,4223526.9877 3060014.6267,4223523.4015 3060028.9305,4223523.0623 3060029.9808,4223523.0503 3060030.0489,4223520.0763 3060037.8803,4223520.0742 3060037.8836,4223520.0734 3060037.88779999,4223519.3019 3060039.9147,4223511.9718 3060059.1699,4223511.4844 3060060.4507,4223508.8245 3060067.4407,4223507.9517 3060069.7345,4223506.5766 3060073.34820001,4223506.3934 3060073.8296,4223506.0974 3060074.6112,4223505.9251 3060075.066,4223503.9777 3060080.2082,4223499.2546 3060092.6791,4223498.9851 3060093.3909,4223498.9189 3060093.609,4223498.871 3060093.7669,4223498.7315 3060094.2269,4223498.602 3060094.8242,4223498.4939 3060095.3225,4223494.2466 3060114.9025,4223493.4996 3060118.3463,4223493.0167 3060119.9275,4223492.9356 3060120.0526,4223492.9085 3060120.1884,4223487.029 3060133.8143,4223486.8508 3060134.22730001,4223481.2053 3060147.3518,4223481.0452 3060147.724,4223477.8942 3060155.0427,4223477.7087 3060155.4732,4223465.8383 3060183.0271,4223461.2523 3060193.6723,4223451.8295 3060215.5454,4223451.5851 3060216.119,4223438.1132 3060247.7376,4223437.1002 3060249.2596,4223436.2258 3060250.6393,4223425.2644 3060262.391,4223424.8206 3060262.8667,4223411.1403 3060277.5332,4223410.6749 3060278.0321,4223390.3714 3060299.7971,4223389.8655 3060300.3394,4223388.0742 3060302.2612,4223387.9872 3060302.3225,4223387.9259 3060302.4169,4223386.3768 3060303.7159,4223365.089 3060318.1724,4223356.2702 3060324.1612,4223333.9261 3060339.3272,4223329.9379 3060345.5207,4223315.501 3060367.9405,4223311.8802 3060379.8533,4223306.5744 3060397.3096,4223308.2367 3060397.6026,4223308.5895 3060397.70280001,4223308.8759 3060397.7341,4223309.5258 3060397.88710001,4223311.9586 3060398.46000001,4223312.5007 3060398.6307,4223312.6706 3060398.6556,4223312.8496 3060398.712,4223312.8945 3060398.7024,4223316.7313 3060399.41100001,4223320.0047 3060401.534,4223321.6169 3060403.5747,4223322.8669 3060405.6686,4223322.9261 3060405.7845,4223322.967 3060405.83630001,4223324.6666 3060408.6832,4223325.266 3060409.8566,4223325.9111 3060412.8842,4223326.0791 3060413.6726,4223325.3699 3060417.5094,4223324.1218 3060419.7524,4223324.0554 3060419.8421,4223324.0497 3060419.8499,4223326.8708 3060421.9715,4223326.9628 3060422.0411,4223301.0502 3060469.0964,4223299.2336 3060472.3952,4223298.7078 3060477.85630001,4223295.342 3060512.8115,4223293.2838 3060543.3206,4223293.1701 3060545.0056,4223293.0146 3060547.3115,4223291.2376 3060573.6522,4223291.8539 3060573.8972,4223291.8198 3060573.9933,4223291.6373 3060574.5071,4223291.3784 3060575.1081,4223291.0807 3060575.6909,4223290.7455 3060576.2529,4223290.3743 3060576.7919,4223289.9687 3060577.3054,4223289.5303 3060577.7914,4223289.0611 3060578.24760001,4223288.5631 3060578.6721,4223288.0383 3060579.0632,4223287.9814 3060579.1025,4223284.4271 3060581.5383,4223280.1888 3060584.4431,4223275.777 3060587.4668,4223275.3916 3060588.681,4223274.2855 3060592.1655,4223274.3621 3060592.5252,4223274.2666 3060593.2551,4223276.4321 3060595.4478,4223278.2599 3060598.1614,4223278.5028 3060598.5219,4223279.3159 3060602.3379,4223278.6067 3060606.1747,4223277.2155 3060608.6065,4223273.2839 3060613.6708,4223272.5515 3060614.51270001,4223270.1119 3060616.1914,4223269.337 3060616.7247,4223268.8194 3060616.9154,4223268.3368 3060617.0933,4223260.7351 3060619.4427,4223259.4652 3060622.0997,4223257.4877 3060626.2375,4223255.4109 3060630.5829,4223255.3731 3060630.6801,4223253.1668 3060636.35860001,4223252.0419 3060639.2539,4223252.0152 3060639.2955,4223252.006 3060639.34520001,4223251.558 3060640.287,4223251.3657 3060640.6913,4223246.0422 3060650.2354,4223247.4897 3060652.1115,4223248.9481 3060654.5908,4223249.8722 3060658.6085,4223250.0439 3060662.3543,4223250.145 3060664.5582,4223250.115 3060664.7365,4223250.156 3060664.9289,4223249.9812 3060666.8337,4223249.9355 3060667.3314,4223247.1621 3060681.2557,4223247.0736 3060682.0983,4223246.7456 3060685.2198,4223245.1765 3060700.1545,4223245.0375 3060700.7179,4223244.9573 3060701.4846,4223244.9058 3060701.713,4223244.0927 3060705.3157,4223240.6858 3060720.4116,4223239.4506 3060727.9782,4223238.3581 3060734.6708,4223238.2339 3060736.9153,4223237.9678 3060741.7233,4223237.9385 3060742.25219999,4223237.8202 3060744.3898,4223238.5629 3060752.9424,4223239.1154 3060759.3048,4223239.0879 3060759.49200001,4223239.1448 3060759.738,4223239.9425 3060776.0511,4223239.9144 3060776.2199,4223239.9535 3060776.4037,4223239.951 3060776.437,4223239.9552 3060776.4566,4223239.9757 3060777.9706,4223240.0788 3060785.5575,4223240.0993 3060787.0691,4223239.8598 3060788.3645,4223239.8626 3060789.5836,4223239.8746 3060794.9407,4223243.5158 3060798.756,4223243.7031 3060798.7462,4223245.0036 3060800.6362,4223245.5538 3060803.2186,4223245.8167 3060804.4522,4223245.1252 3060808.2443,4223243.5322 3060812.2995,4223256.0434 3060837.3831,4223256.1058 3060837.6402,4223256.2806 3060837.8942,4223257.0937 3060841.7101,4223256.7888 3060844.3002,4223256.7245 3060844.5542,4223253.9762 3060855.4095,4223253.3743 3060857.7868,4223254.2745 3060858.8085,4223256.9264 3060861.8188,4223260.5459 3060865.9271,4223262.2279 3060868.58479999,4223263.0297 3060872.04260001,4223263.0664 3060872.7839,4223264.7133 3060906.0852,4223265.4421 3060920.8216,4223273.2525 3060930.1581,4223279.7087 3060937.8757,4223279.9626 3060938.2885,4223280.454 3060938.8902,4223281.3809 3060940.3342,4223282.3061 3060941.62,4223282.8209 3060942.3353,4223286.5311 3060963.55500001,4223288.2118 3060973.1726,4223283.9118 3060985.6509,4223283.4354 3060987.0333,4223277.9106 3061003.066,4223273.2498 3061016.5911,4223270.623 3061024.2077,4223265.2524 3061039.7876,4223264.7589 3061041.2191,4223264.5944 3061041.6964,4223262.6256 3061047.4078,4223261.8285 3061049.7202,4223260.7381 3061052.522,4223241.1886 3061102.75570001,4223238.6837 3061109.1921,4223236.0907 3061118.4507,4223225.3592 3061156.7686,4223223.2566 3061164.2764,4223222.4014 3061167.3298,4223220.3 3061177.5865,4223213.9725 3061208.47090001,4223212.4507 3061215.8983,4223209.5818 3061229.90130001,4223208.2855 3061236.2285,4223212.414 3061243.341,4223213.9178 3061245.9319,4223217.7483 3061250.3597,4223230.469 3061265.0462,4223243.7273 3061270.8335,4223251.2167 3061274.1026,4223257.1959 3061276.7126,4223296.3433 3061293.8005,4223299.9278 3061295.3652,4223330.8338 3061303.1635,4223343.9832 3061306.4814,4223354.2799 3061311.6103,4223368.4815 3061318.6843,4223391.9122 3061330.3554,4223415.2582 3061346.107,4223417.1942 3061347.41330001,4223430.0454 3061356.084,4223433.1786 3061362.2563,4223441.2954 3061378.2461,4223441.8627 3061379.3635,4223445.223 3061385.9798,4223449.8778 3061395.1447,4223452.1807 3061393.2383,4223455.9029 3061397.6027,4223466.0843 3061409.5406,4223466.2004 3061409.6646,4223466.2024 3061409.6675,4223467.2622 3061410.7116,4223483.4291 3061426.6394,4223483.4402 3061426.6466,4223483.4477 3061426.65740001,4223499.5922 3061442.157,4223499.6002 3061442.1621,4223499.8437 3061442.36190001,4223520.3694 3061456.48310001,4223541.5762 3061473.5646,4223541.6388 3061473.6012,4223541.7537 3061473.6923,4223559.3274 3061484.9233,4223559.4175 3061484.9694,4223559.4624 3061485.0022,4223565.761 3061488.3656,4223569.2892 3061490.24960001,4223578.0809 3061494.9442,4223578.2829 3061495.0381,4223578.2869 3061495.0388,4223578.2902 3061495.04089999,4223596.0037 3061502.015,4223604.1117 3061505.359,4223609.1488 3061507.4365,4223624.3008 3061520.5385,4223635.0342 3061532.0392,4223641.2595 3061538.7095,4223641.2973 3061538.73530001,4223641.3269 3061538.77830001,4223641.5707 3061538.98050001,4223657.9636 3061550.5141,4223674.1959 3061570.1009,4223675.0565 3061571.1393,4223675.1066 3061571.1935,4223675.2016 3061571.2965,4223675.5775 3061571.0844,4223677.6943 3061569.8899,4223677.7535 3061569.85640001,4223678.2034 3061570.9901,4223679.5262 3061573.8952,4223679.6187 3061574.0983,4223689.1454 3061585.2723,4223698.4828 3061594.6333,4223707.4493 3061600.4352,4223714.91 3061604.2839,4223718.1798 3061604.87030001,4223720.2566 3061603.3738,4223720.4524 3061603.2328,4223722.6298 3061601.9993,4223725.618 3061601.1686,4223743.4327 3061598.73240001,4223764.0686 3061595.6296,4223765.6208 3061595.5028,4223770.0332 3061596.3177,4223773.7976 3061598.75909999,4223774.6525 3061600.0006,4223775.1914 3061600.7848,4223775.6887 3061601.5075,4223776.3409 3061602.4554,4223776.4514 3061602.7228,4223776.4875 3061602.814,4223776.596 3061603.1013,4223776.6946 3061603.3883,4223776.725 3061603.4816,4223776.8281 3061603.8171,4223776.9208 3061604.1622,4223776.9446 3061604.2576,4223777.0123 3061604.5469,4223777.0786 3061604.877,4223777.0963 3061604.9735,4223777.1486 3061605.287,4223777.1901 3061605.5945,4223777.2016 3061605.69160001,4223777.2364 3061606.034,4223777.2593 3061606.3738,4223777.2642 3061606.472,4223777.2749 3061606.7857,4223777.2759 3061607.0965,4223777.2747 3061607.1948,4223777.2677 3061607.4294,4223781.6249 3061607.613,4223781.5971 3061607.6572,4223781.4017 3061607.9066,4223779.946 3061610.2403,4223778.1352 3061613.1431,4223770.3536 3061625.6178,4223760.3875 3061639.6839,4223759.9238 3061640.3383,4223747.366 3061645.503,4223740.9277 3061645.5914,4223740.9219 3061645.6176,4223740.8855 3061646.1964,4223740.6741 3061647.7068,4223740.4092 3061649.5988,4223740.4083 3061649.6056,4223740.1012 3061651.7997,4223742.5203 3061659.4085,4223747.0089 3061671.6944,4223747.085 3061672.1081,4223747.451 3061673.3165,4223747.5419 3061673.8108,4223747.706 3061675.4841,4223747.6921 3061675.5596,4223747.7025 3061675.6087,4223747.6955 3061675.8604,4223747.703 3061675.8998,4223747.6995 3061676.0244,4223747.6895 3061676.0728,4223747.6793 3061676.4396,4223746.8898 3061687.7704,4223746.1003 3061699.1013,4223746.0627 3061699.266,4223746.0336 3061699.7736,4223745.749 3061701.9786,4223745.5817 3061702.624,4223745.5111 3061703.209,4223745.4169 3061703.5722,4223745.027 3061704.76320001,4223744.1264 3061706.5046,4223743.8079 3061706.9956,4223742.9454 3061708.0763,4223742.585 3061708.5279,4223741.0866 3061709.7922,4223740.6044 3061710.124,4223738.8883 3061711.07170001,4223737.0202 3061711.6664,4223736.4478 3061711.7885,4223734.4997 3061712.0074,4223732.5463 3061711.842,4223731.9708 3061711.7357,4223730.0873 3061711.1925,4223728.346 3061710.2922,4223727.855 3061709.9737,4223726.3229 3061708.751,4223725.0588 3061707.2529,4223724.727 3061706.7708,4223723.7796 3061705.0548,4223723.1851 3061703.1869,4223723.0631 3061702.61450001,4223722.8445 3061700.6665,4223722.8712 3061700.5221,4223722.8567 3061700.45409999,4223722.879 3061700.0651,4223722.86 3061699.9573,4223722.8722 3061699.745,4223722.9062 3061699.5917,4223722.938 3061699.0387,4223723.1792 3061697.1689,4223723.9452 3061686.1751,4223724.5644 3061677.2883,4223720.8328 3061667.0744,4223720.8186 3061666.9972,4223720.6751 3061666.6126,4223717.3903 3061656.281,4223717.2958 3061655.698,4223717.0491 3061654.8513,4223716.9787 3061654.4173,4223716.8508 3061652.953,4223716.8801 3061652.7947,4223716.8652 3061652.7247,4223716.891 3061652.3137,4223716.8696 3061652.1902,4223716.8839 3061651.9619,4223716.9244 3061651.7816,4223716.9608 3061651.2027,4223717.3423 3061648.4774,4223717.7777 3061645.3666,4223717.5045 3061644.3746,4223704.8592 3061640.9141,4223687.495 3061628.8666,4223660.3 3061602.7128,4223638.659 3061575.8953,4223627.855 3061562.4486,4223627.7513 3061562.3345,4223627.7402 3061562.3178,4223607.0513 3061540.71960001,4223606.9523 3061540.6528,4223606.889 3061540.5679,4223584.1105 3061521.5477,4223583.9912 3061521.4766,4223583.8018 3061521.33570001,4223559.8287 3061507.9848,4223556.2796 3061506.14,4223549.097 3061502.6709,4223527.8465 3061490.31330001,4223506.9263 3061474.7522,4223485.5812 3061457.9411,4223463.6962 3061435.1853,4223463.6901 3061435.1812,4223463.6853 3061435.1741,4223460.1014 3061431.5057,4223448.4775 3061419.6077,4223447.1705 3061418.27,4223436.5624 3061404.7912,4223432.0043 3061398.9995,4223425.4021 3061390.6107,4223425.2253 3061390.4142,4223425.2138 3061390.40680001,4223425.2062 3061390.3959,4223419.8998 3061385.3674,4223400.4555 3061366.94140001,4223400.4379 3061366.9302,4223400.1844 3061366.7259,4223398.603 3061365.6782,4223385.9754 3061357.3128,4223381.9619 3061354.654,4223381.8026 3061354.5706,4223381.7408 3061354.527,4223359.0691 3061343.3659,4223358.9526 3061343.3396,4223358.8404 3061343.2704,4223355.9292 3061342.262,4223326.3959 3061332.0315,4223292.0752 3061320.1124,4223292.0576 3061320.1095,4223292.046 3061320.1024,4223265.3079 3061311.2768,4223233.5781 3061300.8034,4223233.0413 3061300.7209,4223232.9454 3061300.7027,4223204.0236 3061300.78700001,4223202.4343 3061300.7917,4223200.6659 3061300.7607,4223198.4276 3061300.7214,4223198.2772 3061302.6422,4223198.103 3061304.8673,4223197.8637 3061309.0868,4223197.7332 3061311.25879999,4223197.0132 3061323.8968,4223196.1593 3061338.8843,4223195.06 3061358.17890001,4223194.4143 3061371.85960001,4223193.6203 3061388.6837,4223192.1865 3061419.0627,4223189.5308 3061425.1627,4223171.228 3061467.2038,4223169.2579 3061480.3787,4223168.1407 3061487.8499,4223163.9392 3061515.9474,4223163.8767 3061549.9782,4223163.7879 3061598.3355,4223168.1559 3061618.5049,4223169.2966 3061623.7722,4223171.8717 3061635.66260001,4223183.1549 3061687.7624,4223184.6479 3061694.6562,4223188.6474 3061723.3134,4223188.7545 3061735.67600001,4223188.929 3061755.8319,4223234.1008 3061827.1381,4223234.2713 3061827.4074,4223236.5896 3061831.0669,4223243.5469 3061834.5473,4223253.409 3061840.76720001,4223253.5815 3061840.855,4223253.6268 3061840.88639999,4223270.6543 3061848.8703,4223273.5026 3061851.8548,4223276.405 3061856.5461,4223283.5077 3061868.6502,4223286.9245 3061894.1154,4223286.9766 3061894.5034,4223286.7686 3061895.5939,4223284.3312 3061908.375,4223283.1363 3061914.6403,4223278.5437 3061932.8201,4223274.114 3061950.35469999,4223274.0279 3061950.6954,4223261.6219 3061999.17810001,4223261.6165 3061999.2235,4223261.596 3061999.2933,4223260.5568 3062004.6542,4223254.4295 3062036.2622,4223253.2221 3062042.4905,4223253.2115 3062042.609,4223253.195 3062042.6772,4223253.179 3062042.8415,4223251.4207 3062060.8965,4223251.144 3062063.7381,4223248.1698 3062082.7075,4223235.5569 3062113.5991,4223235.5559 3062113.6044,4223235.5528 3062113.609,4223231.7887 3062122.9718,4223223.1148 3062144.5465,4223223.0596 3062144.68390001,4223221.1244 3062151.2562,4223206.2402 3062203.7294,4223206.2342 3062203.77470001,4223206.2062 3062203.8678,4223201.8386 3062224.8647,4223200.4683 3062228.4793,4223195.1072 3062242.62050001,4223194.9775 3062243.3566,4223195.1401 3062244.1198,4223195.1706 3062244.1642,4223195.1816 3062244.21009999,4223196.4132 3062246.7213,4223197.2048 3062248.3351,4223199.4668 3062252.9471,4223202.1203 3062260.5607,4223199.677 3062267.3556,4223197.8504 3062271.3942,4223192.8947 3062282.3515,4223178.3175 3062312.5248,4223178.2864 3062312.665,4223178.2125 3062312.7882,4223171.0203 3062335.3838,4223170.9262 3062336.0176,4223170.9416 3062336.0898,4223170.9325 3062336.1505,4223171.3659 3062341.5462,4223172.0606 3062350.1956,4223172.217 3062350.8258,4223172.2467 3062350.869,4223172.2574 3062350.9139,4223179.3101 3062365.337,4223180.4178 3062367.6022,4223182.4098 3062372.6656,4223186.0564 3062381.9346,4223188.6121 3062388.4307,4223188.6291 3062388.4563,4223188.6361 3062388.4889,4223188.8108 3062388.81039999,4223195.1965 3062398.3568,4223200.007 3062405.54839999,4223204.8 3062412.7137,4223205.7569 3062414.14420001,4223219.3946 3062434.53219999,4223247.4081 3062482.6019,4223257.4624 3062499.8545,4223258.2223 3062501.1585,4223260.3006 3062504.7904,4223260.8764 3062505.83129999,4223263.7178 3062510.9674,4223272.8711 3062527.5135,4223277.1899 3062535.3202,4223290.3826 3062559.1679,4223294.3214 3062566.2879,4223322.1803 3062617.24950001,4223325.0798 3062622.5534,4223325.0896 3062622.5662,4223325.1104 3062622.6073,4223337.6999 3062644.0315,4223337.7116 3062644.0463,4223337.7284 3062644.0784,4223344.7525 3062655.3137,4223347.7774 3062660.1522,4223348.3489 3062661.0663,4223350.9912 3062665.2927,4223355.2061 3062672.0346,4223355.2462 3062672.0843,4223355.2819 3062672.1471,4223374.8433 3062699.17,4223383.5777 3062711.3884,4223390.1286 3062720.5523,4223390.1622 3062720.5914,4223390.1789 3062720.6196,4223403.6012 3062737.82,4223416.6554 3062755.12120001,4223417.5185 3062756.2651,4223418.7634 3062757.9151,4223427.1121 3062768.9799,4223427.2364 3062769.1208,4223427.2546 3062769.1485,4223429.0065 3062771.0034,4223458.2975 3062802.0178,4223458.3355 3062802.0435,4223458.3642 3062802.0851,4223487.8465 3062830.4765,4223487.8921 3062830.5059,4223487.9192 3062830.54309999,4223514.0885 3062853.3676)),((4224522.3816 3061229.2867,4224522.3693 3061229.31120001,4224519.4697 3061235.09930001,4224519.399 3061235.1988,4224519.2373 3061235.53930001,4224515.5988 3061242.07950001,4224505.5679 3061260.1107,4224505.2373 3061260.86500001,4224499.3282 3061274.3486,4224497.9864 3061286.4107,4224497.9445 3061286.5785,4224497.9387 3061286.682,4224497.9247 3061286.79210001,4224497.8039 3061287.2601,4224497.7492 3061287.7303,4224497.7197 3061287.8486,4224497.6747 3061287.988,4224497.6526 3061288.1618,4224493.8786 3061302.0168,4224491.708 3061309.9854,4224490.3855 3061314.8405,4224490.2362 3061315.2874,4224490.2214 3061315.382,4224490.1748 3061315.5216,4224490.1374 3061315.5829,4224490.0278 3061315.9108,4224490.0199 3061315.9551,4224482.0202 3061336.7039,4224478.0822 3061349.0755,4224477.9769 3061349.2513,4224477.8701 3061349.59810001,4224477.8432 3061349.7434,4224477.7959 3061349.839,4224477.7645 3061349.9409,4224477.6841 3061350.0649,4224477.5231 3061350.3901,4224477.4788 3061350.5902,4224477.1621 3061351.2458,4224474.1672 3061357.4448,4224470.6883 3061367.6669,4224466.5254 3061382.7689,4224466.3784 3061383.20640001,4224466.3645 3061383.2955,4224466.3119 3061383.4521,4224466.2763 3061383.5104,4224466.1748 3061383.8125,4224466.1418 3061383.8635,4224466.1223 3061383.9691,4224465.6008 3061385.07590001,4224465.4246 3061385.39709999,4224465.0943 3061385.8439,4224464.7719 3061386.432,4224455.0383 3061399.9533,4224454.6729 3061400.3891,4224454.5846 3061400.5368,4224454.4422 3061400.7066,4224454.3475 3061400.7771,4224454.0891 3061401.0852,4224454.0308 3061401.1253,4224453.9467 3061401.255,4224440.1222 3061415.4617,4224439.6204 3061416.0073,4224432.6007 3061423.6397,4224426.0092 3061430.8065,4224425.0962 3061431.7992,4224420.1611 3061447.30320001,4224420.0585 3061447.4744,4224419.9345 3061447.9042,4224419.9221 3061447.9711,4224419.9055 3061448.0047,4224419.8863 3061448.0713,4224419.4742 3061448.9729,4224419.3375 3061449.2366,4224419.1995 3061449.4279,4224419.1843 3061449.4533,4224419.1401 3061449.5107,4224418.8382 3061450.093,4224406.7492 3061468.6487,4224397.8257 3061485.67159999,4224397.6372 3061486.0312,4224392.8685 3061495.6501,4224392.8562 3061495.6674,4224392.8239 3061495.7391,4224388.9654 3061503.3554,4224389.9648 3061516.39520001,4224391.9736 3061536.8507,4224394.9711 3061552.2999,4224395.0176 3061552.75209999,4224395.0835 3061552.9915,4224399.4682 3061586.3498,4224402.8685 3061604.7226,4224406.1224 3061620.354,4224412.0225 3061636.6011,4224412.0447 3061636.7221,4224412.0911 3061636.7954,4224417.2541 3061651.8477,4224417.342 3061652.3536,4224417.5931 3061653.19789999,4224417.6177 3061653.2739,4224417.6235 3061653.3001,4224417.6474 3061653.3804,4224417.7091 3061653.7352,4224417.8208 3061654.9361,4224417.8343 3061655.0515,4224417.8703 3061655.2133,4224418.2989 3061668.7031,4224418.8294 3061678.1496,4224421.3338 3061683.9006,4224421.3373 3061683.917,4224421.3475 3061683.9355,4224421.3511 3061683.9408,4224421.3521 3061683.9439,4224421.3538 3061683.94690001,4224421.7523 3061685.0463,4224421.8523 3061685.382,4224421.8895 3061685.6259,4224421.941 3061685.7873,4224421.9417 3061685.79060001,4224421.9659 3061686.0036,4224422.1197 3061686.5205,4224425.3468 3061705.0703,4224425.3764 3061705.3883,4224425.4189 3061705.5426,4224427.6308 3061722.3709,4224427.6657 3061722.8532,4224427.6901 3061722.9533,4224427.7027 3061723.1284,4224427.691 3061723.2041,4224427.7151 3061723.5382,4224427.7037 3061723.6002,4224427.7278 3061723.7133,4224427.5622 3061725.6666,4224427.4558 3061726.2422,4224426.9122 3061728.1257,4224426.0117 3061729.867,4224425.6932 3061730.3581,4224424.4702 3061731.8903,4224422.9719 3061733.1546,4224422.4897 3061733.4864,4224420.7735 3061734.4341,4224418.9055 3061735.0289,4224418.333 3061735.1509,4224416.3849 3061735.3698,4224414.4316 3061735.2044,4224413.856 3061735.0981,4224411.9725 3061734.5549,4224411.1736 3061734.1418,4224410.2313 3061733.65459999,4224409.7403 3061733.3362,4224408.2081 3061732.1135,4224406.944 3061730.6153,4224406.6123 3061730.1332,4224405.6648 3061728.41720001,4224405.209 3061727.1181,4224405.0997 3061726.7208,4224404.9949 3061725.9825,4224404.8267 3061725.3713,4224402.8599 3061710.408,4224402.6459 3061708.7799,4224399.6959 3061691.8225,4224397.9179 3061687.7393,4224397.8825 3061687.401,4224397.3227 3061682.0508,4224397.2951 3061681.9448,4224397.2704 3061681.7602,4224395.64 3061675.4284,4224388.1099 3061646.1852,4224388.0256 3061645.9386,4224388.0222 3061645.922,4224387.113 3061643.7155,4224385.1394 3061638.9258,4224382.6483 3061632.8803,4224378.4378 3061621.8496,4224371.5255 3061603.7407,4224369.3671 3061598.0861,4224364.9434 3061568.5283,4224363.406 3061558.2564,4224360.3288 3061530.7677,4224363.3311 3061496.7815,4224363.5003 3061494.867,4224363.5206 3061494.6371,4224367.1788 3061480.1303,4224372.5855 3061470.6707,4224402.6569 3061424.2059,4224403.8806 3061422.3892,4224404.1037 3061422.058,4224407.6463 3061416.7987,4224418.0394 3061405.4984,4224423.3038 3061399.7745,4224423.384 3061399.7177,4224423.4776 3061399.6003,4224423.4973 3061399.57,4224423.5117 3061399.5577,4224423.5264 3061399.5393,4224436.8698 3061385.827,4224444.9004 3061374.6713,4224448.6054 3061361.2306,4224448.7523 3061360.7931,4224448.7663 3061360.704,4224448.7902 3061360.6327,4224448.7994 3061360.6177,4224448.8051 3061360.5815,4224452.6198 3061349.3727,4224452.7134 3061349.22,4224452.7707 3061349.0339,4224452.7966 3061348.8935,4224452.8414 3061348.8041,4224452.866 3061348.7242,4224452.9338 3061348.6197,4224453.1081 3061348.272,4224453.1518 3061348.07500001,4224456.4135 3061341.3236,4224460.2079 3061329.40300001,4224460.3108 3061329.2314,4224460.4175 3061328.8575,4224460.4361 3061328.7541,4224460.5494 3061328.4601,4224465.8917 3061314.6039,4224468.3483 3061308.2322,4224469.2774 3061304.8214,4224475.226 3061282.9828,4224476.6626 3061270.06860001,4224476.8193 3061269.4416,4224476.8998 3061268.74900001,4224476.9971 3061268.3597,4224477.4087 3061267.08370001,4224477.422 3061267.0579,4224477.4283 3061267.0367,4224477.4512 3061267.0014,4224477.5363 3061266.8369,4224477.5592 3061266.7237,4224480.2212 3061260.6495,4224484.7206 3061250.3828,4224484.79 3061250.2796,4224484.8363 3061250.1822,4224484.8557 3061250.0931,4224484.9097 3061249.9794,4224484.9819 3061249.87570001,4224485.204 3061249.408,4224487.1529 3061245.9046,4224493.0584 3061235.2893,4224496.2938 3061229.47360001,4224497.7378 3061226.8778,4224499.0172 3061224.578,4224501.1448 3061220.3309,4224487.4861 3061215.0339,4224488.0428 3061214.0552,4224503.4383 3061186.9891,4224503.4882 3061186.8854,4224503.5053 3061186.8609,4224530.7319 3061129.7478,4224533.4565 3061125.4704,4224534.4514 3061123.9083,4224550.8324 3061098.1907,4224573.5137 3061071.6521,4224573.6192 3061071.4773,4224573.7796 3061071.2523,4224591.7532 3061035.5671,4224591.8011 3061035.4605,4224591.8063 3061035.4527,4224617.5046 3060975.2593,4224617.791 3060974.58850001,4224620.7537 3060968.2933,4224622.3516 3060964.8982,4224632.5171 3060943.2984,4224634.8709 3060938.2971,4224635.6315 3060936.68089999,4224636.0755 3060935.73760001,4224650.5914 3060919.1055,4224661.65 3060909.26319999,4224670.4764 3060901.4072,4224707.0933 3060865.3557,4224731.834 3060833.3265,4224736.4503 3060827.3502,4224739.7109 3060830.3156,4224772.3839 3060789.7744,4224798.45 3060781.9262,4224839.6207 3060730.8377,4224848.1758 3060712.78040001,4224859.5127 3060688.8518,4224861.6108 3060685.2593,4224885.8727 3060643.7169,4224915.6049 3060599.4558,4224939.6974 3060559.6824,4224969.834 3060526.0565,4224998.8888 3060481.2547,4225009.3817 3060458.2353,4225012.2425 3060451.9593,4225018.0216 3060420.3195,4225019.3885 3060412.8359,4225019.4943 3060412.257,4225021.1548 3060407.763,4225035.5038 3060368.93,4225037.7938 3060362.73240001,4225045.5978 3060341.6038,4225047.2538 3060336.1407,4225054.8618 3060313.6313,4225067.5411 3060276.1181,4225068.5344 3060273.1765,4225076.4441 3060249.754,4225081.4344 3060235.0045,4225085.6377 3060220.4858,4225088.2169 3060213.0399,4225096.4206 3060188.2055,4225099.3695 3060179.2644,4225101.618 3060168.0828,4225106.6487 3060143.052,4225105.3854 3060128.0882,4225104.205 3060114.0734,4225106.2726 3060106.9597,4225111.4571 3060089.1222,4225116.1449 3060073.0103,4225117.4577 3060068.4983,4225121.3759 3060062.4442,4225137.4498 3060037.5619,4225138.5863 3060035.8161,4225146.5106 3060020.6063,4225152.5441 3060009.0327,4225163.3637 3059983.8725,4225163.4008 3059982.9219,4225180.4438 3059985.3924,4225180.4405 3059985.3694,4225177.8601 3059967.2961,4225176.3513 3059962.7311,4225176.3499 3059962.727,4225175.1408 3059959.0627,4225169.8519 3059943.0347,4225169.85 3059943.0236,4225169.8365 3059942.9876,4225169.4294 3059941.7331,4225169.2054 3059940.377,4225169.0152 3059939.6082,4225168.9723 3059939.435,4225167.3965 3059919.5009,4225166.8472 3059912.55220001,4225166.8924 3059912.2536,4225166.817 3059911.9,4225167.0234 3059909.7384,4225169.3933 3059898.2825,4225170.2136 3059894.3173,4225172.6769 3059882.3641,4225172.0383 3059866.7422,4225172.0083 3059866.0084,4225170.88 3059838.4308,4225164.6086 3059825.35240001,4225157.5472 3059815.8244,4225157.0371 3059814.9377,4225156.7365 3059814.5358,4225140.0866 3059782.96990001,4225138.9879 3059780.8869,4225131.7973 3059767.2667,4225114.3499 3059745.5053,4225113.3745 3059744.2888,4225112.8079 3059743.5891,4225103.7329 3059732.8018,4225088.7189 3059714.9547,4225083.6833 3059712.09,4225082.671 3059711.5144,4225064.5543 3059701.21209999,4225062.5242 3059699.6867,4225062.3334 3059699.4093,4225062.0993 3059699.2471,4225056.1886 3059692.7465,4225055.3355 3059691.8084,4225054.8605 3059691.2859,4225054.8571 3059691.2806,4225054.8533 3059691.27800001,4225052.1325 3059688.2792,4225051.3241 3059687.38819999,4225049.0376 3059684.8682,4225047.7467 3059683.4454,4225037.1518 3059671.7711,4225014.944 3059647.3009,4225014.661 3059646.98899999,4225012.7502 3059644.88350001,4225001.633 3059632.6354,4225000.9122 3059631.515,4225000.1169 3059630.4333,4224998.6263 3059627.4903,4224997.8585 3059625.9743,4224994.9841 3059620.2991,4224994.7576 3059619.85180001,4224993.4166 3059617.20430001,4224993.4157 3059617.2006,4224993.4127 3059617.1964,4224990.5377 3059611.5078,4224985.8384 3059602.2093,4224985.7701 3059601.9308,4224985.5776 3059601.6511,4224985.0198 3059599.9486,4224982.6091 3059589.5062,4224981.5239 3059584.8055,4224979.4099 3059575.6559,4224978.1572 3059570.2355,4224977.0298 3059565.357,4224977.0289 3059565.3534,4224972.4078 3059545.3146,4224971.8377 3059545.4077,4224961.3296 3059547.1235,4224958.9123 3059547.5182,4224957.6471 3059547.7247,4224955.7495 3059548.0345,4224955.6281 3059548.0543,4224953.9028 3059549.0362,4224947.1701 3059552.8677,4224933.0267 3059560.8223,4224920.2399 3059567.78820001,4224915.3724 3059569.4851,4224898.7194 3059575.284,4224894.0401 3059578.3184,4224887.5863 3059582.5073,4224883.6653 3059585.115,4224879.3713 3059587.9708,4224879.1416 3059588.1235,4224876.028 3059590.24650001,4224874.187 3059591.5017,4224864.064 3059602.4114,4224860.882 3059608.9155,4224857.8239 3059615.1527,4224852.7269 3059625.5486,4224845.9152 3059639.473,4224832.2061 3059649.3886,4224813.104 3059663.20489999,4224804.3194 3059669.5659,4224793.0206 3059678.5815,4224774.3016 3059693.5294,4224739.1182 3059718.59390001,4224709.1224 3059736.0767,4224710.4844 3059738.6992,4224710.894 3059739.4878,4224706.5563 3059742.6823,4224706.3734 3059742.84170001,4224706.3485 3059742.8583,4224701.2671 3059747.7955,4224700.0058 3059749.021,4224692.5791 3059756.23690001,4224687.8205 3059760.8605,4224679.3107 3059769.1287,4224679.2955 3059769.1515,4224679.2711 3059769.1683,4224678.9227 3059769.6545,4224676.6194 3059774.1707,4224672.8757 3059781.51100001,4224664.8852 3059797.1778,4224653.1347 3059816.6636,4224640.4122 3059834.6671,4224634.7926 3059842.6194,4224627.2096 3059853.3501,4224627.1147 3059853.5249,4224627.0532 3059853.6116,4224620.8847 3059865.9775,4224613.6302 3059880.5204,4224613.4347 3059880.9124,4224601.0285 3059905.78290001,4224585.715 3059928.0956,4224580.0343 3059936.3727,4224574.0318 3059944.684,4224553.746 3059972.77280001,4224553.5094 3059973.2037,4224553.4286 3059973.4527,4224551.9567 3059979.2622,4224548.5234 3059992.8134,4224545.6079 3060004.3209,4224544.8312 3060007.3867,4224544.6216 3060007.8264,4224544.5369 3060008.0043,4224541.4425 3060017.67889999,4224533.4464 3060042.6789,4224533.8697 3060055.8553,4224535.3345 3060101.4472,4224532.5454 3060124.3044,4224532.483 3060124.4914,4224529.8015 3060132.5361,4224526.8311 3060143.9411,4224527.0099 3060157.1039,4224530.1347 3060176.7498,4224530.1826 3060177.3073,4224530.2222 3060177.465,4224530.2317 3060177.5758,4224530.2204 3060177.652,4224530.2512 3060177.77960001,4224531.0789 3060190.0083,4224531.053 3060190.1733,4224531.0886 3060190.4903,4224531.1008 3060190.5478,4224531.0986 3060190.5794,4224531.1041 3060190.6287,4224531.0173 3060192.0858,4224530.9596 3060192.5187,4224530.7404 3060193.3568,4224530.6617 3060193.94770001,4224525.2428 3060212.8907,4224524.1792 3060219.4358,4224525.1449 3060226.8604,4224527.0408 3060230.4066,4224533.8674 3060235.8378,4224534.1875 3060236.1196,4224534.2495 3060236.1576,4224534.3616 3060236.2564,4224534.3949 3060236.3022,4224534.6151 3060236.4961,4224534.6474 3060236.5431,4224534.7273 3060236.5949,4224534.7709 3060236.6466,4224534.7996 3060236.6703,4224534.8378 3060236.7258,4224535.1196 3060237.05980001,4224535.2814 3060237.173,4224540.5697 3060243.08929999,4224540.8317 3060243.5,4224541.5443 3060244.375,4224541.8038 3060244.7817,4224542.5589 3060246.2075,4224542.9481 3060247.2758,4224543.0464 3060247.6019,4224543.1173 3060248.06160001,4224543.3118 3060248.7076,4224545.0011 3060258.0584,4224545.0594 3060258.6624,4224545.1143 3060258.8761,4224545.138 3060259.1224,4224545.1833 3060259.9466,4224545.0176 3060261.8999,4224544.9112 3060262.47540001,4224544.3677 3060264.359,4224544.3276 3060264.4207,4224544.3187 3060264.4688,4224544.2194 3060264.6918,4224544.2118 3060264.7285,4224544.1629 3060264.8383,4224544.1372 3060264.8763,4224543.9923 3060265.2013,4224541.0155 3060271.22,4224538.4167 3060276.4745,4224538.401 3060276.4967,4224538.3803 3060276.5389,4224538.2728 3060276.69140001,4224538.0169 3060277.2135,4224525.7995 3060297.9518,4224525.505 3060298.33750001,4224525.4064 3060298.5295,4224525.3946 3060298.5478,4224525.2612 3060298.7159,4224525.1316 3060298.94450001,4224524.9368 3060299.1997,4224524.2492 3060299.9965,4224524.2157 3060300.0488,4224522.9292 3060301.3987,4224499.8255 3060325.6411,4224499.7915 3060325.6648,4224499.7655 3060325.69730001,4224499.7532 3060325.7162,4224499.7443 3060325.7237,4224499.7367 3060325.7333,4224499.7176 3060325.7464,4224499.7128 3060325.7505,4224499.6987 3060325.7721,4224452.4312 3060373.821,4224439.8456 3060388.3095,4224439.7039 3060388.413,4224439.5091 3060388.6545,4224439.4629 3060388.7257,4224439.4252 3060388.7584,4224439.383 3060388.8107,4224439.2959 3060388.8707,4224439.2899 3060388.8758,4224439.233 3060388.9616,4224438.8595 3060389.3294,4224433.0659 3060395.0352,4224432.9075 3060395.1417,4224431.6296 3060396.2084,4224431.1699 3060396.5176,4224429.5417 3060397.4054,4224429.4255 3060397.4302,4224429.3729 3060397.4664,4224429.0523 3060397.5822,4224428.9852 3060397.6254,4224428.8164 3060397.6864,4224428.7101 3060397.7057,4224428.2478 3060397.8727,4224421.7638 3060399.7844,4224421.1324 3060399.88,4224420.4322 3060400.08,4224420.0286 3060400.1411,4224418.6691 3060400.2528,4224418.5077 3060400.223,4224418.4373 3060400.238,4224418.0216 3060400.2114,4224417.8946 3060400.2334,4224417.6628 3060400.2186,4224417.4762 3060400.1766,4224416.8921 3060400.1393,4224411.4538 3060399.3659,4224408.666 3060400.4128,4224405.9114 3060402.0372,4224405.4551 3060402.2687,4224405.357 3060402.3409,4224405.188 3060402.42669999,4224405.1021 3060402.4479,4224404.7845 3060402.6091,4224404.7202 3060402.6228,4224404.6155 3060402.6948,4224402.7474 3060403.2896,4224402.175 3060403.4116,4224401.8946 3060403.4431,4224401.8392 3060403.4599,4224401.7987 3060403.4673,4224401.7168 3060403.4753,4224400.8983 3060403.5551,4224400.2269 3060403.6305,4224400.1721 3060403.6259,4224400.1387 3060403.6291,4224400.078 3060403.6179,4224399.7257 3060403.5881,4224399.5513 3060403.6199,4224396.501 3060403.4825,4224390.6924 3060403.2207,4224390.4269 3060403.1636,4224389.7613 3060403.12580001,4224389.5447 3060403.0981,4224389.2886 3060403.0319,4224389.0384 3060403.007,4224388.9253 3060402.9826,4224388.5649 3060402.8729,4224388.1644 3060402.8217,4224380.8704 3060400.8183,4224375.2424 3060401.9536,4224374.6045 3060402.0218,4224374.3267 3060402.0946,4224374.1555 3060402.1129,4224373.7454 3060402.1375,4224373.6479 3060402.1606,4224367.9238 3060402.4996,4224367.779 3060402.4762,4224367.5212 3060402.5052,4224367.4699 3060402.5161,4224367.4421 3060402.5141,4224367.4018 3060402.5186,4224366.7483 3060402.4633,4224365.7403 3060402.3895,4224365.7082 3060402.3843,4224365.6609 3060402.3712,4224365.4485 3060402.3532,4224364.8729 3060402.2469,4224362.9894 3060401.7037,4224361.6339 3060401.0414,4224361.2445 3060400.81320001,4224360.4934 3060400.2412,4224360.0041 3060399.9545,4224356.091 3060396.7832,4224354.9763 3060397.7581,4224354.5903 3060398.04439999,4224353.6226 3060398.6014,4224353.2268 3060398.895,4224348.0552 3060401.5699,4224347.8616 3060401.6184,4224347.47 3060401.8347,4224347.4139 3060401.8732,4224347.3784 3060401.88519999,4224347.3177 3060401.9187,4224346.6566 3060402.1292,4224345.8914 3060402.3879,4224345.8442 3060402.3997,4224345.7813 3060402.4079,4224345.4497 3060402.5135,4224344.8773 3060402.6355,4224342.9291 3060402.8544,4224341.5409 3060402.7768,4224341.1281 3060402.7247,4224340.3878 3060402.534,4224339.7641 3060402.4553,4224338.2193 3060402.0368,4224336.2958 3060403.3574,4224335.7228 3060403.6734,4224335.4651 3060403.87,4224335.2089 3060404.0112,4224334.3322 3060404.4404,4224332.8379 3060404.9479,4224332.3805 3060405.064,4224331.3263 3060405.20290001,4224330.825 3060405.3301,4224327.444 3060405.6369,4224327.238 3060405.607,4224326.7462 3060405.6622,4224326.6775 3060405.6769,4224326.6386 3060405.6743,4224326.5626 3060405.6829,4224321.0953 3060405.75789999,4224316.1486 3060405.8258,4224315.0548 3060405.8408,4224314.4247 3060406.1454,4224311.6684 3060408.1716,4224311.0958 3060408.4996,4224310.6983 3060408.8118,4224307.0817 3060410.9446,4224306.6255 3060411.1761,4224306.5273 3060411.2483,4224306.3583 3060411.3341,4224306.2725 3060411.3553,4224305.9548 3060411.5164,4224305.8905 3060411.5301,4224305.7858 3060411.6022,4224304.7877 3060411.9702,4224304.4832 3060412.0643,4224304.0915 3060412.1263,4224303.4517 3060412.3242,4224300.5949 3060412.8842,4224299.9651 3060412.9497,4224299.7058 3060413.01729999,4224299.5169 3060413.0369,4224299.0074 3060413.0668,4224298.9234 3060413.0862,4224292.934 3060413.3682,4224291.1165 3060413.4537,4224291.0044 3060413.4349,4224290.8241 3060413.4552,4224290.7835 3060413.4638,4224290.7618 3060413.4622,4224290.7335 3060413.4654,4224289.835 3060413.4373,4224289.5664 3060413.4168,4224289.3054 3060413.3563,4224288.674 3060413.3081,4224281.084 3060412.03539999,4224275.2226 3060412.1159,4224241.874 3060414.8493,4224241.5642 3060414.8029,4224241.1943 3060414.8817,4224239.5766 3060414.7725,4224238.8991 3060414.6711,4224236.6799 3060414.0717,4224234.9387 3060413.1714,4224234.4476 3060412.853,4224232.9155 3060411.6303,4224231.6514 3060410.1322,4224231.3197 3060409.6501,4224230.3722 3060407.9341,4224229.7777 3060406.0662,4224229.6557 3060405.4938,4224229.4371 3060403.54570001,4224229.6028 3060401.59239999,4224229.7092 3060401.0169,4224230.2527 3060399.1334,4224231.1532 3060397.39200001,4224231.4718 3060396.901,4224232.6947 3060395.3687,4224234.1931 3060394.1044,4224234.6753 3060393.77259999,4224236.3914 3060392.8249,4224237.9279 3060392.3068,4224238.3984 3060392.1896,4224239.5326 3060392.0426,4224239.9983 3060391.9266,4224260.8103 3060390.2208,4224273.7372 3060389.1612,4224273.9298 3060389.1901,4224274.3516 3060389.1427,4224274.4169 3060389.1287,4224274.4535 3060389.1312,4224274.5189 3060389.1239,4224281.8086 3060389.0238,4224282.7071 3060389.0519,4224282.9758 3060389.0724,4224283.2368 3060389.1329,4224283.8681 3060389.1811,4224291.2674 3060390.4218,4224297.002 3060390.1518,4224297.0973 3060390.1331,4224298.5158 3060389.2967,4224301.6564 3060386.9879,4224301.8798 3060386.86,4224301.9198 3060386.8255,4224301.9668 3060386.7916,4224302.3228 3060386.5899,4224302.6095 3060386.3675,4224302.7982 3060386.2594,4224303.3385 3060385.9895,4224303.4629 3060385.8999,4224307.3434 3060384.0239,4224307.4824 3060383.991,4224307.7008 3060383.8705,4224307.743 3060383.8414,4224307.7688 3060383.8329,4224307.8029 3060383.8141,4224308.4257 3060383.6158,4224309.3967 3060383.2949,4224309.4274 3060383.28759999,4224309.475 3060383.2817,4224309.671 3060383.2193,4224310.2434 3060383.0973,4224312.1915 3060382.8784,4224313.0852 3060382.8661,4224315.8364 3060382.8284,4224320.783 3060382.7605,4224325.7627 3060382.6922,4224326.3749 3060382.27180001,4224326.9479 3060381.9558,4224327.2057 3060381.75920001,4224327.4618 3060381.618,4224328.3397 3060381.1884,4224328.3445 3060381.1869,4224328.354 3060381.1839,4224328.3607 3060381.1793,4224332.7619 3060379.2962,4224332.7821 3060379.2919,4224334.6209 3060378.7075,4224335.1905 3060378.5867,4224337.1284 3060378.37,4224338.5166 3060378.44759999,4224338.9294 3060378.4997,4224339.6697 3060378.6904,4224340.2934 3060378.7691,4224341.0631 3060378.9776,4224342.187 3060377.6298,4224342.3538 3060377.5053,4224342.6626 3060377.1184,4224342.7008 3060377.0596,4224342.7301 3060377.0338,4224342.7778 3060376.974,4224343.3147 3060376.52100001,4224343.8871 3060376.0189,4224343.929 3060375.9877,4224343.9854 3060375.95510001,4224344.2762 3060375.70969999,4224344.7584 3060375.3779,4224346.4746 3060374.4302,4224347.4877 3060374.05770001,4224347.7968 3060373.9626,4224348.2024 3060373.8988,4224348.8442 3060373.7014,4224356.1162 3060372.2996,4224356.741 3060372.2356,4224356.9901 3060372.1709,4224357.2534 3060372.1439,4224358.1352 3060372.0928,4224360.0886 3060372.2582,4224360.6641 3060372.3645,4224362.5476 3060372.9077,4224364.0067 3060373.6316,4224364.4239 3060373.88280001,4224365.3408 3060374.5899,4224365.746 3060374.83399999,4224369.8556 3060378.3318,4224371.0213 3060379.2765,4224371.4872 3060379.24900001,4224379.0071 3060377.732,4224379.645 3060377.6638,4224379.9228 3060377.59100001,4224380.1988 3060377.5615,4224381.1236 3060377.5059,4224382.529 3060377.5857,4224382.9469 3060377.6392,4224383.7108 3060377.8367,4224384.3272 3060377.9155,4224393.0176 3060380.3025,4224397.175 3060380.4898,4224397.8466 3060380.0937,4224398.3029 3060379.8622,4224398.4011 3060379.79,4224398.5701 3060379.7042,4224398.6559 3060379.683,4224398.9735 3060379.5219,4224399.0161 3060379.5128,4224399.0843 3060379.4762,4224399.1426 3060379.4361,4224399.1829 3060379.42329999,4224399.2082 3060379.4097,4224399.2608 3060379.3985,4224399.5191 3060379.31619999,4224399.6455 3060379.23360001,4224406.1224 3060376.8012,4224406.4866 3060376.7325,4224407.7842 3060376.3361,4224408.291 3060376.2405,4224410.0081 3060376.06800001,4224410.1695 3060376.0979,4224410.2399 3060376.0828,4224410.6557 3060376.10940001,4224410.7826 3060376.0874,4224411.0144 3060376.1022,4224411.201 3060376.1442,4224411.7851 3060376.1815,4224417.6596 3060377.017,4224418.8941 3060376.65299999,4224422.0724 3060379.08699999,4224430.4366 3060369.0658,4224432.4399 3060366.6907,4224453.4022 3060341.8381,4224464.0412 3060329.22470001,4224466.1665 3060326.705,4224468.0713 3060324.4468,4224468.8739 3060323.4952,4224468.89 3060323.4682,4224468.9613 3060323.38379999,4224484.2297 3060302.4378,4224505.1939 3060279.8423,4224505.5858 3060279.2221,4224505.7098 3060278.7496,4224507.3661 3060266.4887,4224507.3839 3060266.1938,4224507.2887 3060265.6106,4224498.4215 3060237.9428,4224499.1504 3060216.5172,4224499.1519 3060216.5067,4224499.3062 3060215.4416,4224499.4422 3060214.5033,4224503.0392 3060189.6733,4224503.0597 3060189.3595,4224503.0487 3060189.3075,4224503.056 3060189.2616,4224502.3175 3060177.4725,4224500.9623 3060155.8376,4224503.8214 3060143.4029,4224508.0141 3060125.1687,4224510.8933 3060112.64699999,4224510.9337 3060112.2676,4224510.9441 3060112.2148,4224511.3114 3060066.32359999,4224512.5028 3060055.0095,4224517.1443 3060010.9318,4224524.1167 3059986.1524,4224527.023 3059975.8235,4224530.618 3059963.0472,4224533.6997 3059952.09489999,4224543.1541 3059941.4946,4224548.2893 3059935.737,4224549.0489 3059934.8854,4224550.6325 3059933.1098,4224551.2691 3059932.3961,4224564.6063 3059919.3585,4224564.6199 3059919.338,4224564.6416 3059919.3231,4224564.9831 3059918.8503,4224590.7451 3059869.26269999,4224598.2021 3059856.9342,4224599.2178 3059855.255,4224626.6211 3059809.9501,4224626.7471 3059809.747,4224644.1279 3059781.7195,4224669.4908 3059740.8206,4224671.9553 3059736.8465,4224674.0083 3059733.5358,4224685.8856 3059724.5501,4224718.0213 3059700.2381,4224719.9159 3059697.9459,4224720.151 3059697.7455,4224725.6377 3059693.06800001,4224742.2596 3059678.898,4224750.6882 3059670.6217,4224767.5447 3059654.0941,4224767.891 3059653.7545,4224781.6296 3059640.2851,4224785.1086 3059636.5071,4224798.6308 3059621.8535,4224800.5095 3059619.8176,4224800.5426 3059619.76650001,4224800.6045 3059619.5867,4224803.6398 3059609.1873,4224806.0594 3059600.8976,4224816.3293 3059572.7654,4224825.1666 3059554.1721,4224831.2471 3059541.3791,4224841.0278 3059526.0777,4224842.6099 3059553.0185,4224842.7693 3059553.6916,4224843.2116 3059554.3344,4224843.6379 3059554.6509,4224852.1466 3059559.3596,4224852.2507 3059559.4172,4224857.3665 3059563.7146,4224858.8758 3059566.3942,4224859.9843 3059565.2474,4224859.9983 3059565.2378,4224860.0077 3059565.2233,4224862.0169 3059563.8197,4224862.733 3059563.3194,4224863.0927 3059563.0682,4224880.3843 3059555.3353,4224881.5028 3059554.835,4224881.5068 3059554.8341,4224881.5104 3059554.8317,4224901.7049 3059545.8204,4224902.7146 3059545.3699,4224904.9192 3059544.3864,4224919.4432 3059537.3973,4224920.1698 3059537.0477,4224926.022 3059534.2311,4224934.1781 3059530.3056,4224934.8849 3059529.9654,4224935.0821 3059529.919,4224935.2688 3059529.7905,4224937.7074 3059529.0914,4224949.3771 3059527.3032,4224953.4404 3059526.6805,4224953.6855 3059526.643,4224957.3462 3059526.0821,4224971.387 3059523.9234,4224971.6946 3059523.87610001,4224974.4371 3059523.4544,4224976.8162 3059519.1892,4224989.1396 3059497.0953,4224989.8054 3059495.9016,4224990.1269 3059495.32529999,4224990.1384 3059495.3046,4224991.1037 3059493.5735,4224992.0738 3059491.8337,4225001.5323 3059474.8713,4225003.142 3059471.9844,4225003.8832 3059470.6552,4225012.3279 3059455.5118,4225013.4386 3059453.52,4225014.0673 3059452.393,4225014.0705 3059452.3869,4225017.0082 3059447.129,4225019.0988 3059443.3873,4225019.7881 3059442.4627,4225020.1407 3059441.8698,4225023.9909 3059437.24090001,4225030.3347 3059429.614,4225034.4416 3059424.6765,4225037.2228 3059421.3327,4225038.2033 3059420.1538,4225040.4625 3059417.4379,4225052.8819 3059402.5163,4225055.052 3059399.9091,4225056.4438 3059398.2369,4225056.5405 3059398.1208,4225056.8104 3059397.7965,4225057.3201 3059397.1841,4225057.4701 3059397.0721,4225057.604 3059396.8576,4225061.0627 3059393.0499,4225061.0693 3059393.0452,4225061.0744 3059393.037,4225062.6745 3059391.28160001,4225074.1377 3059378.6591,4225076.7404 3059375.7936,4225076.7429 3059375.7905,4225094.0391 3059356.7649,4225097.6239 3059352.8218,4225098.8624 3059351.4599,4225111.8233 3059335.664,4225113.3079 3059333.8547,4225115.4428 3059331.2528,4225115.7567 3059330.7904,4225120.4163 3059323.9275,4225121.1311 3059322.8747,4225122.7468 3059320.49489999,4225123.6923 3059319.101,4225124.6906 3059317.6293,4225134.8183 3059302.699,4225137.139 3059299.2778,4225140.548 3059294.252,4225147.8483 3059283.48930001,4225149.9631 3059280.3714,4225152.2357 3059277.0197,4225152.2643 3059276.9513,4225158.2351 3059262.70070001,4225159.6556 3059259.3103,4225160.2193 3059257.9649,4225161.2047 3059255.61250001,4225168.167 3059238.9917,4225168.8016 3059237.4769,4225175.903 3059220.5154,4225176.8389 3059218.28020001,4225185.2269 3059220.4774,4225186.6081 3059220.8392,4225187.8335 3059221.8642,4225188.7021 3059222.5906,4225194.6967 3059227.22669999,4225196.8982 3059228.9293,4225189.484 3059246.7998,4225188.6496 3059248.8098,4225180.9324 3059267.3996,4225179.8891 3059269.9147,4225178.2685 3059273.8219,4225172.886 3059286.7986,4225172.8012 3059286.9273,4225171.9105 3059288.602,4225168.6746 3059293.3467,4225168.6717 3059293.3502,4225168.6701 3059293.3533,4225168.4238 3059293.7138,4225161.7076 3059303.5446,4225155.63 3059312.4404,4225155.2158 3059313.0472,4225141.3034 3059333.4284,4225141.301 3059333.4313,4225141.2996 3059333.4339,4225140.8791 3059334.0492,4225138.545 3059337.4636,4225134.4745 3059343.4183,4225134.4675 3059343.4268,4225134.4634 3059343.4344,4225133.9784 3059344.1409,4225133.9436 3059344.1916,4225133.5597 3059344.6599,4225133.4403 3059344.8627,4225126.1023 3059353.8359,4225116.6221 3059365.4286,4225115.7815 3059366.4565,4225115.4169 3059366.8651,4225115.3912 3059366.9058,4225115.2798 3059367.0266,4225114.8224 3059367.5225,4225114.9457 3059367.6203,4225117.4401 3059369.5987,4225109.1259 3059373.7185,4225094.9391 3059380.7482,4225090.3295 3059384.4917,4225071.7627 3059399.6075,4225065.4951 3059407.934,4225055.5564 3059427.2618,4225053.618 3059431.0294,4225049.5197 3059438.9951,4225045.3426 3059447.114,4225042.6847 3059452.2801,4225038.0022 3059463.1852,4225031.7426 3059477.7495,4225029.878 3059482.088,4225019.0591 3059507.2982,4225017.5466 3059510.8092,4225005.6949 3059535.0835,4225003.2764 3059546.2675,4225002.4386 3059550.1193,4224999.6769 3059562.8568,4224997.5257 3059572.7783,4224997.5075 3059577.3288,4224997.4149 3059597.0315,4224998.2373 3059598.6832,4225001.4404 3059605.1162,4225003.5426 3059609.3383,4225003.783 3059599.2477,4225013.4501 3059615.2927,4225018.8255 3059624.21480001,4225018.9135 3059624.3256,4225018.9792 3059624.4348,4225029.7346 3059637.8285,4225048.3575 3059661.0193,4225052.1426 3059665.7329,4225052.3073 3059665.914,4225052.3706 3059665.95509999,4225052.4078 3059666.0054,4225066.6211 3059678.0688,4225081.9188 3059691.0527,4225082.4164 3059691.3519,4225082.4613 3059691.3812,4225106.5262 3059701.1398,4225111.4114 3059703.12080001,4225124.9823 3059716.8761,4225125.4469 3059717.4325,4225126.1717 3059718.3008,4225134.1443 3059723.3747,4225137.2867 3059725.3723,4225141.9828 3059728.3576,4225156.8118 3059748.7956,4225157.6005 3059750.6338,4225162.8949 3059762.9731,4225169.9318 3059779.3723,4225173.5945 3059787.9081,4225175.3053 3059801.6994,4225176.0876 3059808.0054,4225184.7404 3059830.2483,4225190.448 3059849.91139999,4225190.9779 3059858.538,4225191.1089 3059860.67,4225191.3467 3059864.5401,4225193.0758 3059892.7773,4225193.1304 3059893.6679,4225193.1456 3059893.91750001,4225194.7193 3059916.9166,4225195.4413 3059927.4684,4225196.9569 3059940.3885,4225199.2769 3059960.2581,4225199.5377 3059962.4879,4225202.175 3059985.0391,4225202.592 3059988.6047,4225202.6942 3059989.5033,4225199.4471 3060006.5924,4225198.3445 3060012.39470001,4225197.7944 3060015.3324,4225195.5165 3060027.2945,4225193.4657 3060030.1929,4225189.6372 3060035.5976,4225188.9796 3060036.526,4225182.063 3060046.2904,4225180.8069 3060048.05780001,4225159.8088 3060068.9874,4225158.1609 3060070.6301,4225150.7266 3060087.0911,4225146.4808 3060096.4922,4225140.4318 3060109.8761,4225139.9119 3060136.4052,4225139.6656 3060149.1995,4225142.4975 3060162.596,4225146.7258 3060182.5981,4225151.721 3060206.2282,4225158.0538 3060223.4663,4225159.6952 3060227.9344,4225160.6624 3060230.5673,4225158.685 3060233.4334,4225139.4117 3060261.3696,4225137.8784 3060263.5922,4225134.0193 3060266.8963,4225130.0269 3060270.3146,4225116.815 3060296.1828,4225116.7878 3060296.2428,4225116.781 3060296.2526,4225101.2038 3060329.916,4225101.1932 3060329.966,4225101.1609 3060330.0157,4225101.0364 3060330.49180001,4225100.5507 3060334.1377,4225098.5472 3060349.1771,4225088.0227 3060385.6693,4225088.0113 3060385.75399999,4225087.9705 3060385.9009,4225086.1224 3060397.2078,4225083.4818 3060413.3635,4225083.4809 3060413.3754,4225083.4785 3060413.38419999,4225080.0753 3060435.6644,4225070.7576 3060454.50349999,4225062.6331 3060470.19230001,4225062.2552 3060470.2729,4225061.6123 3060470.7153,4225061.2007 3060471.3381,4225052.2824 3060492.6472,4225041.3172 3060518.8471,4225030.2001 3060541.6444,4225029.6127 3060542.8488,4225025.0403 3060548.6192,4225002.4876 3060571.1268,4225002.481 3060571.1327,4224979.3069 3060591.7946,4224963.0101 3060606.3248,4224962.9729 3060606.378,4224962.9077 3060606.4228,4224962.7248 3060606.6396,4224930.8344 3060650.3884,4224930.8276 3060650.4007,4224930.8129 3060650.41840001,4224925.5539 3060657.9203,4224918.4562 3060668.04509999,4224918.4255 3060668.1019,4224918.3844 3060668.155,4224902.8216 3060693.7807,4224902.7715 3060693.8813,4224902.7458 3060693.9172,4224875.0613 3060748.7286,4224853.3038 3060778.2042,4224819.9437 3060815.1613,4224796.1815 3060834.6232,4224796.1143 3060834.6849,4224796.0984 3060834.6952,4224747.2801 3060879.3789,4224734.4237 3060889.85580001,4224734.3072 3060889.9624,4224734.2938 3060889.9714,4224703.4447 3060919.9268,4224703.3911 3060920.007,4224703.3206 3060920.0588,4224657.7724 3060973.1033,4224657.7555 3060973.1313,4224657.6715 3060973.23100001,4224643.398 3060992.8846,4224641.2432 3060995.8516,4224641.0035 3060996.2868,4224640.9195 3060996.5484,4224640.4305 3060998.5328,4224632.5223 3061030.6256,4224632.4938 3061030.8746,4224632.4672 3061030.9955,4224630.4977 3061067.1994,4224630.5014 3061067.22040001,4224630.4956 3061067.2483,4224630.1683 3061078.2023,4224623.991 3061092.9307,4224595.6078 3061127.0549,4224595.5874 3061127.0892,4224595.5109 3061127.1814,4224575.7113 3061155.26249999,4224575.4878 3061155.6749,4224575.4862 3061155.6837,4224575.4814 3061155.691,4224561.4515 3061191.8143,4224561.4489 3061191.8291,4224561.4414 3061191.8408,4224544.094 3061238.4555,4224522.3887 3061229.2724,4224522.3816 3061229.2867)))",DE5317307,Fohnbach und Gleibach,DE,B, diff --git a/mock/README.md b/mock/README.md deleted file mode 100644 index 9af0ed0..0000000 --- a/mock/README.md +++ /dev/null @@ -1,16 +0,0 @@ -# Mock Data for Early Stage Processes - -## Natura 2000 - -Download gpkg from the [European Environment Agency](https://sdi.eea.europa.eu/datashare/s/mwzs9eNsJ9Sn4Q4/download?path=%2F&files=Natura2000_end2024.gpkg). - -Import to PostGIS with: - -```bash -psql -U geoengine -d biois -h localhost -p 5432 -c "CREATE SCHEMA IF NOT EXISTS \"Natura2000\";" -ogr2ogr \ - -f "PostgreSQL" \ - PG:"dbname=biois user=geoengine password=geoengine host=localhost port=5432" \ - -lco SCHEMA=Natura2000 \ - Natura2000_end2024.gpkg -``` From 3a4bc857e8e834837bb117e05ec93c4d6ad182bb Mon Sep 17 00:00:00 2001 From: Christian Beilschmidt Date: Fri, 14 Aug 2026 08:22:08 +0200 Subject: [PATCH 11/11] test: add some tests --- backend/src/bin/biois_cli.rs | 20 ++--- backend/src/bin/openapi.rs | 40 --------- frontend/src/app/app.routes.spec.ts | 60 +++++++++++++ .../src/app/create/create.component.spec.ts | 2 +- .../src/app/credits/credits.component.spec.ts | 88 ++++++++++++++++++- .../data-resource-table.component.spec.ts | 2 +- .../src/app/util/long-text.component.spec.ts | 2 +- frontend/src/app/util/mock-resize-observer.ts | 21 +++++ frontend/src/app/util/resize-signal.spec.ts | 25 +----- 9 files changed, 182 insertions(+), 78 deletions(-) delete mode 100644 backend/src/bin/openapi.rs create mode 100644 frontend/src/app/app.routes.spec.ts create mode 100644 frontend/src/app/util/mock-resize-observer.ts diff --git a/backend/src/bin/biois_cli.rs b/backend/src/bin/biois_cli.rs index e16bce0..5ab62a4 100644 --- a/backend/src/bin/biois_cli.rs +++ b/backend/src/bin/biois_cli.rs @@ -62,20 +62,20 @@ impl OpenAPICommand { async fn run(self) -> Result<()> { let _tracing_guard = setup_tracing(CONFIG.logging.clone().into()); - println!("{}", openapi_json().await?); + println!("{}", Self::openapi_json().await?); Ok(()) } -} -async fn openapi_json() -> Result { - let mut service = server().await?; + async fn openapi_json() -> Result { + let mut service = server().await?; - service - .get_router_mut() - .get_openapi_mut() - .to_pretty_json() - .context("Failed to serialize OpenAPI spec as JSON") + service + .get_router_mut() + .get_openapi_mut() + .to_pretty_json() + .context("Failed to serialize OpenAPI spec as JSON") + } } #[cfg(test)] @@ -85,7 +85,7 @@ mod tests { #[tokio::test(flavor = "multi_thread")] async fn it_fetches_openapi_spec_successfully() { let openapi_json: serde_json::Value = - serde_json::from_str(&openapi_json().await.unwrap()).unwrap(); + serde_json::from_str(&OpenAPICommand::openapi_json().await.unwrap()).unwrap(); assert_eq!(openapi_json["openapi"], "3.1.0"); assert_eq!(openapi_json["info"]["title"], "BioIS API"); diff --git a/backend/src/bin/openapi.rs b/backend/src/bin/openapi.rs deleted file mode 100644 index c8229c8..0000000 --- a/backend/src/bin/openapi.rs +++ /dev/null @@ -1,40 +0,0 @@ -#![allow( - clippy::print_stdout, - clippy::print_stderr, - reason = "This is a binary for fetching the OpenAPI spec, so printing to stdout/stderr is fine" -)] - -use anyhow::Context; -use biois::{CONFIG, server, setup_tracing}; - -#[tokio::main] -async fn main() -> anyhow::Result<()> { - let _tracing_guard = setup_tracing(CONFIG.logging.clone().into()); - println!("{}", openapi_json().await?); - - Ok(()) -} - -async fn openapi_json() -> anyhow::Result { - let mut service = server().await?; - - service - .get_router_mut() - .get_openapi_mut() - .to_pretty_json() - .context("Failed to serialize OpenAPI spec as JSON") -} - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test(flavor = "multi_thread")] - async fn it_fetches_openapi_spec_successfully() { - let openapi_json: serde_json::Value = - serde_json::from_str(&openapi_json().await.unwrap()).unwrap(); - - assert_eq!(openapi_json["openapi"], "3.1.0"); - assert_eq!(openapi_json["info"]["title"], "BioIS API"); - } -} diff --git a/frontend/src/app/app.routes.spec.ts b/frontend/src/app/app.routes.spec.ts new file mode 100644 index 0000000..8427e8d --- /dev/null +++ b/frontend/src/app/app.routes.spec.ts @@ -0,0 +1,60 @@ +import { TestBed } from '@angular/core/testing'; +import { provideRouter, Router } from '@angular/router'; +import { MockInstance, vi } from 'vitest'; +import { routes } from './app.routes'; +import { UserService } from './user.service'; + +describe('app routes', () => { + let router: Router; + let userService: UserService; + let isLoggedInSpy: MockInstance<() => boolean>; + let logoutSpy: MockInstance<() => void>; + + beforeEach(() => { + userService = { + isLoggedIn: vi.fn(), + logout: vi.fn(), + } as unknown as UserService; + + TestBed.configureTestingModule({ + providers: [provideRouter(routes), { provide: UserService, useValue: userService }], + }); + + isLoggedInSpy = vi.spyOn(userService, 'isLoggedIn'); + logoutSpy = vi.spyOn(userService, 'logout'); + router = TestBed.inject(Router); + }); + + it('redirects app routes to sign in when the user is not logged in', async () => { + isLoggedInSpy.mockReturnValue(false); + + await router.navigateByUrl('/app/results'); + + expect(router.url).toBe('/app/signin'); + }); + + it('allows app routes when the user is logged in', async () => { + isLoggedInSpy.mockReturnValue(true); + + await router.navigateByUrl('/app/results'); + + expect(router.url).toBe('/app/results'); + }); + + it('redirects unknown app routes to the results page', async () => { + isLoggedInSpy.mockReturnValue(true); + + await router.navigateByUrl('/app/unknown'); + + expect(router.url).toBe('/app/results'); + }); + + it('logs out on signout and redirects to home', async () => { + isLoggedInSpy.mockReturnValue(true); + + await router.navigateByUrl('/app/signout'); + + expect(logoutSpy).toHaveBeenCalledTimes(1); + expect(router.url).toBe('/'); + }); +}); diff --git a/frontend/src/app/create/create.component.spec.ts b/frontend/src/app/create/create.component.spec.ts index 9c641f8..9eb5ef1 100644 --- a/frontend/src/app/create/create.component.spec.ts +++ b/frontend/src/app/create/create.component.spec.ts @@ -3,7 +3,7 @@ import { vi } from 'vitest'; import { Process, ProcessesApi } from '@geoengine/biois'; import { CreateComponent } from './create.component'; import { inputBinding } from '@angular/core'; -import { mockResizeObserverClass } from '../util/resize-signal.spec'; +import { mockResizeObserverClass } from '../util/mock-resize-observer'; describe('CreateComponent', () => { let component: CreateComponent; diff --git a/frontend/src/app/credits/credits.component.spec.ts b/frontend/src/app/credits/credits.component.spec.ts index fcab10e..90bee56 100644 --- a/frontend/src/app/credits/credits.component.spec.ts +++ b/frontend/src/app/credits/credits.component.spec.ts @@ -1,4 +1,8 @@ -import { YearAndMonth } from './credits.component'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { RouterModule } from '@angular/router'; +import { vi } from 'vitest'; +import { UserService } from '../user.service'; +import { CreditsComponent, YearAndMonth } from './credits.component'; describe('YearAndMonth', () => { it('should create an instance from a Date object', () => { @@ -29,3 +33,85 @@ describe('YearAndMonth', () => { expect(date.getMonth()).toBe(4); // Months are zero-based in JavaScript Date }); }); + +describe('CreditsComponent', () => { + let component: CreditsComponent; + let fixture: ComponentFixture; + const creditsMock = vi.fn(); + + beforeEach(async () => { + creditsMock.mockReset(); + creditsMock.mockResolvedValue({ + creditsUsed: 42, + details: [ + { jobId: 'job-123', creditsUsed: 17 }, + { jobId: 'job-456', creditsUsed: 25 }, + ], + }); + + await TestBed.configureTestingModule({ + imports: [CreditsComponent, RouterModule.forRoot([])], + providers: [ + { + provide: UserService, + useValue: { + credits: creditsMock, + }, + }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(CreditsComponent); + component = fixture.componentInstance; + }); + + it('loads and renders the credits for a month', async () => { + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const text = (fixture.nativeElement as HTMLElement).textContent ?? ''; + + expect(text).toContain('Credits used:'); + expect(text).toContain('42'); + expect(text).toContain('job-123'); + expect(text).toContain('17'); + expect(creditsMock).toHaveBeenCalledWith(expect.any(Number), expect.any(Number)); + }); + + it('renders the error state when the credits request fails', async () => { + creditsMock.mockRejectedValueOnce(new Error('network failure')); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const text = (fixture.nativeElement as HTMLElement).textContent ?? ''; + + expect(text).toContain('Error loading credits:'); + expect(text).toContain('network failure'); + }); + + it('updates the selected month and closes the datepicker on month selection', () => { + const close = vi.fn(); + + component.setMonthAndYear(new Date(2024, 6, 15), { close } as never); + + expect(component.yearAndMonth().year).toBe(2024); + expect(component.yearAndMonth().month).toBe(7); + expect(close).toHaveBeenCalledTimes(1); + }); + + it('moves to the next and previous month correctly', () => { + component.yearAndMonth.set(new YearAndMonth(2023, 12)); + component.nextMonth(); + expect(component.yearAndMonth()).toEqual(new YearAndMonth(2024, 1)); + + component.previousMonth(); + expect(component.yearAndMonth()).toEqual(new YearAndMonth(2023, 12)); + + component.yearAndMonth.set(new YearAndMonth(2023, 1)); + component.previousMonth(); + expect(component.yearAndMonth()).toEqual(new YearAndMonth(2022, 12)); + }); +}); diff --git a/frontend/src/app/result/data-resource-table.component.spec.ts b/frontend/src/app/result/data-resource-table.component.spec.ts index 1573448..74d14bc 100644 --- a/frontend/src/app/result/data-resource-table.component.spec.ts +++ b/frontend/src/app/result/data-resource-table.component.spec.ts @@ -6,7 +6,7 @@ import { DataResourceTableComponent, tableColumnInfoFromValue, } from './data-resource-table.component'; -import { mockResizeObserverClass } from '../util/resize-signal.spec'; +import { mockResizeObserverClass } from '../util/mock-resize-observer'; describe('DataResourceTableComponent', () => { let component: DataResourceTableComponent; diff --git a/frontend/src/app/util/long-text.component.spec.ts b/frontend/src/app/util/long-text.component.spec.ts index d3d0921..57579fa 100644 --- a/frontend/src/app/util/long-text.component.spec.ts +++ b/frontend/src/app/util/long-text.component.spec.ts @@ -1,7 +1,7 @@ import { Component } from '@angular/core'; import { ComponentFixture, TestBed } from '@angular/core/testing'; import { LongTextComponent } from './long-text.component'; -import { mockResizeObserverClass } from './resize-signal.spec'; +import { mockResizeObserverClass } from './mock-resize-observer'; @Component({ template: `Projected content for test`, diff --git a/frontend/src/app/util/mock-resize-observer.ts b/frontend/src/app/util/mock-resize-observer.ts new file mode 100644 index 0000000..a384f34 --- /dev/null +++ b/frontend/src/app/util/mock-resize-observer.ts @@ -0,0 +1,21 @@ +export function mockResizeObserverClass( + createdObservers: Array<{ callback: ResizeObserverCallback; disconnected: boolean }>, +): typeof ResizeObserver { + return class MockResizeObserver { + callback: ResizeObserverCallback; + disconnected = false; + + constructor(cb: ResizeObserverCallback) { + this.callback = cb; + createdObservers.push(this); + } + + observe(_el: Element): void { + // jsdom does not implement ResizeObserver in the test environment, so we mock it to test our logic + } + + disconnect(): void { + this.disconnected = true; + } + } as unknown as typeof ResizeObserver; +} diff --git a/frontend/src/app/util/resize-signal.spec.ts b/frontend/src/app/util/resize-signal.spec.ts index a9066e2..ba2b934 100644 --- a/frontend/src/app/util/resize-signal.spec.ts +++ b/frontend/src/app/util/resize-signal.spec.ts @@ -1,32 +1,9 @@ import { signal, ElementRef, runInInjectionContext, Injector } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { describe, it, expect, beforeEach } from 'vitest'; +import { mockResizeObserverClass } from './mock-resize-observer'; import { fromResize } from './resize-signal'; -/** - * jsdom does not implement ResizeObserver in the test environment, so we mock it to test our logic - */ -export function mockResizeObserverClass( - createdObservers: Array<{ callback: ResizeObserverCallback; disconnected: boolean }>, -): typeof ResizeObserver { - return class MockResizeObserver { - // jsdom does not implement ResizeObserver in the test environment, so we mock it to test our logic - callback: ResizeObserverCallback; - disconnected = false; - - constructor(cb: ResizeObserverCallback) { - this.callback = cb; - createdObservers.push(this); - } - observe(_el: Element): void { - // no-op - } - disconnect(): void { - this.disconnected = true; - } - } as unknown as typeof ResizeObserver; -} - describe('fromResize', () => { let createdObservers: Array<{ callback: ResizeObserverCallback; disconnected: boolean }> = [];