diff --git a/.config/dictionaries/project.dic b/.config/dictionaries/project.dic index 407853377a..3d8631c2cf 100644 --- a/.config/dictionaries/project.dic +++ b/.config/dictionaries/project.dic @@ -15,6 +15,7 @@ brotli cabi canvaskit cardano +catid cbor cdylib chaincode @@ -23,6 +24,7 @@ chrono cids ciphertext codegen +codepoints COEP condvar coverallsapp @@ -36,6 +38,8 @@ dashmap datelike dcbor Devnet +dotenv +dotenvy dotstar earthfile Embedder @@ -86,6 +90,7 @@ mkcron mkdelay mkdirat nanos +newtype nextest nolfs nomutex @@ -149,10 +154,13 @@ traceback traitreg tweakable txns +txos unfinalized unlinkat usvg utimensat +utoipa +utxo uuidv wasi wasip diff --git a/hermes/apps/athena/Cargo.lock b/hermes/apps/athena/Cargo.lock index 91fd8f6cb7..4e323f65de 100644 --- a/hermes/apps/athena/Cargo.lock +++ b/hermes/apps/athena/Cargo.lock @@ -587,6 +587,26 @@ dependencies = [ "serde_core", ] +[[package]] +name = "derive_more" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "093242cf7570c207c83073cf82f79706fe7b8317e98620a47d5be7c3d8497678" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bda628edc44c4bb645fbe0f758797143e4e07926f7ebf4e9bdfbd3d2ce621df3" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "digest" version = "0.10.7" @@ -764,6 +784,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + [[package]] name = "fs4" version = "0.12.0" @@ -951,6 +980,17 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "http" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4a85d31aea989eead29a3aaf9e1115a180df8282431156e533de47660892565" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + [[package]] name = "http-proxy" version = "0.1.0" @@ -985,6 +1025,92 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "200072f5d0e3614556f94a9930d5dc3e0662a652823904c3a75dc3b0af7fee47" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cde2700ccaed3872079a65fb1a78f6c0a36c91570f28755dda67bc8f7d9f00a" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436880e8e18df4d7bbc06d58432329d6458cc84531f7ac5f024e93deadb37979" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00210d6893afc98edb752b664b8890f0ef174c8adbb8d0be9710fa66fbbf72d3" + +[[package]] +name = "icu_properties" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "016c619c1eeb94efb86809b015c58f479963de65bdb6253345c1a1276f22e32b" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "potential_utf", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "298459143998310acd25ffe6810ed544932242d3f07083eee1084d83a71bd632" + +[[package]] +name = "icu_provider" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c80da27b5f4187909049ee2d72f276f0d9f99a42c306bd0131ecfe04d8e5af" +dependencies = [ + "displaydoc", + "icu_locale_core", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.2.1" @@ -997,6 +1123,27 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "indexmap" version = "1.9.3" @@ -1094,6 +1241,12 @@ version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" +[[package]] +name = "litemap" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241eaef5fd12c88705a01fc1066c48c4b36e0dd4377dcdc7ec3942cea7a69956" + [[package]] name = "lock_api" version = "0.4.13" @@ -1557,6 +1710,12 @@ dependencies = [ "base64ct", ] +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -1585,6 +1744,15 @@ version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" +[[package]] +name = "potential_utf" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1777,9 +1945,9 @@ dependencies = [ [[package]] name = "regex" -version = "1.11.2" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23d7fd106d8c02486a8d64e778353d1cffe08ce79ac2e82f540c86d0facf6912" +checksum = "843bc0191f75f3e22651ae5f1e72939ab2f72a4bc30fa80a066bd66edefc24d4" dependencies = [ "aho-corasick", "memchr", @@ -1789,9 +1957,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.10" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b9458fa0bfeeac22b5ca447c63aaf45f28439a709ccd244698632f9aa6394d6" +checksum = "5276caf25ac86c8d810222b3dbb938e512c55c6831a10f3e6ed1c93b84041f1c" dependencies = [ "aho-corasick", "memchr", @@ -1891,9 +2059,9 @@ checksum = "d767eb0aabc880b29956c35734170f26ed551a859dbd361d140cdbeca61ab1e2" [[package]] name = "serde" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0dca6411025b24b60bfa7ec1fe1f8e710ac09782dca409ee8237ba74b51295fd" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", "serde_derive", @@ -1901,18 +2069,18 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ba2ba63999edb9dac981fb34b3e5c0d111a69b0924e253ed29d83f7c99e966a4" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.226" +version = "1.0.228" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8db53ae22f34573731bafa1db20f04027b2d25e02d8205921b569171699cdb33" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", @@ -1989,12 +2157,26 @@ name = "shared" version = "0.1.0" dependencies = [ "anyhow", + "base64", + "bech32 0.11.0", "cardano-blockchain-types", "catalyst-types 0.0.7", + "chrono", + "derive_more", + "ed25519-dalek", + "hex", + "http", "log", + "num-bigint", + "rbac-registration 0.0.10", + "regex", + "serde", "serde_json", "strum 0.27.2", "strum_macros 0.27.2", + "url", + "utoipa", + "uuid", "wit-bindgen", ] @@ -2055,6 +2237,12 @@ dependencies = [ "der", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "staked-ada-indexer" version = "0.1.0" @@ -2324,6 +2512,16 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d4f6d1145dcb577acf783d4e601bc1d76a13337bb54e6233add580b07344c8b" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tls_codec" version = "0.4.2" @@ -2429,6 +2627,49 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "url" +version = "2.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08bc136a29a3d1758e07a9cca267be308aeebf5cfd5a10f3f67ab2097683ef5b" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utoipa" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +dependencies = [ + "indexmap 2.11.4", + "serde", + "serde_json", + "utoipa-gen", +] + +[[package]] +name = "utoipa-gen" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "url", + "uuid", +] + [[package]] name = "uuid" version = "1.18.1" @@ -2907,6 +3148,12 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea2f10b9bb0928dfb1b42b65e1f9e36f7f54dbdf08457afefb38afcdec4fa2bb" + [[package]] name = "x509-cert" version = "0.2.5" @@ -2925,6 +3172,30 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" +[[package]] +name = "yoke" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f41bb01b8226ef4bfd589436a297c53d118f65921786300e427be8d487695cc" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38da3c9736e16c5d3c8c597a9aaa5d1fa565d0532ae05e27c24aa62fb32c0ab6" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" version = "0.8.27" @@ -2945,6 +3216,27 @@ dependencies = [ "syn", ] +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.1" @@ -2965,6 +3257,39 @@ dependencies = [ "syn", ] +[[package]] +name = "zerotrie" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36f0bbd478583f79edad978b407914f61b2972f5af6fa089686016be8f9af595" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7aa2bd55086f1ab526693ecbe444205da57e25f4489879da80635a46d90e73b" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b96237efa0c878c64bd89c436f661be4e46b2f3eff1ebb976f7ef2321d2f58f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "zstd" version = "0.13.3" diff --git a/hermes/apps/athena/modules/rbac-registration-indexer/Cargo.toml b/hermes/apps/athena/modules/rbac-registration-indexer/Cargo.toml index 7bb1d337d5..282ddd2c0c 100644 --- a/hermes/apps/athena/modules/rbac-registration-indexer/Cargo.toml +++ b/hermes/apps/athena/modules/rbac-registration-indexer/Cargo.toml @@ -8,8 +8,8 @@ crate-type = ["cdylib"] [dependencies] shared = { version = "0.1.0", path = "../../shared", features = ["cardano-blockchain-types"] } -anyhow = "1.0.98" -serde_json = "1.0.142" +anyhow = "1.0.100" +serde_json = "1.0.145" cardano-blockchain-types = { version = "0.0.6", git = "https://github.com/input-output-hk/catalyst-libs", tag = "cardano-blockchain-types/v0.0.6" } rbac-registration = { version = "0.0.10", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "rbac-registration/v0.0.10" } diff --git a/hermes/apps/athena/modules/rbac-registration/Cargo.toml b/hermes/apps/athena/modules/rbac-registration/Cargo.toml index 69284fa8f1..e068dcff8d 100644 --- a/hermes/apps/athena/modules/rbac-registration/Cargo.toml +++ b/hermes/apps/athena/modules/rbac-registration/Cargo.toml @@ -8,17 +8,17 @@ crate-type = ["cdylib"] [dependencies] shared = { version = "0.1.0", path = "../../shared", features = ["cardano-blockchain-types"] } -anyhow = "1.0.98" -serde_json = "1.0.142" -serde = "1.0.226" +anyhow = "1.0.100" +serde_json = "1.0.145" +serde = "1.0.228" bech32 = "0.11.0" x509-cert = "0.2.5" hex = "0.4.3" -minicbor = "0.25.1" -ed25519-dalek = "2.1.1" -chrono = "0.4.38" -regex = "1.11.1" -uuid = { version = "1.12.1", features = ["v4", "v7", "serde"] } +minicbor = "0.25.1" +ed25519-dalek = "2.2.0" +chrono = "0.4.42" +regex = "1.12.2" +uuid = { version = "1.18.1", features = ["v4", "v7", "serde"] } cardano-blockchain-types = { version = "0.0.6", git = "https://github.com/input-output-hk/catalyst-libs", tag = "cardano-blockchain-types/v0.0.6" } rbac-registration = { version = "0.0.10", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "rbac-registration/v0.0.10" } diff --git a/hermes/apps/athena/modules/stake-ada-indexer/Cargo.toml b/hermes/apps/athena/modules/stake-ada-indexer/Cargo.toml index 4fe9c47772..5cd21978a8 100644 --- a/hermes/apps/athena/modules/stake-ada-indexer/Cargo.toml +++ b/hermes/apps/athena/modules/stake-ada-indexer/Cargo.toml @@ -8,4 +8,4 @@ crate-type = ["cdylib"] [dependencies] shared = { version = "0.1.0", path = "../../shared", features = ["cardano-blockchain-types"] } -anyhow = "1.0.98" +anyhow = "1.0.100" diff --git a/hermes/apps/athena/shared/Cargo.toml b/hermes/apps/athena/shared/Cargo.toml index f57e0f8170..59f184c51b 100644 --- a/hermes/apps/athena/shared/Cargo.toml +++ b/hermes/apps/athena/shared/Cargo.toml @@ -3,14 +3,49 @@ name = "shared" version = "0.1.0" edition = "2021" +[features] +cat-gateway-types = [ + "rbac-registration", + "regex", + "serde", + "chrono", + "hex", + "ed25519-dalek", + "num-bigint", + "http", + "utoipa", + "uuid", + "url", + "base64", + "bech32", + "derive_more", + "cardano-blockchain-types" +] + [dependencies] wit-bindgen = "0.46.0" - -anyhow = "1.0.98" -serde_json = "1.0.142" -strum = "0.27.2" +anyhow = "1.0.100" +serde_json = { version = "1.0.145", features = ["arbitrary_precision"] } +strum = { version = "0.27.2", features = ["derive"] } strum_macros = "0.27.2" log = { version = "0.4.28", features = ["kv_serde"] } - cardano-blockchain-types = { version = "0.0.6", git = "https://github.com/input-output-hk/catalyst-libs", tag = "cardano-blockchain-types/v0.0.6", optional = true } catalyst-types = { version = "0.0.7", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "catalyst-types/v0.0.7" } + +rbac-registration = { version = "0.0.10", git = "https://github.com/input-output-hk/catalyst-libs.git", tag = "rbac-registration/v0.0.10", optional = true } +regex = { version = "1.12.2", optional = true } +serde = { version = "1.0.228", optional = true } +chrono = { version = "0.4.42", optional = true } +hex = { version = "0.4.3", optional = true } +ed25519-dalek = { version = "2.2.0", optional = true } +num-bigint = { version = "0.4.6", optional = true } +http = { version = "1.3.1", optional = true } +utoipa = { version = "5.4.0", features = ["uuid", "url"], optional = true } +uuid = { version = "1.18.1", features = ["v4", "v7", "serde"], optional = true } +url = { version = "2.5.7", optional = true } +base64 = { version = "0.22.1", optional = true } +bech32 = { version = "0.11.0", optional = true } +derive_more = { version = "2.0.1", default-features = false, features = [ + "from", + "into", +], optional = true } \ No newline at end of file diff --git a/hermes/apps/athena/shared/src/utils/common/auth/mod.rs b/hermes/apps/athena/shared/src/utils/common/auth/mod.rs new file mode 100644 index 0000000000..60936ff5fe --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/auth/mod.rs @@ -0,0 +1,5 @@ +//! Catalyst RBAC Token Authentication + +pub mod none; +pub mod none_or_rbac; +pub(crate) mod rbac; diff --git a/hermes/apps/athena/shared/src/utils/common/auth/none.rs b/hermes/apps/athena/shared/src/utils/common/auth/none.rs new file mode 100644 index 0000000000..a9461c898a --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/auth/none.rs @@ -0,0 +1,6 @@ +//! None authorization scheme. +//! +//! Means the API Endpoint does not need to use any Auth. + +/// Endpoint can be used without any authorization. +pub struct NoAuthorization; diff --git a/hermes/apps/athena/shared/src/utils/common/auth/none_or_rbac.rs b/hermes/apps/athena/shared/src/utils/common/auth/none_or_rbac.rs new file mode 100644 index 0000000000..be696347aa --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/auth/none_or_rbac.rs @@ -0,0 +1,24 @@ +//! Either has No Authorization, or RBAC Token. + +use super::{ + none::NoAuthorization, + rbac::{scheme::CatalystRBACSecurityScheme, token::CatalystRBACTokenV1}, +}; + +#[allow(dead_code, clippy::upper_case_acronyms, clippy::large_enum_variant)] +/// Endpoint allows Authorization with or without RBAC Token. +pub enum NoneOrRBAC { + /// Has RBAC Token. + RBAC(CatalystRBACSecurityScheme), + /// Has No Authorization. + None(NoAuthorization), +} + +impl From for Option { + fn from(value: NoneOrRBAC) -> Self { + match value { + NoneOrRBAC::RBAC(auth) => Some(auth.into()), + NoneOrRBAC::None(_) => None, + } + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/auth/rbac/mod.rs b/hermes/apps/athena/shared/src/utils/common/auth/rbac/mod.rs new file mode 100644 index 0000000000..6a89dcc7d4 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/auth/rbac/mod.rs @@ -0,0 +1,6 @@ +//! Catalyst RBAC Authorization + +/// Catalyst RBAC Security Scheme definition +pub(crate) mod scheme; +/// Catalyst RBAC Token utility functions +pub(crate) mod token; diff --git a/hermes/apps/athena/shared/src/utils/common/auth/rbac/scheme.rs b/hermes/apps/athena/shared/src/utils/common/auth/rbac/scheme.rs new file mode 100644 index 0000000000..c77b3303b3 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/auth/rbac/scheme.rs @@ -0,0 +1,13 @@ +//! Catalyst RBAC Security Scheme + +use super::token::CatalystRBACTokenV1; + +/// Catalyst RBAC Access Token +#[allow(clippy::module_name_repetitions)] +pub struct CatalystRBACSecurityScheme(CatalystRBACTokenV1); + +impl From for CatalystRBACTokenV1 { + fn from(value: CatalystRBACSecurityScheme) -> Self { + value.0 + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/auth/rbac/token.rs b/hermes/apps/athena/shared/src/utils/common/auth/rbac/token.rs new file mode 100644 index 0000000000..eb70e6945e --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/auth/rbac/token.rs @@ -0,0 +1,223 @@ +//! Catalyst RBAC Token utility functions. + +// cspell: words rsplit Fftx + +use std::{ + fmt::{Display, Formatter}, + sync::LazyLock, + time::Duration, +}; + +use anyhow::{anyhow, Context, Result}; +use base64::{prelude::BASE64_URL_SAFE_NO_PAD, Engine}; +use cardano_blockchain_types::Network; +use catalyst_types::catalyst_id::CatalystId; +use chrono::{TimeDelta, Utc}; +use ed25519_dalek::{ed25519::signature::Signer, Signature, SigningKey, VerifyingKey}; +use rbac_registration::registration::cardano::RegistrationChain; +use regex::Regex; + +/// Captures just the digits after last slash +/// This Regex should not fail +#[allow(clippy::unwrap_used)] +static REGEX: LazyLock = LazyLock::new(|| Regex::new(r"/\d+$").unwrap()); + +/// A Catalyst RBAC Authorization Token. +/// +/// See [this document] for more details. +/// +/// [this document]: https://github.com/input-output-hk/catalyst-voices/blob/main/docs/src/catalyst-standards/permissionless-auth/auth-header.md +#[derive(Debug, Clone)] +pub(crate) struct CatalystRBACTokenV1 { + /// A Catalyst identifier. + catalyst_id: CatalystId, + /// A network value. + /// + /// The network value is contained in the Catalyst ID and can be accessed from it, but + /// it is a string, so we convert it to this enum during the validation. + network: Network, + /// Ed25519 Signature of the Token + signature: Signature, + /// Raw bytes of the token without the signature. + raw: Vec, + /// A corresponded RBAC chain, constructed from the most recent data from the + /// database. Lazy initialized + reg_chain: Option, +} + +impl CatalystRBACTokenV1 { + /// Bearer Token prefix for this token. + const AUTH_TOKEN_PREFIX: &str = "catid."; + + /// Creates a new token instance. + // TODO: Remove the attribute when the function is used. + #[allow(dead_code)] + pub(crate) fn new( + network: &str, + subnet: Option<&str>, + role0_pk: VerifyingKey, + sk: &SigningKey, + ) -> Result { + let catalyst_id = CatalystId::new(network, subnet, role0_pk) + .with_nonce() + .as_id(); + let network = convert_network(&catalyst_id.network())?; + let raw = as_raw_bytes(&catalyst_id.to_string()); + let signature = sk.sign(&raw); + + Ok(Self { + catalyst_id, + network, + signature, + raw, + reg_chain: None, + }) + } + + /// Parses a token from the given string. + /// + /// The token consists of the following parts: + /// - "catid" prefix. + /// - Nonce. + /// - Network. + /// - Role 0 public key. + /// - Signature. + /// + /// For example: + /// ``` + /// catid.:173710179@preprod.cardano/FftxFnOrj2qmTuB2oZG2v0YEWJfKvQ9Gg8AgNAhDsKE. + /// ``` + pub(crate) fn parse(token: &str) -> Result { + let token = token + .strip_prefix(Self::AUTH_TOKEN_PREFIX) + .ok_or_else(|| anyhow!("Missing token prefix"))?; + let (token, signature) = token + .rsplit_once('.') + .ok_or_else(|| anyhow!("Missing token signature"))?; + let signature = BASE64_URL_SAFE_NO_PAD + .decode(signature.as_bytes()) + .context("Invalid token signature encoding")? + .try_into() + .map(|b| Signature::from_bytes(&b)) + .map_err(|_| anyhow!("Invalid token signature length"))?; + let raw = as_raw_bytes(token); + + let catalyst_id: CatalystId = token.parse().context("Invalid Catalyst ID")?; + if catalyst_id.username().is_some_and(|n| !n.is_empty()) { + return Err(anyhow!("Catalyst ID must not contain username")); + } + if !catalyst_id.clone().is_id() { + return Err(anyhow!("Catalyst ID must be in an ID format")); + } + if catalyst_id.nonce().is_none() { + return Err(anyhow!("Catalyst ID must have nonce")); + } + + if REGEX.is_match(token) { + return Err(anyhow!( + "Catalyst ID mustn't have role or rotation specified" + )); + } + let network = convert_network(&catalyst_id.network())?; + + Ok(Self { + catalyst_id, + network, + signature, + raw, + reg_chain: None, + }) + } + + /// Given the `PublicKey`, verifies the token was correctly signed. + pub(crate) fn verify( + &self, + public_key: &VerifyingKey, + ) -> Result<()> { + public_key + .verify_strict(&self.raw, &self.signature) + .context("Token signature verification failed") + } + + /// Checks that the token timestamp is valid. + /// + /// The timestamp is valid if it isn't too old or too skewed. + pub(crate) fn is_young( + &self, + max_age: Duration, + max_skew: Duration, + ) -> bool { + let Some(token_age) = self.catalyst_id.nonce() else { + return false; + }; + + let now = Utc::now(); + + // The token is considered old if it was issued more than max_age ago. + // And newer than an allowed clock skew value + // This is a safety measure to avoid replay attacks. + let Ok(max_age) = TimeDelta::from_std(max_age) else { + return false; + }; + let Ok(max_skew) = TimeDelta::from_std(max_skew) else { + return false; + }; + let Some(min_time) = now.checked_sub_signed(max_age) else { + return false; + }; + let Some(max_time) = now.checked_add_signed(max_skew) else { + return false; + }; + (min_time < token_age) && (max_time > token_age) + } + + /// Returns a Catalyst ID from the token. + pub(crate) fn catalyst_id(&self) -> &CatalystId { + &self.catalyst_id + } + + /// Returns a network. + #[allow(dead_code)] + pub(crate) fn network(&self) -> Network { + self.network + } +} + +impl Display for CatalystRBACTokenV1 { + fn fmt( + &self, + f: &mut Formatter<'_>, + ) -> std::fmt::Result { + write!( + f, + "{}{}.{}", + CatalystRBACTokenV1::AUTH_TOKEN_PREFIX, + self.catalyst_id, + BASE64_URL_SAFE_NO_PAD.encode(self.signature.to_bytes()) + ) + } +} + +/// Converts the given token string to raw bytes. +fn as_raw_bytes(token: &str) -> Vec { + // The signature is calculated over all bytes in the token including the final '.'. + CatalystRBACTokenV1::AUTH_TOKEN_PREFIX + .bytes() + .chain(token.bytes()) + .chain(".".bytes()) + .collect() +} + +/// Checks if the given network is supported. +fn convert_network((network, subnet): &(String, Option)) -> Result { + if network != "cardano" { + return Err(anyhow!("Unsupported network: {network}")); + } + + match subnet.as_deref() { + None => Ok(Network::Mainnet), + Some("preprod") => Ok(Network::Preprod), + Some("preview") => Ok(Network::Preview), + Some(subnet) => Err(anyhow!("Unsupported host: {subnet}.{network}",)), + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/mod.rs b/hermes/apps/athena/shared/src/utils/common/mod.rs new file mode 100644 index 0000000000..7734e3446c --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/mod.rs @@ -0,0 +1,7 @@ +//! Define common and reusable api components here. +//! these components should be structured into their own sub modules. + +pub mod auth; +pub mod objects; +pub mod responses; +pub mod types; diff --git a/hermes/apps/athena/shared/src/utils/common/objects/cardano/mod.rs b/hermes/apps/athena/shared/src/utils/common/objects/cardano/mod.rs new file mode 100644 index 0000000000..31363b5a54 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/objects/cardano/mod.rs @@ -0,0 +1,8 @@ +//! Defines API schemas of Cardano Objects. +//! +//! These Objects MUST be used in multiple places for multiple things to be considered +//! common. They should not be simple types. but actual objects. +//! Simple types belong in `common/types`. + +pub mod network; +pub mod stake_info; diff --git a/hermes/apps/athena/shared/src/utils/common/objects/cardano/network.rs b/hermes/apps/athena/shared/src/utils/common/objects/cardano/network.rs new file mode 100644 index 0000000000..1ffb8b630e --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/objects/cardano/network.rs @@ -0,0 +1,25 @@ +//! Defines API schemas of Cardano network types. + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Cardano network type. +#[derive(Clone, Debug, Serialize, Deserialize, ToSchema)] +pub enum Network { + /// Cardano mainnet. + Mainnet, + /// Cardano preprod. + Preprod, + /// Cardano preview. + Preview, +} + +impl From for cardano_blockchain_types::Network { + fn from(value: Network) -> Self { + match value { + Network::Mainnet => Self::Mainnet, + Network::Preprod => Self::Preprod, + Network::Preview => Self::Preview, + } + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/objects/cardano/stake_info.rs b/hermes/apps/athena/shared/src/utils/common/objects/cardano/stake_info.rs new file mode 100644 index 0000000000..ccfded9a2d --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/objects/cardano/stake_info.rs @@ -0,0 +1,66 @@ +//! Defines API schemas of stake amount type. + +use derive_more::{From, Into}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::utils::common::types::{ + array_types::impl_array_types, + cardano::{ + ada_value::AdaValue, asset_name::AssetName, asset_value::AssetValue, + hash28::HexEncodedHash28, slot_no::SlotNo, + }, +}; + +/// User's staked txo asset info. +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct StakedTxoAssetInfo { + /// Asset policy hash (28 bytes). + pub policy_hash: HexEncodedHash28, + /// Token policies Asset Name. + pub asset_name: AssetName, + /// Token Asset Value. + pub amount: AssetValue, +} + +// List of User's Staked Native Token Info +impl_array_types!( + StakedAssetInfoList, + StakedTxoAssetInfo, + Some(poem_openapi::registry::MetaSchema { + example: Self::example().to_json(), + max_items: Some(1000), + items: Some(Box::new(StakedTxoAssetInfo::schema_ref())), + ..poem_openapi::registry::MetaSchema::ANY + }) +); + +/// User's cardano stake info. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct StakeInfo { + /// Total stake amount. + pub ada_amount: AdaValue, + + /// Block's slot number which contains the latest unspent UTXO. + pub slot_number: SlotNo, + + /// TXO assets infos. + pub assets: StakedAssetInfoList, +} + +/// Volatile stake information. +#[derive(From, Into, Serialize, Deserialize, ToSchema)] +pub struct VolatileStakeInfo(StakeInfo); + +/// Persistent stake information. +#[derive(From, Into, Serialize, Deserialize, ToSchema)] +pub struct PersistentStakeInfo(StakeInfo); + +/// Full user's cardano stake info. +#[derive(Serialize, Deserialize, ToSchema)] +pub struct FullStakeInfo { + /// Volatile stake information. + pub volatile: VolatileStakeInfo, + /// Persistent stake information. + pub persistent: PersistentStakeInfo, +} diff --git a/hermes/apps/athena/shared/src/utils/common/objects/mod.rs b/hermes/apps/athena/shared/src/utils/common/objects/mod.rs new file mode 100644 index 0000000000..1e362b0b49 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/objects/mod.rs @@ -0,0 +1,3 @@ +//! This module contains common and re-usable objects. + +pub mod cardano; diff --git a/hermes/apps/athena/shared/src/utils/common/responses/code_401_unauthorized.rs b/hermes/apps/athena/shared/src/utils/common/responses/code_401_unauthorized.rs new file mode 100644 index 0000000000..0f4c1c9aee --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/responses/code_401_unauthorized.rs @@ -0,0 +1,33 @@ +//! Define `Unauthorized` response type. + +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::utils::common; + +// Keep this message consistent with the response comment. +/// The client has not sent valid authentication credentials for the requested +/// resource. +#[derive(ToSchema)] +pub struct Unauthorized { + /// Unique ID of this Server Error so that it can be located easily for debugging. + id: common::types::generic::error_uuid::ErrorUuid, + /// Error message. + // Will not contain sensitive information, internal details or backtraces. + msg: common::types::generic::error_msg::ErrorMessage, +} + +impl Unauthorized { + /// Create a new Payload. + pub fn new(msg: Option) -> Self { + let msg = msg.unwrap_or( + "Your request was not successful because it lacks valid authentication credentials for the requested resource.".to_string(), + ); + let id = Uuid::new_v4(); + + Self { + id: id.into(), + msg: msg.into(), + } + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/responses/code_403_forbidden.rs b/hermes/apps/athena/shared/src/utils/common/responses/code_403_forbidden.rs new file mode 100644 index 0000000000..bc46b9433b --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/responses/code_403_forbidden.rs @@ -0,0 +1,61 @@ +//! Define `Forbidden` response type. + +use utoipa::ToSchema; +use uuid::Uuid; + +use crate::utils::{common, common::types::array_types::impl_array_types}; + +/// The client has not sent valid authentication credentials for the requested +/// resource. +#[derive(ToSchema)] +pub struct Forbidden { + /// Unique ID of this Server Error so that it can be located easily for debugging. + id: common::types::generic::error_uuid::ErrorUuid, + /// Error message. + // Will not contain sensitive information, internal details or backtraces. + msg: common::types::generic::error_msg::ErrorMessage, + /// List or Roles required to access the resource. + // TODO: This should be a Vector of defined Roles/Grants. + // When those are defined, use that type instead of "String" + // It should look like an enum. + required: Option, +} + +impl Forbidden { + /// Create a new Server Error Response Payload. + pub fn new( + msg: Option, + roles: Option>, + ) -> Self { + let msg = msg.unwrap_or( + "Your request was not successful because your authentication credentials do not have the required roles for the requested resource.".to_string(), + ); + let id = Uuid::new_v4(); + + Self { + id: id.into(), + msg: msg.into(), + required: roles.map(Into::into), + } + } +} + +// List of roles +impl_array_types!( + RoleList, + String, + Some(poem_openapi::registry::MetaSchema { + example: Self::example().to_json(), + max_items: Some(100), + items: Some(Box::new(poem_openapi::registry::MetaSchemaRef::Inline( + Box::new(poem_openapi::registry::MetaSchema::new("string").merge( + poem_openapi::registry::MetaSchema { + max_length: Some(100), + pattern: Some("^[0-9a-zA-Z].*$".into()), + ..poem_openapi::registry::MetaSchema::ANY + } + )) + ))), + ..poem_openapi::registry::MetaSchema::ANY + }) +); diff --git a/hermes/apps/athena/shared/src/utils/common/responses/code_412_precondition_failed.rs b/hermes/apps/athena/shared/src/utils/common/responses/code_412_precondition_failed.rs new file mode 100644 index 0000000000..d4d283d10c --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/responses/code_412_precondition_failed.rs @@ -0,0 +1,58 @@ +//! Define `Precondition Failed` response type. + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::utils::{common, common::types::array_types::impl_array_types}; + +/// The client has not sent valid data in its request, headers, parameters or body. +#[derive(Debug, Clone, ToSchema)] +pub struct PreconditionFailed { + /// Details of each error in the content that was detected. + /// + /// Note: This may not be ALL errors in the content, as validation of content can stop + /// at any point an error is detected. + detail: ContentErrorDetailList, +} + +impl PreconditionFailed { + /// Create a new `ContentErrorDetail` Response Payload. + pub fn new(errors: Vec) -> Self { + let mut detail = vec![]; + for error in errors { + detail.push(ContentErrorDetail::new(&error)); + } + + Self { + detail: detail.into(), + } + } +} + +// List of Content Error Details +impl_array_types!(ContentErrorDetailList, ContentErrorDetail); + +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct ContentErrorDetail { + /// The location of the error + // #[oai(skip_serializing_if_is_none)] + loc: Option, + /// The error message. + // #[oai(skip_serializing_if_is_none)] + msg: Option, + /// The type of error + // #[oai(rename = "type", skip_serializing_if_is_none)] + err_type: Option, +} + +impl ContentErrorDetail { + /// Create a new `ContentErrorDetail` Response Payload. + pub fn new(error: &anyhow::Error) -> Self { + // TODO: See if we can get more info from the error than this. + Self { + loc: None, + msg: Some(error.to_string().into()), + err_type: None, + } + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/responses/code_429_too_many_requests.rs b/hermes/apps/athena/shared/src/utils/common/responses/code_429_too_many_requests.rs new file mode 100644 index 0000000000..86e7a84f1f --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/responses/code_429_too_many_requests.rs @@ -0,0 +1,29 @@ +//! Define `TooManyRequests` response type. + +use uuid::Uuid; + +use crate::utils::common; + +/// The client has sent too many requests in a given amount of time. +pub struct TooManyRequests { + /// Unique ID of this Server Error so that it can be located easily for debugging. + id: common::types::generic::error_uuid::ErrorUuid, + /// Error message. + // Will not contain sensitive information, internal details or backtraces. + msg: common::types::generic::error_msg::ErrorMessage, +} + +impl TooManyRequests { + /// Create a new Server Error Response Payload. + pub(crate) fn new(msg: Option) -> Self { + let msg = msg.unwrap_or( + "Too Many Requests. You have exceeded the rate limit for this endpoint.".to_string(), + ); + let id = Uuid::new_v4(); + + Self { + id: id.into(), + msg: msg.into(), + } + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/responses/code_500_internal_server_error.rs b/hermes/apps/athena/shared/src/utils/common/responses/code_500_internal_server_error.rs new file mode 100644 index 0000000000..19377ed1ae --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/responses/code_500_internal_server_error.rs @@ -0,0 +1,45 @@ +//! Define `ServerError` type. + +use utoipa::ToSchema; +use uuid::Uuid; + +/// While using macro-vis lib, you will get the `uncommon_codepoints` warning, so you will +/// probably want to place this in your crate root +use crate::utils::{common, settings::Settings}; + +/// An internal server error occurred. +/// +/// *The contents of this response should be reported to the projects issue tracker.* +#[derive(ToSchema)] +pub struct InternalServerError { + /// Unique ID of this Server Error so that it can be located easily for debugging. + id: common::types::generic::error_uuid::ErrorUuid, + /// Error message. + // Will not contain sensitive information, internal details or backtraces. + msg: common::types::generic::error_msg::ErrorMessage, + /// A URL to report an issue. + issue: Option, +} + +impl InternalServerError { + /// Create a new Server Error Response Payload. + pub(crate) fn new(msg: Option) -> Self { + let msg = msg.unwrap_or( + "Internal Server Error. Please report the issue to the service owner.".to_string(), + ); + let id = Uuid::new_v4(); + let issue_title = format!("Internal Server Error - {id}"); + let issue = Settings::generate_github_issue_url(&issue_title); + + Self { + id: id.into(), + msg: msg.into(), + issue: issue.map(Into::into), + } + } + + /// Get the id of this Server Error. + pub(crate) fn id(&self) -> Uuid { + self.id.clone().into() + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/responses/code_503_service_unavailable.rs b/hermes/apps/athena/shared/src/utils/common/responses/code_503_service_unavailable.rs new file mode 100644 index 0000000000..22f39b0694 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/responses/code_503_service_unavailable.rs @@ -0,0 +1,38 @@ +//! Define `Service Unavailable` Response Body. + +use uuid::Uuid; + +use crate::utils::common; + +/// The service is not available, try again later. +/// +/// *This is returned when the service either has not started, +/// or has become unavailable.* +pub struct ServiceUnavailable { + /// Unique ID of this Server Error so that it can be located easily for debugging. + id: common::types::generic::error_uuid::ErrorUuid, + /// Error message. + // Will not contain sensitive information, internal details or backtraces. + msg: common::types::generic::error_msg::ErrorMessage, +} + +impl ServiceUnavailable { + /// Create a new Server Error Response Payload. + pub(crate) fn new(msg: Option) -> Self { + let msg = msg.unwrap_or( + "Service Unavailable. Indicates that the server is not ready to handle the request." + .to_string(), + ); + let id = Uuid::new_v4(); + + Self { + id: id.into(), + msg: msg.into(), + } + } + + /// Get the id of this Server Error. + pub(crate) fn id(&self) -> Uuid { + self.id.clone().into() + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/responses/mod.rs b/hermes/apps/athena/shared/src/utils/common/responses/mod.rs new file mode 100644 index 0000000000..a2dee89e11 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/responses/mod.rs @@ -0,0 +1,217 @@ +//! Generic Responses are all contained in their own modules, grouped by response codes. + +use code_401_unauthorized::Unauthorized; +use code_403_forbidden::Forbidden; +use code_412_precondition_failed::PreconditionFailed; +use code_429_too_many_requests::TooManyRequests; +use code_503_service_unavailable::ServiceUnavailable; +use log::{debug, error}; + +mod code_401_unauthorized; +mod code_403_forbidden; +mod code_412_precondition_failed; +mod code_429_too_many_requests; + +pub(crate) mod code_500_internal_server_error; +pub(crate) mod code_503_service_unavailable; + +use code_500_internal_server_error::InternalServerError; +use utoipa::ToSchema; + +use super::types::headers::retry_after::{RetryAfterHeader, RetryAfterOption}; + +/// Default error responses +#[derive(ToSchema)] +pub enum ErrorResponses { + /// ## Not Found + /// + /// The queried stake address was not found at the requested slot number. + NotFound, + + /// ## Bad Request + /// + /// The client has not sent valid request, could be an invalid HTTP in general or + /// provided not correct headers, path or query arguments. + #[allow(dead_code)] + BadRequest, + + /// ## Unauthorized + /// + /// The client has not sent valid authentication credentials for the requested + /// resource. + #[allow(dead_code)] + Unauthorized(Unauthorized), + + /// ## Forbidden + /// + /// The client has not sent valid authentication credentials for the requested + /// resource. + #[allow(dead_code)] + Forbidden(Forbidden), + + /// ## URI Too Long + /// + /// The client sent a request with the URI is longer than the server is willing to + /// interpret + #[allow(dead_code)] + UriTooLong, + + /// ## Precondition Failed + /// + /// The client has not sent valid data in its request, headers, parameters or body. + PreconditionFailed(PreconditionFailed), + + /// ## Too Many Requests + /// + /// The client has sent too many requests in a given amount of time. + TooManyRequests(TooManyRequests, RetryAfterHeader), + + /// ## Request Header Fields Too Large + /// + /// The client sent a request with too large header fields. + #[allow(dead_code)] + RequestHeaderFieldsTooLarge, + + /// ## Internal Server Error. + /// + /// An internal server error occurred. + /// + /// *The contents of this response should be reported to the projects issue tracker.* + ServerError(InternalServerError), + + /// ## Service Unavailable + /// + /// The service is not available, try again later. + /// + /// *This is returned when the service either has not started, + /// or has become unavailable.* + ServiceUnavailable(ServiceUnavailable, Option), +} + +impl ErrorResponses { + /// Handle a 401 unauthorized response. + /// + /// Returns a 401 Unauthorized response. + /// Its OK if we actually never call this. Required for the API. + /// May be generated by the ingress. + pub fn unauthorized(text: String) -> Self { + let error = Unauthorized::new(Some(text)); + ErrorResponses::Unauthorized(error) + } + + /// Handle a 403 forbidden response. + /// + /// Returns a 403 Forbidden response. + /// Its OK if we actually never call this. Required for the API. + /// May be generated by the ingress. + pub fn forbidden(roles: Option>) -> Self { + let error = Forbidden::new(None, roles); + ErrorResponses::Forbidden(error) + } +} + +/// Combine provided responses type with the default responses under one type. +#[derive(ToSchema)] +pub enum WithErrorResponses { + /// Provided responses + With(T), + /// Error responses + Error(ErrorResponses), +} + +impl WithErrorResponses { + /// Handle a 5xx response. + /// Returns a Server Error or a Service Unavailable response. + pub fn handle_error(err: &anyhow::Error) -> Self { + debug!("Handling Response for Internal Error; error={:?}", err); + Self::internal_error(err) + } + + /// Handle a 503 service unavailable error response with passing a response `msg`. + /// Its different with the original `service_unavailable` as it does not handles an + /// error, though its no need to log the id of this response. + /// + /// Returns a 503 Service unavailable Error response. + pub fn service_unavailable_with_msg( + msg: String, + retry: RetryAfterOption, + ) -> Self { + let error = ServiceUnavailable::new(Some(msg)); + let retry = match retry { + RetryAfterOption::Default => Some(RetryAfterHeader::default()), + RetryAfterOption::None => None, + RetryAfterOption::Some(value) => Some(value), + }; + WithErrorResponses::Error(ErrorResponses::ServiceUnavailable(error, retry)) + } + + /// Handle a 503 service unavailable error response. + /// + /// Returns a 503 Service unavailable Error response. + pub fn service_unavailable( + err: &anyhow::Error, + retry: RetryAfterOption, + ) -> Self { + let error = ServiceUnavailable::new(None); + error!( + "id={}, error={:?}, retry_after={:?}", + error.id(), + err, + retry + ); + let retry = match retry { + RetryAfterOption::Default => Some(RetryAfterHeader::default()), + RetryAfterOption::None => None, + RetryAfterOption::Some(value) => Some(value), + }; + WithErrorResponses::Error(ErrorResponses::ServiceUnavailable(error, retry)) + } + + /// Handle a 500 internal error response. + /// + /// Returns a 500 Internal Error response. + pub fn internal_error(err: &anyhow::Error) -> Self { + let error = InternalServerError::new(None); + log::error!("id={}, error={:?}", error.id(), err); + WithErrorResponses::Error(ErrorResponses::ServerError(error)) + } + + /// Handle a 401 unauthorized response. + /// + /// Returns a 401 Unauthorized response. + /// Its OK if we actually never call this. Required for the API. + /// May be generated by the ingress. + pub fn unauthorized(text: String) -> Self { + WithErrorResponses::Error(ErrorResponses::unauthorized(text)) + } + + /// Handle a 403 forbidden response. + /// + /// Returns a 403 Forbidden response. + /// Its OK if we actually never call this. Required for the API. + /// May be generated by the ingress. + #[allow(dead_code)] + pub fn forbidden(roles: Option>) -> Self { + WithErrorResponses::Error(ErrorResponses::forbidden(roles)) + } + + /// Handle a 412 precondition failed response. + /// + /// Returns a 412 precondition failed response. + fn precondition_failed(errors: Vec) -> Self { + let error = PreconditionFailed::new(errors); + WithErrorResponses::Error(ErrorResponses::PreconditionFailed(error)) + } + + /// Handle a 429 rate limiting response. + /// + /// Returns a 429 Rate limit response. + /// Its OK if we actually never call this. Required for the API. + /// May be generated by the ingress. + #[allow(dead_code)] + pub fn rate_limit(retry_after: Option) -> Self { + let retry_after = retry_after.unwrap_or_default(); + let error = TooManyRequests::new(None); + WithErrorResponses::Error(ErrorResponses::TooManyRequests(error, retry_after)) + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/types/array_types.rs b/hermes/apps/athena/shared/src/utils/common/types/array_types.rs new file mode 100644 index 0000000000..1651cb7d8b --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/array_types.rs @@ -0,0 +1,42 @@ +//! Simple array types implementor. + +/// Macro to make creating validated and documented array types much easier. +/// +/// ## Parameters +/// +/// * `$ty` - The Type name to create. Example `MyNewType`. +/// * `$type_name` - The `OpenAPI` name for the type. Almost always going to be `string`. +/// * `$item_ty` - The Type name of the item inside this Type. +/// * `$validation` - *OPTIONAL* Validation function to apply to the string value. +macro_rules! impl_array_types { + ($(#[$docs:meta])* $ty:ident, $item_ty:ident) => { + impl_array_types!($(#[$docs])* $ty, $item_ty, |_| true); + }; + + ($(#[$docs:meta])* $ty:ident, $item_ty:ident, $validator:expr) => { + $(#[$docs])* + #[derive(Debug, Default, Clone, serde::Serialize, serde::Deserialize, utoipa::ToSchema)] + pub struct $ty(Vec<$item_ty>); + + impl From> for $ty { + fn from(value: Vec<$item_ty>) -> Self { + Self(value) + } + } + + impl std::ops::Deref for $ty { + type Target = Vec<$item_ty>; + + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + impl std::ops::DerefMut for $ty { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } + } + }; +} +pub(crate) use impl_array_types; diff --git a/hermes/apps/athena/shared/src/utils/common/types/cardano/ada_value.rs b/hermes/apps/athena/shared/src/utils/common/types/cardano/ada_value.rs new file mode 100644 index 0000000000..50962ca0b6 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/cardano/ada_value.rs @@ -0,0 +1,73 @@ +//! ADA coins value on the blockchain. + +use std::fmt::Display; + +use anyhow::bail; +use num_bigint::BigInt; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// ADA coins value on the blockchain. +#[derive( + Debug, + Eq, + PartialEq, + Hash, + Clone, + Copy, + PartialOrd, + Ord, + Default, + Serialize, + Deserialize, + ToSchema, +)] +pub struct AdaValue(u64); + +impl Display for AdaValue { + fn fmt( + &self, + f: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl AdaValue { + /// Performs saturating addition. + pub fn saturating_add( + self, + v: Self, + ) -> Self { + self.0.checked_add(v.0).map_or_else( + || { + log::error!("Ada value overflow: {self} + {v}",); + Self(u64::MAX) + }, + Self, + ) + } +} + +/// Is the Slot Number valid? +fn is_valid(_value: u64) -> bool { + true +} + +impl From for BigInt { + fn from(val: AdaValue) -> Self { + BigInt::from(val.0) + } +} + +impl TryFrom for AdaValue { + type Error = anyhow::Error; + + fn try_from(value: num_bigint::BigInt) -> Result { + let value: u64 = value.try_into()?; + if !is_valid(value) { + bail!("Invalid ADA Value"); + } + Ok(Self(value)) + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/types/cardano/as_at.rs b/hermes/apps/athena/shared/src/utils/common/types/cardano/as_at.rs new file mode 100644 index 0000000000..86ebad2d47 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/cardano/as_at.rs @@ -0,0 +1,34 @@ +//! Query Parameter that can take either a Blockchain slot Number or Unix Epoch timestamp. +//! +//! Allows better specifying of times that restrict a GET endpoints response. + +//! Hex encoded 28 byte hash. +//! +//! Hex encoded string which represents a 28 byte hash. + +use std::fmt::{self, Display}; + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +use crate::utils::common::types::cardano::slot_no::SlotNo; + +/// As at time from query string parameter. +/// Store (Whence, When and decoded `SlotNo`) in a tuple for easier access. +#[derive(Debug, Eq, PartialEq, Hash, Serialize, Deserialize, ToSchema)] +pub struct AsAt((String, u64, SlotNo)); + +impl From for SlotNo { + fn from(value: AsAt) -> Self { + value.0 .2 + } +} + +impl Display for AsAt { + fn fmt( + &self, + f: &mut fmt::Formatter<'_>, + ) -> fmt::Result { + write!(f, "{}:{}", self.0 .0, self.0 .1) + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/types/cardano/asset_name.rs b/hermes/apps/athena/shared/src/utils/common/types/cardano/asset_name.rs new file mode 100644 index 0000000000..eab4a6b807 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/cardano/asset_name.rs @@ -0,0 +1,25 @@ +//! Cardano Native Asset Name. + +use crate::utils::common::types::string_types::impl_string_types; + +impl_string_types!(AssetName, "string", "cardano:asset_name", is_valid); + +impl From<&Vec> for AssetName { + fn from(value: &Vec) -> Self { + match String::from_utf8(value.clone()) { + Ok(name) => { + // UTF8 - Yay + // Escape any `\` so its consistent with escaped ascii below. + let name = name.replace('\\', r"\\"); + Self(name) + }, + Err(_) => Self(value.escape_ascii().to_string()), + } + } +} + +impl From for AssetName { + fn from(value: String) -> Self { + Self(value) + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/types/cardano/asset_value.rs b/hermes/apps/athena/shared/src/utils/common/types/cardano/asset_value.rs new file mode 100644 index 0000000000..c057e27eeb --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/cardano/asset_value.rs @@ -0,0 +1,51 @@ +//! Value of a Cardano Native Asset. + +use std::fmt::Display; + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Value of a Cardano Native Asset (may not be zero) +#[derive(Debug, Clone, Eq, PartialEq, Hash, Serialize, Deserialize, ToSchema)] +pub struct AssetValue(i128); + +impl Display for AssetValue { + fn fmt( + &self, + f: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl AssetValue { + /// Performs saturating addition. + pub fn saturating_add( + &self, + v: &Self, + ) -> Self { + self.0.checked_add(v.0).map_or_else( + || { + log::error!("Asset value overflow: {self} + {v}",); + Self(i128::MAX) + }, + Self, + ) + } +} + +// Really no need for this to be fallible. +// Its not possible for it to be outside the range of an i128, and if it is. +// Just saturate. +impl From<&num_bigint::BigInt> for AssetValue { + fn from(value: &num_bigint::BigInt) -> Self { + let sign = value.sign(); + match TryInto::::try_into(value) { + Ok(v) => Self(v), + Err(_) => match sign { + num_bigint::Sign::Minus => Self(i128::MIN), + _ => Self(i128::MAX), + }, + } + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/types/cardano/catalyst_id.rs b/hermes/apps/athena/shared/src/utils/common/types/cardano/catalyst_id.rs new file mode 100644 index 0000000000..6817845231 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/cardano/catalyst_id.rs @@ -0,0 +1,33 @@ +//! A Catalyst identifier. + +// cSpell:ignoreRegExp cardano/Fftx + +use anyhow::Context; +use catalyst_types::catalyst_id::CatalystId as CatalystIdInner; + +/// A Catalyst identifier. +#[derive(Debug, Clone, PartialEq, Hash)] +pub(crate) struct CatalystId(CatalystIdInner); + +impl From for CatalystId { + fn from(value: CatalystIdInner) -> Self { + Self(value.as_short_id()) + } +} + +impl From for CatalystIdInner { + fn from(value: CatalystId) -> Self { + value.0 + } +} + +impl TryFrom<&str> for CatalystId { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + value + .parse() + .context("Invalid Catalyst ID") + .map(|id: CatalystIdInner| Self(id.as_short_id())) + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/types/cardano/cip19_stake_address.rs b/hermes/apps/athena/shared/src/utils/common/types/cardano/cip19_stake_address.rs new file mode 100644 index 0000000000..ed587b64a3 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/cardano/cip19_stake_address.rs @@ -0,0 +1,106 @@ +//! Cardano stake address types. +//! +//! More information can be found in [CIP-19](https://cips.cardano.org/cip/CIP-19) + +use anyhow::bail; +use cardano_blockchain_types::{pallas_addresses::Address, StakeAddress}; + +use crate::utils::common::types::string_types::impl_string_types; + +// cSpell:enable +/// Production Stake Address Identifier +const PROD_STAKE: &str = "stake"; +/// Test Stake Address Identifier +const TEST_STAKE: &str = "stake_test"; +/// Length of the decoded address. +const DECODED_ADDR_LEN: usize = 29; + +impl_string_types!(Cip19StakeAddress, "string", FORMAT, is_valid); + +impl TryFrom<&str> for Cip19StakeAddress { + type Error = anyhow::Error; + + fn try_from(value: &str) -> Result { + value.to_string().try_into() + } +} + +impl TryFrom for Cip19StakeAddress { + type Error = anyhow::Error; + + fn try_from(value: String) -> Result { + match bech32::decode(&value) { + Ok((hrp, addr)) => { + let hrp = hrp.as_str(); + if addr.len() == DECODED_ADDR_LEN && (hrp == PROD_STAKE || hrp == TEST_STAKE) { + return Ok(Cip19StakeAddress(value)); + } + bail!("Invalid CIP-19 formatted Stake Address") + }, + Err(err) => { + bail!("Invalid CIP-19 formatted Stake Address : {err}"); + }, + }; + } +} + +impl From for Cip19StakeAddress { + fn from(value: StakeAddress) -> Self { + Self(value.to_string()) + } +} + +impl TryInto for Cip19StakeAddress { + type Error = anyhow::Error; + + fn try_into(self) -> Result { + let address_str = &self.0; + let address = Address::from_bech32(address_str)?; + match address { + Address::Stake(address) => Ok(address.into()), + _ => Err(anyhow::anyhow!("Invalid stake address")), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Test Vector: + // cspell: disable + const VALID_PROD_STAKE_ADDRESS: &str = + "stake1uyehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gh6ffgw"; + const VALID_TEST_STAKE_ADDRESS: &str = + "stake_test1uqehkck0lajq8gr28t9uxnuvgcqrc6070x3k9r8048z8y5gssrtvn"; + const INVALID_STAKE_ADDRESS: &str = + "invalid1u9nlq5nmuzthw3vhgakfpxyq4r0zl2c0p8uqy24gpyjsa6c3df4h6"; + // cspell: enable + + #[test] + fn test_valid_stake_address_from_string() { + let stake_address_prod = Cip19StakeAddress::try_from(VALID_PROD_STAKE_ADDRESS.to_string()); + let stake_address_test = Cip19StakeAddress::try_from(VALID_TEST_STAKE_ADDRESS.to_string()); + + assert!(stake_address_prod.is_ok()); + assert!(stake_address_test.is_ok()); + assert_eq!(stake_address_prod.unwrap().0, VALID_PROD_STAKE_ADDRESS); + assert_eq!(stake_address_test.unwrap().0, VALID_TEST_STAKE_ADDRESS); + } + + #[test] + fn test_invalid_stake_address_from_string() { + let stake_address = Cip19StakeAddress::try_from(INVALID_STAKE_ADDRESS.to_string()); + assert!(stake_address.is_err()); + } + + #[test] + fn cip19_stake_address_to_stake_address() { + let stake_address_prod = + Cip19StakeAddress::try_from(VALID_PROD_STAKE_ADDRESS.to_string()).unwrap(); + + let stake_addr: StakeAddress = stake_address_prod.try_into().unwrap(); + let bytes = Vec::from(stake_addr); + assert_eq!(bytes.len(), 29); + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/types/cardano/hash28.rs b/hermes/apps/athena/shared/src/utils/common/types/cardano/hash28.rs new file mode 100644 index 0000000000..57d8a3af13 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/cardano/hash28.rs @@ -0,0 +1,36 @@ +//! Hex encoded 28 byte hash. +//! +//! Hex encoded string which represents a 28 byte hash. + +use anyhow::bail; +use cardano_blockchain_types::hashes::BLAKE_2B224_SIZE; + +use crate::utils::{ + common::types::string_types::impl_string_types, + hex::{as_hex_string, from_hex_string}, +}; + +/// Length of the hash itself; +const HASH_LENGTH: usize = BLAKE_2B224_SIZE; +impl_string_types!(HexEncodedHash28, "string", "hex:hash(28)", is_valid); + +impl TryFrom<&Vec> for HexEncodedHash28 { + type Error = anyhow::Error; + + fn try_from(value: &Vec) -> Result { + if value.len() != HASH_LENGTH { + bail!("Hash Length Invalid.") + } + Ok(Self(as_hex_string(value))) + } +} + +// Because it is impossible for the Encoded Hash to not be valid (due to `is_valid`), we +// can ensure this method is infallible. +// All creation of this type should come only from one of the deserialization methods. +impl From for Vec { + fn from(val: HexEncodedHash28) -> Self { + #[allow(clippy::expect_used)] + from_hex_string(&val.0).expect("This can only fail if the type was invalidly constructed.") + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/types/cardano/mod.rs b/hermes/apps/athena/shared/src/utils/common/types/cardano/mod.rs new file mode 100644 index 0000000000..2d91f30e94 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/cardano/mod.rs @@ -0,0 +1,11 @@ +//! Cardano Types + +pub mod ada_value; +pub mod as_at; +pub mod asset_name; +pub mod asset_value; +pub(crate) mod catalyst_id; +pub mod cip19_stake_address; +pub mod hash28; +pub mod slot_no; +pub(crate) mod transaction_id; diff --git a/hermes/apps/athena/shared/src/utils/common/types/cardano/slot_no.rs b/hermes/apps/athena/shared/src/utils/common/types/cardano/slot_no.rs new file mode 100644 index 0000000000..524ff24bd9 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/cardano/slot_no.rs @@ -0,0 +1,80 @@ +//! Slot Number on the blockchain. + +use anyhow::bail; +use cardano_blockchain_types::Slot; +use num_bigint::BigInt; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +/// Slot number +#[derive( + Debug, Eq, PartialEq, Hash, Clone, Copy, PartialOrd, Ord, Serialize, Deserialize, ToSchema, +)] +pub struct SlotNo(u64); + +impl SlotNo { + /// Maximum. + pub const MAXIMUM: SlotNo = SlotNo(u64::MAX / 2); + /// Minimum. + pub(crate) const MINIMUM: SlotNo = SlotNo(0); + + /// Is the Slot Number valid? + fn is_valid(value: u64) -> bool { + (Self::MINIMUM.0..=Self::MAXIMUM.0).contains(&value) + } + + /// Generic conversion of `Option` to `Option`. + pub fn into_option>(value: Option) -> Option { + value.map(std::convert::Into::into) + } +} + +impl Default for SlotNo { + /// Explicit default implementation of `SlotNo` which is `0`. + fn default() -> Self { + Self(0) + } +} + +impl From for BigInt { + fn from(val: SlotNo) -> Self { + BigInt::from(val.0) + } +} + +impl TryFrom for SlotNo { + type Error = anyhow::Error; + + fn try_from(value: u64) -> Result { + if !Self::is_valid(value) { + bail!("Invalid Slot Number"); + } + Ok(Self(value)) + } +} + +impl TryFrom for SlotNo { + type Error = anyhow::Error; + + fn try_from(value: i64) -> Result { + u64::try_from(value).map(TryInto::try_into)? + } +} + +impl From for u64 { + fn from(value: SlotNo) -> Self { + value.0 + } +} + +impl From for SlotNo { + fn from(value: Slot) -> Self { + Self(value.into()) + } +} + +impl From for Slot { + fn from(value: SlotNo) -> Self { + value.0.into() + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/types/cardano/transaction_id.rs b/hermes/apps/athena/shared/src/utils/common/types/cardano/transaction_id.rs new file mode 100644 index 0000000000..393f37ce40 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/cardano/transaction_id.rs @@ -0,0 +1,27 @@ +//! Transaction ID. + +use cardano_blockchain_types::hashes::{TransactionId, BLAKE_2B256_SIZE}; + +use crate::utils::{common::types::string_types::impl_string_types, hex::as_hex_string}; + +/// Length of the hash itself; +const HASH_LENGTH: usize = BLAKE_2B256_SIZE; + +impl_string_types!(TxnId, "string", "hex:hash(32)", is_valid); + +impl TryFrom> for TxnId { + type Error = anyhow::Error; + + fn try_from(value: Vec) -> Result { + if value.len() != HASH_LENGTH { + anyhow::bail!("Hash Length Invalid.") + } + Ok(Self(as_hex_string(&value))) + } +} + +impl From for TxnId { + fn from(value: TransactionId) -> Self { + Self(value.to_string()) + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/types/generic/date_time.rs b/hermes/apps/athena/shared/src/utils/common/types/generic/date_time.rs new file mode 100644 index 0000000000..edbb54a0ed --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/generic/date_time.rs @@ -0,0 +1,18 @@ +//! Implement API endpoint interfacing `DateTime`. + +use core::fmt; + +use derive_more::{From, Into}; + +/// Newtype for `DateTime`. Should be used for API interfacing `DateTime` only. +#[derive(Debug, Clone, From, Into)] +pub(crate) struct DateTime(chrono::DateTime); + +impl fmt::Display for DateTime { + fn fmt( + &self, + f: &mut fmt::Formatter<'_>, + ) -> fmt::Result { + self.0.to_rfc3339().fmt(f) + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/types/generic/error_list.rs b/hermes/apps/athena/shared/src/utils/common/types/generic/error_list.rs new file mode 100644 index 0000000000..4834c28e53 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/generic/error_list.rs @@ -0,0 +1,7 @@ +//! Implement newtype of `ErrorList` + +use super::error_msg::ErrorMessage; +use crate::utils::common::types::array_types::impl_array_types; + +// List of Errors +impl_array_types!(ErrorList, ErrorMessage); diff --git a/hermes/apps/athena/shared/src/utils/common/types/generic/error_msg.rs b/hermes/apps/athena/shared/src/utils/common/types/generic/error_msg.rs new file mode 100644 index 0000000000..ba01db7ccd --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/generic/error_msg.rs @@ -0,0 +1,26 @@ +//! Generic Error Messages + +// cspell: words impls + +use crate::utils::common::types::string_types::impl_string_types; + +impl_string_types!(ErrorMessage, "string", "error", is_valid); + +#[allow(clippy::derivable_impls)] +impl Default for ErrorMessage { + fn default() -> Self { + Self(String::default()) + } +} + +impl From for ErrorMessage { + fn from(val: String) -> Self { + Self(val) + } +} + +impl From<&str> for ErrorMessage { + fn from(val: &str) -> Self { + Self(val.to_owned()) + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/types/generic/error_uuid.rs b/hermes/apps/athena/shared/src/utils/common/types/generic/error_uuid.rs new file mode 100644 index 0000000000..3a6448ef92 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/generic/error_uuid.rs @@ -0,0 +1,9 @@ +//! Implement API endpoint interfacing `ErrorUuid`. + +use derive_more::{From, Into}; +use utoipa::ToSchema; +use uuid::Uuid; + +/// Error Unique ID +#[derive(Debug, Clone, From, Into, ToSchema)] +pub(crate) struct ErrorUuid(Uuid); diff --git a/hermes/apps/athena/shared/src/utils/common/types/generic/mod.rs b/hermes/apps/athena/shared/src/utils/common/types/generic/mod.rs new file mode 100644 index 0000000000..47de9f137f --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/generic/mod.rs @@ -0,0 +1,9 @@ +//! Generic Types +//! +//! These types may be used in Cardano, but are not specific to Cardano. + +pub(crate) mod date_time; +pub(crate) mod error_list; +pub(crate) mod error_msg; +pub(crate) mod error_uuid; +pub(crate) mod url; diff --git a/hermes/apps/athena/shared/src/utils/common/types/generic/url.rs b/hermes/apps/athena/shared/src/utils/common/types/generic/url.rs new file mode 100644 index 0000000000..d990a94ac1 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/generic/url.rs @@ -0,0 +1,7 @@ +//! Implementation of `Url` newtype. + +use derive_more::{From, Into}; + +/// URL String +#[derive(Debug, Clone, From, Into)] +pub(crate) struct Url(url::Url); diff --git a/hermes/apps/athena/shared/src/utils/common/types/headers/mod.rs b/hermes/apps/athena/shared/src/utils/common/types/headers/mod.rs new file mode 100644 index 0000000000..093868cbe4 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/headers/mod.rs @@ -0,0 +1,17 @@ +//! Header Types +//! +//! These are types used to define the values of the contents of header fields. +//! +//! There are two kinds of header fields: +//! +//! ## Passive header fields +//! +//! These headers are not created or updated by the responder, or are only read in a +//! request. They could be produced by middleware. +//! +//! ## Active header fields +//! +//! These are produced as part of a response, and it's the responsibility of the responder +//! to set them. + +pub(crate) mod retry_after; diff --git a/hermes/apps/athena/shared/src/utils/common/types/headers/retry_after.rs b/hermes/apps/athena/shared/src/utils/common/types/headers/retry_after.rs new file mode 100644 index 0000000000..2680c41baf --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/headers/retry_after.rs @@ -0,0 +1,52 @@ +//! Retry After header type +//! +//! This is an active header which expects to be provided in a response. + +use std::fmt::Display; + +use chrono::{DateTime, Utc}; + +/// Parameter which describes the possible choices for a Retry-After header field. +#[derive(Debug)] +#[allow(dead_code)] // Its OK if all these variants are not used. +pub enum RetryAfterHeader { + /// Http Date + Date(DateTime), + /// Interval in seconds. + Seconds(u64), +} + +/// Parameter which lets us set the retry header, or use some default. +/// Needed, because its valid to exclude the retry header specifically. +/// This is also due to the way Poem handles optional headers. +#[derive(Debug)] +#[allow(dead_code)] // Its OK if all these variants are not used. +pub enum RetryAfterOption { + /// Use a default Retry After header value + Default, + /// Don't include the Retry After header value in the response. + None, + /// Use a specific Retry After header value + Some(RetryAfterHeader), +} + +impl Display for RetryAfterHeader { + fn fmt( + &self, + f: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + match self { + RetryAfterHeader::Date(date_time) => { + let http_date = date_time.format("%a, %d %b %Y %T GMT").to_string(); + write!(f, "{http_date}") + }, + RetryAfterHeader::Seconds(secs) => write!(f, "{secs}"), + } + } +} + +impl Default for RetryAfterHeader { + fn default() -> Self { + Self::Seconds(300) + } +} diff --git a/hermes/apps/athena/shared/src/utils/common/types/mod.rs b/hermes/apps/athena/shared/src/utils/common/types/mod.rs new file mode 100644 index 0000000000..5c5927be3a --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/mod.rs @@ -0,0 +1,15 @@ +//! Common types +//! +//! These should be simple types, not objects. +//! For example, types derived from strings or integers and vectors of simple types only. +//! +//! Objects are objects, and not types. +//! +//! Simple types can be enums, if the intended underlying type is simple, such as a string +//! or integer. + +pub(crate) mod array_types; +pub mod cardano; +pub(crate) mod generic; +pub(crate) mod headers; +pub(crate) mod string_types; diff --git a/hermes/apps/athena/shared/src/utils/common/types/string_types.rs b/hermes/apps/athena/shared/src/utils/common/types/string_types.rs new file mode 100644 index 0000000000..ec03145804 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/common/types/string_types.rs @@ -0,0 +1,71 @@ +//! Simple string types. +//! +//! This code comes from Poem, but it is not exported by Poem, so replicated here. +//! +//! Original Source: + +/// Macro to make creating validated and documented string types much easier. +/// +/// ## Parameters +/// +/// * `$ty` - The Type name to create. Example `MyNewType`. +/// * `$type_name` - The `OpenAPI` name for the type. Almost always going to be `string`. +/// * `$format` - The `OpenAPI` format for the type. Where possible use a defined +/// `OpenAPI` or `JsonSchema` format. +/// * `$schema` - A Poem `MetaSchema` which defines all the schema parameters for the +/// type. +/// * `$validation` - *OPTIONAL* Validation function to apply to the string value. +/// +/// +/// ## Example +/// +/// ```ignore +/// impl_string_types!(MyNewType, "string", "date", MyNewTypeSchema, SomeValidationFunction); +/// ``` +/// +/// Is the equivalent of: +/// +/// ```ignore +/// #[derive(Debug, Clone, Eq, PartialEq, Hash)] +/// pub struct MyNewType(pub String); +/// +/// impl for MyNewType { ... } +/// ``` +macro_rules! impl_string_types { + ($(#[$docs:meta])* $ty:ident, $type_name:literal, $format:expr) => { + impl_string_types!($(#[$docs])* $ty, $type_name, $format, |_| true); + }; + + ($(#[$docs:meta])* $ty:ident, $type_name:literal, $format:expr, $validator:expr) => { + $(#[$docs])* + #[derive(Debug, Clone, Eq, PartialEq, Hash, serde::Serialize, serde::Deserialize, utoipa::ToSchema)] + pub struct $ty(String); + + impl std::ops::Deref for $ty { + type Target = String; + + fn deref(&self) -> &Self::Target { + &self.0 + } + } + + impl std::ops::DerefMut for $ty { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.0 + } + } + + impl AsRef for $ty { + fn as_ref(&self) -> &str { + &self.0 + } + } + + impl From<$ty> for String { + fn from(val: $ty) -> Self { + val.0 + } + } + }; +} +pub(crate) use impl_string_types; diff --git a/hermes/apps/athena/shared/src/utils/hex.rs b/hermes/apps/athena/shared/src/utils/hex.rs new file mode 100644 index 0000000000..f3bb0886c3 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/hex.rs @@ -0,0 +1,19 @@ +//! Hex helper functions + +use anyhow::{bail, Result}; + +/// Convert bytes to hex string with the `0x` prefix +pub(crate) fn as_hex_string>(bytes: T) -> String { + format!("0x{}", hex::encode(bytes)) +} + +/// Convert bytes to hex string with the `0x` prefix +pub(crate) fn from_hex_string(hex: &str) -> Result> { + #[allow(clippy::string_slice)] // Safe because of size checks. + if hex.len() < 4 || hex.len() % 2 != 0 || &hex[0..2] != "0x" { + bail!("Invalid hex string"); + } + + #[allow(clippy::string_slice)] // Safe due to above checks. + Ok(hex::decode(&hex[2..])?) +} diff --git a/hermes/apps/athena/shared/src/utils/mod.rs b/hermes/apps/athena/shared/src/utils/mod.rs index 7fb380c777..162e79b891 100644 --- a/hermes/apps/athena/shared/src/utils/mod.rs +++ b/hermes/apps/athena/shared/src/utils/mod.rs @@ -2,6 +2,14 @@ #[cfg(feature = "cardano-blockchain-types")] pub mod cardano; +#[cfg(feature = "cat-gateway-types")] +pub mod common; +#[cfg(feature = "cat-gateway-types")] +pub mod hex; pub mod log; pub mod problem_report; +#[cfg(feature = "cat-gateway-types")] +pub mod rbac; +#[cfg(feature = "cat-gateway-types")] +pub mod settings; pub mod sqlite; diff --git a/hermes/apps/athena/shared/src/utils/rbac/chain_info.rs b/hermes/apps/athena/shared/src/utils/rbac/chain_info.rs new file mode 100644 index 0000000000..b33c365c24 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/rbac/chain_info.rs @@ -0,0 +1 @@ +//! A RBAC registration chain information. diff --git a/hermes/apps/athena/shared/src/utils/rbac/get_chain.rs b/hermes/apps/athena/shared/src/utils/rbac/get_chain.rs new file mode 100644 index 0000000000..64aa53c701 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/rbac/get_chain.rs @@ -0,0 +1,10 @@ +//! Utilities for obtaining a RBAC registration chain (`RegistrationChain`). + +use anyhow::Result; +use catalyst_types::catalyst_id::CatalystId; + +/// Returns the latest (including the volatile part) registration chain by the given +/// Catalyst ID. +pub async fn latest_rbac_chain(_id: &CatalystId) -> Result> { + Ok(None) +} diff --git a/hermes/apps/athena/shared/src/utils/rbac/mod.rs b/hermes/apps/athena/shared/src/utils/rbac/mod.rs new file mode 100644 index 0000000000..d76e7009b4 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/rbac/mod.rs @@ -0,0 +1,3 @@ +//! RBAC related utilities. + +mod chain_info; diff --git a/hermes/apps/athena/shared/src/utils/settings/chain_follower.rs b/hermes/apps/athena/shared/src/utils/settings/chain_follower.rs new file mode 100644 index 0000000000..4e2bf1c62a --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/settings/chain_follower.rs @@ -0,0 +1,62 @@ +//! Command line and environment variable settings for the service + +use cardano_blockchain_types::Network; + +use super::str_env_var::StringEnvVar; + +/// Default chain to follow. +const DEFAULT_NETWORK: NetworkFromStr = NetworkFromStr::Mainnet; + +/// Configuration for the chain follower. +#[derive(Clone)] +pub(crate) struct EnvVars { + /// The Blockchain we sync from. + pub(crate) chain: Network, +} + +#[derive(strum::EnumString, strum::VariantNames, strum::Display)] +#[strum(ascii_case_insensitive)] +enum NetworkFromStr { + /// Mainnet + Mainnet, + /// Preprod + Preprod, + /// Preview + Preview, + /// Devnet + Devnet, +} + +impl From for Network { + fn from(value: NetworkFromStr) -> Self { + match value { + NetworkFromStr::Mainnet => Self::Mainnet, + NetworkFromStr::Preprod => Self::Preprod, + NetworkFromStr::Preview => Self::Preview, + NetworkFromStr::Devnet => Self::Devnet { + genesis_key: "5b33322c3235332c3138362c3230312c3137372c31312c3131372c3133352c3138372c3136372c3138312c3138382c32322c35392c3230362c3130352c3233312c3135302c3231352c33302c37382c3231322c37362c31362c3235322c3138302c37322c3133342c3133372c3234372c3136312c36385d", + magic: 42, + network_id: 0, + byron_epoch_length: 100_000, + byron_slot_length: 1000, + byron_known_slot: 0, + byron_known_time: 1_564_010_416, + byron_known_hash: "8f8602837f7c6f8b8867dd1cbc1842cf51a27eaed2c70ef48325d00f8efb320f", + shelley_epoch_length: 100, + shelley_slot_length: 1, + shelley_known_slot: 1_598_400, + shelley_known_hash: "02b1c561715da9e540411123a6135ee319b02f60b9a11a603d3305556c04329f", + shelley_known_time: 1_595_967_616, + }, + } + } +} + +impl EnvVars { + /// Create a config for a cassandra cluster, identified by a default namespace. + pub(super) fn new() -> Self { + let chain = StringEnvVar::new_as_enum("CHAIN_NETWORK", DEFAULT_NETWORK, false).into(); + + Self { chain } + } +} diff --git a/hermes/apps/athena/shared/src/utils/settings/mod.rs b/hermes/apps/athena/shared/src/utils/settings/mod.rs new file mode 100644 index 0000000000..11b7c9cb40 --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/settings/mod.rs @@ -0,0 +1,103 @@ +//! Command line and environment variable settings for the service +use cardano_blockchain_types::Network; +use log::error; +use std::sync::LazyLock; +use url::Url; + +pub(crate) mod chain_follower; +pub(crate) mod str_env_var; + +/// Default Github repo owner +const GITHUB_REPO_OWNER_DEFAULT: &str = "input-output-hk"; + +/// Default Github repo name +const GITHUB_REPO_NAME_DEFAULT: &str = "hermes"; + +/// Default Github issue template to use +const GITHUB_ISSUE_TEMPLATE_DEFAULT: &str = "bug_report.yml"; + +/// All the `EnvVars` used by the service. +struct EnvVars { + /// The github repo owner + github_repo_owner: &'static str, + + /// The github repo name + github_repo_name: &'static str, + + /// The github issue template to use + github_issue_template: &'static str, + + /// The Chain Follower configuration + chain_follower: chain_follower::EnvVars, +} + +// Lazy initialization of all env vars which are not command line parameters. +// All env vars used by the application should be listed here and all should have a +// default. The default for all NON Secret values should be suitable for Production, and +// NOT development. Secrets however should only be used with the default value in +// development + +/// Handle to the mithril sync thread. One for each Network ONLY. +static ENV_VARS: LazyLock = LazyLock::new(|| { + // Support env vars in a `.env` file, doesn't need to exist. + + // TODO: get vars from env correctly after filesystem implemented in host + EnvVars { + github_repo_owner: option_env!("GITHUB_REPO_OWNER").unwrap_or(GITHUB_REPO_OWNER_DEFAULT), + github_repo_name: option_env!("GITHUB_REPO_NAME").unwrap_or(GITHUB_REPO_NAME_DEFAULT), + github_issue_template: option_env!("GITHUB_ISSUE_TEMPLATE") + .unwrap_or(GITHUB_ISSUE_TEMPLATE_DEFAULT), + chain_follower: chain_follower::EnvVars::new(), + } +}); + +/// Our Global Settings for this running service. +pub struct Settings(); + +impl Settings { + /// Chain Follower network (The Blockchain network we are configured to use). + /// Note: Catalyst Gateway can ONLY follow one network at a time. + pub fn cardano_network() -> Network { + ENV_VARS.chain_follower.chain + } + + /// Generate a github issue url with a given title + /// + /// ## Arguments + /// + /// * `title`: &str - the title to give the issue + /// + /// ## Returns + /// + /// * String - the url + /// + /// ## Example + /// + /// ```rust,no_run + /// # use cat_data_service::settings::generate_github_issue_url; + /// assert_eq!( + /// generate_github_issue_url("Hello, World! How are you?"), + /// "https://github.com/input-output-hk/catalyst-voices/issues/new?template=bug_report.yml&title=Hello%2C%20World%21%20How%20are%20you%3F" + /// ); + /// ``` + pub(crate) fn generate_github_issue_url(title: &str) -> Option { + let path = format!( + "https://github.com/{}/{}/issues/new", + ENV_VARS.github_repo_owner, ENV_VARS.github_repo_name + ); + + match Url::parse_with_params( + &path, + &[ + ("template", ENV_VARS.github_issue_template), + ("title", title), + ], + ) { + Ok(url) => Some(url), + Err(e) => { + error!("Failed to generate github issue url {:?}", e.to_string()); + None + }, + } + } +} diff --git a/hermes/apps/athena/shared/src/utils/settings/str_env_var.rs b/hermes/apps/athena/shared/src/utils/settings/str_env_var.rs new file mode 100644 index 0000000000..b86a4f339c --- /dev/null +++ b/hermes/apps/athena/shared/src/utils/settings/str_env_var.rs @@ -0,0 +1,204 @@ +//! Processing for String Environment Variables + +// cspell: words smhdwy + +use std::{ + env::{self, VarError}, + fmt::{self, Display}, + str::FromStr, +}; + +use log::{error, info}; +use strum::VariantNames; + +/// An environment variable read as a string. +#[derive(Clone)] +pub(crate) struct StringEnvVar { + /// Value of the env var. + value: String, + /// Whether the env var is displayed redacted or not. + redacted: bool, +} + +/// Ergonomic way of specifying if a env var needs to be redacted or not. +pub(super) enum StringEnvVarParams { + /// The env var is plain and should not be redacted. + Plain(String, Option), + /// The env var is redacted and should be redacted. + Redacted(String, Option), +} + +impl From<&str> for StringEnvVarParams { + fn from(s: &str) -> Self { + StringEnvVarParams::Plain(String::from(s), None) + } +} + +impl From for StringEnvVarParams { + fn from(s: String) -> Self { + StringEnvVarParams::Plain(s, None) + } +} + +impl From<(&str, bool)> for StringEnvVarParams { + fn from((s, r): (&str, bool)) -> Self { + if r { + StringEnvVarParams::Redacted(String::from(s), None) + } else { + StringEnvVarParams::Plain(String::from(s), None) + } + } +} + +impl From<(&str, bool, &str)> for StringEnvVarParams { + fn from((s, r, c): (&str, bool, &str)) -> Self { + if r { + StringEnvVarParams::Redacted(String::from(s), Some(String::from(c))) + } else { + StringEnvVarParams::Plain(String::from(s), Some(String::from(c))) + } + } +} + +/// An environment variable read as a string. +impl StringEnvVar { + /// Read the env var from the environment. + /// + /// If not defined, read from a .env file. + /// If still not defined, use the default. + /// + /// # Arguments + /// + /// * `var_name`: &str - the name of the env var + /// * `default_value`: &str - the default value + /// + /// # Returns + /// + /// * Self - the value + /// + /// # Example + /// + /// ```rust,no_run + /// #use cat_data_service::settings::StringEnvVar; + /// + /// let var = StringEnvVar::new("MY_VAR", "default"); + /// assert_eq!(var.as_str(), "default"); + /// ``` + pub(super) fn new( + var_name: &str, + param: StringEnvVarParams, + ) -> Self { + let (default_value, redacted, choices) = match param { + StringEnvVarParams::Plain(s, c) => (s, false, c), + StringEnvVarParams::Redacted(s, c) => (s, true, c), + }; + + match env::var(var_name) { + Ok(value) => { + let value = Self { value, redacted }; + info!("Env Var Defined; env={}, value={}", var_name, value); + value + }, + Err(err) => { + let value = Self { + value: default_value, + redacted, + }; + if err == VarError::NotPresent { + if let Some(choices) = choices { + info!( + "Env Var Defaulted; env={}, default={:?}, choices={:?}", + var_name, value, choices + ); + } else { + info!("Env Var Defaulted; env={}, default={}", var_name, value); + } + } else if let Some(choices) = choices { + info!( + "Env Var Error; env={}, default={}, choices={:?}, error={:?}", + var_name, value, choices, err + ); + } else { + info!( + "Env Var Error; env={}, default={}, error={:?}", + var_name, value, err + ); + } + + value + }, + } + } + + /// Convert an Envvar into the required Enum Type. + pub(super) fn new_as_enum( + var_name: &str, + default: T, + redacted: bool, + ) -> T + where + ::Err: std::fmt::Display, + { + let mut choices = String::new(); + for name in T::VARIANTS { + if choices.is_empty() { + choices.push('['); + } else { + choices.push(','); + } + choices.push_str(name); + } + choices.push(']'); + + let choice = StringEnvVar::new( + var_name, + (default.to_string().as_str(), redacted, choices.as_str()).into(), + ); + + let value = match T::from_str(choice.as_str()) { + Ok(var) => var, + Err(error) => { + error!( + "Invalid choice. Using Default.; error={}, default={}, choices={:?}, choice={}", + error, default, choices, choice + ); + default + }, + }; + + value + } + + /// Get the read env var as a str. + /// + /// # Returns + /// + /// * &str - the value + pub(crate) fn as_str(&self) -> &str { + &self.value + } +} + +impl fmt::Display for StringEnvVar { + fn fmt( + &self, + f: &mut fmt::Formatter<'_>, + ) -> fmt::Result { + if self.redacted { + return write!(f, "REDACTED"); + } + write!(f, "{}", self.value) + } +} + +impl fmt::Debug for StringEnvVar { + fn fmt( + &self, + f: &mut fmt::Formatter<'_>, + ) -> fmt::Result { + if self.redacted { + return write!(f, "REDACTED"); + } + write!(f, "env: {}", self.value) + } +} diff --git a/hermes/bin/tests/integration/tests/serial/athena/staked_ada.rs b/hermes/bin/tests/integration/tests/serial/athena/staked_ada.rs new file mode 100644 index 0000000000..45bc4c63c2 --- /dev/null +++ b/hermes/bin/tests/integration/tests/serial/athena/staked_ada.rs @@ -0,0 +1,17 @@ +use hyper::Method; + +use crate::serial::athena::build::build_athena; + +#[tokio::test] +async fn check_empty_db_request() { + let app_file_name = build_athena().expect("failed to build athena app"); + + utils::hermes::build(); + + let handler = tokio::spawn( + utils::hermes::run_app(&temp_dir, &app_file_name) + .expect_err("should fail to run hermes app"), + ); + + let req = reqwest::Request::new(Method::GET, ""); +}