diff --git a/.gitignore b/.gitignore index 6ba562d..245c01a 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules/ actions/*/dist/ *.tsbuildinfo .idea -.env \ No newline at end of file +.env +**/target diff --git a/actions/cron-action/Cargo.lock b/actions/cron-action/Cargo.lock index 68af2f7..4d8529f 100644 --- a/actions/cron-action/Cargo.lock +++ b/actions/cron-action/Cargo.lock @@ -445,7 +445,7 @@ checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" [[package]] name = "hercules" version = "0.0.0" -source = "git+https://github.com/code0-tech/hercules.git?branch=%2322-primitive-rust-sdk#b598050aaf51ea8b2ac326d851c8803e6f2f687b" +source = "git+https://github.com/code0-tech/hercules.git?branch=%2322-primitive-rust-sdk#c6ec1336b39e246cc739ab698291e06fd1f08978" dependencies = [ "async-trait", "hercules-macros", @@ -459,12 +459,13 @@ dependencies = [ "tokio-stream", "tonic", "tucana", + "uuid", ] [[package]] name = "hercules-macros" version = "0.0.0" -source = "git+https://github.com/code0-tech/hercules.git?branch=%2322-primitive-rust-sdk#b598050aaf51ea8b2ac326d851c8803e6f2f687b" +source = "git+https://github.com/code0-tech/hercules.git?branch=%2322-primitive-rust-sdk#c6ec1336b39e246cc739ab698291e06fd1f08978" dependencies = [ "proc-macro2", "quote", @@ -1484,6 +1485,17 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "want" version = "0.3.1" diff --git a/actions/rest-action/Cargo.lock b/actions/rest-action/Cargo.lock new file mode 100644 index 0000000..988f23e --- /dev/null +++ b/actions/rest-action/Cargo.lock @@ -0,0 +1,2706 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "getrandom 0.3.4", + "once_cell", + "serde", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "futures-util", + "http", + "http-body", + "http-body-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "sync_wrapper", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "borrow-or-share" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytecount" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "num-traits", +] + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "data-encoding" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" +dependencies = [ + "serde", +] + +[[package]] +name = "env_filter" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "900d271a03799a1ee8d1ca9b19893b48ca674a9284fefcfb85f05e74ed314217" +dependencies = [ + "log", + "regex", +] + +[[package]] +name = "env_logger" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de671bd27a75a797dc9ae289ba1e77276e75e2026408aab65185384e2d5cd3f6" +dependencies = [ + "anstream", + "anstyle", + "env_filter", + "jiff", + "log", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fancy-regex" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1e1dacd0d2082dfcf1351c4bdd566bbe89a2b263235a2b50058f1e130a47277" +dependencies = [ + "bit-set", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[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 = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[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 = "fraction" +version = "0.15.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e076045bb43dac435333ed5f04caf35c7463631d0dae2deb2638d94dd0a5b872" +dependencies = [ + "lazy_static", + "num", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.2.0", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hercules" +version = "0.0.0" +source = "git+https://github.com/code0-tech/hercules.git?branch=%2322-primitive-rust-sdk#c6ec1336b39e246cc739ab698291e06fd1f08978" +dependencies = [ + "async-trait", + "hercules-macros", + "inventory", + "log", + "schemars", + "serde", + "serde_json", + "thiserror", + "tokio", + "tokio-stream", + "tonic", + "tucana 0.0.80", + "uuid", +] + +[[package]] +name = "hercules-macros" +version = "0.0.0" +source = "git+https://github.com/code0-tech/hercules.git?branch=%2322-primitive-rust-sdk#c6ec1336b39e246cc739ab698291e06fd1f08978" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-timeout" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b90d566bffbce6a75bd8b09a05aa8c2cb1fabb6cb348f8840c9e4c90a0d83b0" +dependencies = [ + "hyper", + "hyper-util", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +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", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", +] + +[[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 = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "jsonschema" +version = "0.46.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0a699d3e77675e6aa4bfffe3b907c8b5f7ed3241f9965bffb25475ad4b08d05" +dependencies = [ + "ahash", + "bytecount", + "data-encoding", + "email_address", + "fancy-regex", + "fraction", + "getrandom 0.3.4", + "idna", + "itoa", + "jsonschema-regex", + "num-cmp", + "num-traits", + "percent-encoding", + "referencing", + "regex", + "reqwest", + "rustls", + "serde", + "serde_json", + "unicode-general-category", + "uuid-simd", +] + +[[package]] +name = "jsonschema-regex" +version = "0.46.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbd1086b01b9349fd4ef9a07433965af64c8ce8159abe633a189e4ff817bd13" +dependencies = [ + "regex-syntax", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lupus" +version = "0.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c732858255ef8034638b39667f7372f129f58ee76f34dd4c862acf71b1d67709" +dependencies = [ + "jsonschema", + "serde_json", + "tucana 0.0.75", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "micromap" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "multimap" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d87ecb2933e8aeadb3e3a02b828fed80a7528047e68b4f424523a0981a3a084" + +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-cmp" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa" + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "outref" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "pbjson" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8edd1efdd8ab23ba9cb9ace3d9987a72663d5d7c9f74fa00b51d6213645cf6c" +dependencies = [ + "base64", + "serde", +] + +[[package]] +name = "pbjson-build" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2ed4d5c6ae95e08ac768883c8401cf0e8deb4e6e1d6a4e1fd3d2ec4f0ec63200" +dependencies = [ + "heck", + "itertools", + "prost", + "prost-types", +] + +[[package]] +name = "pbjson-types" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a14e2757d877c0f607a82ce1b8560e224370f159d66c5d52eb55ea187ef0350e" +dependencies = [ + "bytes", + "chrono", + "pbjson", + "pbjson-build", + "prost", + "prost-build", + "serde", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[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 = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.119", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "prost" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528ac67416ff8646872a3c02cad9cc4ee5dc9f9540c9b10771855c95cb2e5ae1" +dependencies = [ + "bytes", + "prost-derive", +] + +[[package]] +name = "prost-build" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03da047801ff44bb6a4d407d4860c05fd70bb81714e6b2f3812603d5b145b042" +dependencies = [ + "heck", + "itertools", + "log", + "multimap", + "petgraph", + "prettyplease", + "prost", + "prost-types", + "pulldown-cmark", + "pulldown-cmark-to-cmark", + "regex", + "syn 2.0.119", + "tempfile", +] + +[[package]] +name = "prost-derive" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b570b25f7617e43d59005d0990ccb79e950a423952cea19671b7a876da390adf" +dependencies = [ + "anyhow", + "itertools", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "prost-types" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f94967dc7688f3054c7fac87473ffae4cc4c3904800e2d9f5b857246d8963b0a" +dependencies = [ + "prost", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags 2.13.1", + "memchr", + "unicase", +] + +[[package]] +name = "pulldown-cmark-to-cmark" +version = "22.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50793def1b900256624a709439404384204a5dc3a6ec580281bfaac35e882e90" +dependencies = [ + "pulldown-cmark", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.13.1", +] + +[[package]] +name = "ref-cast" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "216e8f773d7923bcba9ceb86a86c93cabb3903a11872fc3f138c49630e50b96d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9283685feec7d69af75fb0e858d5e7378f33fe4fc699383b2916ab9273e03c" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "referencing" +version = "0.46.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbf332a2f81899f6836f22c03da73dae8a664c32e3016b84692c23cddadc95d" +dependencies = [ + "ahash", + "fluent-uri", + "getrandom 0.3.4", + "hashbrown 0.16.1", + "itoa", + "micromap", + "parking_lot", + "percent-encoding", + "serde_json", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "rest-action" +version = "0.0.0" +dependencies = [ + "base64", + "env_logger", + "form_urlencoded", + "hercules", + "http-body-util", + "hyper", + "hyper-util", + "log", + "lupus", + "percent-encoding", + "regex", + "ring", + "schemars", + "serde", + "serde_json", + "tokio", + "tokio-stream", + "tucana 0.0.80", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.13.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "schemars" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" +dependencies = [ + "dyn-clone", + "ref-cast", + "schemars_derive", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 3.0.3", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.13.1", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_derive_internals" +version = "0.30.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "indexmap", + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3d06f0b082ba57c26b79407372e57cf2a1e28124f78e9479fe80322cf53420b" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", + "tokio-util", +] + +[[package]] +name = "tokio-util" +version = "0.7.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "libc", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tonic" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac2a5518c70fa84342385732db33fb3f44bc4cc748936eb5833d2df34d6445ef" +dependencies = [ + "async-trait", + "axum", + "base64", + "bytes", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-timeout", + "hyper-util", + "percent-encoding", + "pin-project", + "socket2", + "sync_wrapper", + "tokio", + "tokio-stream", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tonic-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c68f61875ac5293cf72e6c8cf0158086428c82c37229e98c840878f1706b0322" +dependencies = [ + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tonic-prost" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50849f68853be452acf590cde0b146665b8d507b3b8af17261df47e02c209ea0" +dependencies = [ + "bytes", + "prost", + "tonic", +] + +[[package]] +name = "tonic-prost-build" +version = "0.14.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "654e5643eff75d7f8c99197ce1440ed19a3474eada74c12bbac488b2cafdae27" +dependencies = [ + "prettyplease", + "proc-macro2", + "prost-build", + "prost-types", + "quote", + "syn 2.0.119", + "tempfile", + "tonic-build", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "indexmap", + "pin-project-lite", + "slab", + "sync_wrapper", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags 2.13.1", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "tucana" +version = "0.0.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6a58d33f2013edc52c4d782b3bdcdccff2b6b7f2233b91fc833e2f69df2277b" +dependencies = [ + "pbjson", + "pbjson-build", + "pbjson-types", + "prost", + "prost-build", + "prost-types", + "serde", + "serde_json", + "tonic", + "tonic-build", + "tonic-prost", + "tonic-prost-build", +] + +[[package]] +name = "tucana" +version = "0.0.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0cfe93386a4f88e5ea97e6ce64b72b975a8bb5464062cb66aad12e46544b959" +dependencies = [ + "pbjson", + "pbjson-build", + "pbjson-types", + "prost", + "prost-build", + "prost-types", + "serde", + "serde_json", + "tonic", + "tonic-build", + "tonic-prost", + "tonic-prost-build", +] + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-general-category" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b993bddc193ae5bd0d623b49ec06ac3e9312875fdae725a975c51db1cc1677f" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +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 = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "uuid-simd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8" +dependencies = [ + "outref", + "vsimd", +] + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vsimd" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.56" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/actions/rest-action/Cargo.toml b/actions/rest-action/Cargo.toml new file mode 100644 index 0000000..5a7df2b --- /dev/null +++ b/actions/rest-action/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "rest-action" +version = "0.0.0" +edition = "2024" +publish = false + +[dependencies] +hercules = { git = "https://github.com/code0-tech/hercules.git", branch = "#22-primitive-rust-sdk" } +tucana = { version = "0.0.80", default-features = false } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "sync"] } +tokio-stream = "0.1" +hyper = "1.8" +hyper-util = "0.1" +http-body-util = "0.1" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +schemars = "1" +log = "0.4" +env_logger = "0.11" +regex = "1" +percent-encoding = "2.3" +form_urlencoded = "1.2" +base64 = "0.22" +ring = "0.17" +lupus = "0.0.2" diff --git a/actions/rest-action/src/auth/credentials.rs b/actions/rest-action/src/auth/credentials.rs new file mode 100644 index 0000000..d5b7a02 --- /dev/null +++ b/actions/rest-action/src/auth/credentials.rs @@ -0,0 +1,133 @@ +use base64::Engine; +use tucana::shared::{Struct, Value, value::Kind}; + +use super::jwt::validate_hs256_jwt; +use super::types::AuthenticationType; + +pub(super) fn matches_authorization( + auth_type: AuthenticationType, + auth_value: &Value, + authorization: &str, +) -> bool { + match auth_type { + AuthenticationType::BearerJwt => { + let Some(secret) = value_as_string(auth_value) else { + return false; + }; + validate_hs256_jwt(authorization, secret) + } + AuthenticationType::BearerStatic => { + let Some(expected_token) = value_as_string(auth_value) else { + return false; + }; + authorization.trim() == format!("Bearer {}", expected_token.trim()) + } + AuthenticationType::Basic => { + let Some(credentials) = basic_credentials(auth_value) else { + return false; + }; + let expected_encoded = + base64::engine::general_purpose::STANDARD.encode(credentials.as_bytes()); + authorization.trim() == format!("Basic {}", expected_encoded) + } + } +} + +fn basic_credentials(value: &Value) -> Option { + if let Some(credentials) = value_as_string(value) { + return Some(credentials.trim().to_string()); + } + + let Some(Kind::StructValue(Struct { fields })) = value.kind.as_ref() else { + return None; + }; + + let username = fields + .get("username") + .or_else(|| fields.get("user")) + .and_then(value_as_string)?; + let password = fields + .get("password") + .or_else(|| fields.get("pass")) + .and_then(value_as_string)?; + + Some(format!("{username}:{password}")) +} + +fn value_as_string(value: &Value) -> Option<&str> { + match value.kind.as_ref() { + Some(Kind::StringValue(value)) => Some(value.as_str()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + use tucana::shared::{Struct, Value, value::Kind}; + + use super::matches_authorization; + use crate::auth::jwt::tests::create_hs256_jwt; + use crate::auth::types::AuthenticationType; + + #[test] + fn bearer_static_matches_expected_token() { + let value = string_value("secret"); + assert!(matches_authorization( + AuthenticationType::BearerStatic, + &value, + "Bearer secret" + )); + assert!(!matches_authorization( + AuthenticationType::BearerStatic, + &value, + "Bearer other" + )); + } + + #[test] + fn bearer_jwt_verifies_hs256_token() { + let secret = string_value("jwt-secret"); + let token = create_hs256_jwt("jwt-secret", r#"{"sub":"123"}"#); + assert!(matches_authorization( + AuthenticationType::BearerJwt, + &secret, + &format!("Bearer {token}") + )); + assert!(!matches_authorization( + AuthenticationType::BearerJwt, + &secret, + "Bearer header.payload.bad-signature" + )); + } + + #[test] + fn basic_matches_encoded_username_password_object_pair() { + let value = basic_value("user", "pass"); + assert!(matches_authorization( + AuthenticationType::Basic, + &value, + "Basic dXNlcjpwYXNz" + )); + assert!(!matches_authorization( + AuthenticationType::Basic, + &value, + "Basic dXNlcjpvdGhlcg==" + )); + } + + fn string_value(value: &str) -> Value { + Value { + kind: Some(Kind::StringValue(value.to_string())), + } + } + + fn basic_value(username: &str, password: &str) -> Value { + let mut fields = HashMap::new(); + fields.insert("username".to_string(), string_value(username)); + fields.insert("password".to_string(), string_value(password)); + Value { + kind: Some(Kind::StructValue(Struct { fields })), + } + } +} diff --git a/actions/rest-action/src/auth/jwt.rs b/actions/rest-action/src/auth/jwt.rs new file mode 100644 index 0000000..3d057ad --- /dev/null +++ b/actions/rest-action/src/auth/jwt.rs @@ -0,0 +1,131 @@ +use base64::Engine; +use ring::hmac; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub(super) fn validate_hs256_jwt(authorization: &str, secret: &str) -> bool { + let Some(token) = authorization.trim().strip_prefix("Bearer ") else { + return false; + }; + + validate_token(token, secret) +} + +fn validate_token(token: &str, secret: &str) -> bool { + let mut segments = token.split('.'); + let Some(header_segment) = segments.next() else { + return false; + }; + let Some(payload_segment) = segments.next() else { + return false; + }; + let Some(signature_segment) = segments.next() else { + return false; + }; + + if segments.next().is_some() { + return false; + } + + let Some(header) = decode_json_segment(header_segment) else { + return false; + }; + + // The flow stores only one shared secret, so JWT auth is intentionally + // limited to HS256. Supporting RS/ES algorithms would require a public + // key or JWKS setting instead of a secret string. + if header.get("alg").and_then(|alg| alg.as_str()) != Some("HS256") { + return false; + } + + if !verify_signature(header_segment, payload_segment, signature_segment, secret) { + return false; + } + + let Some(payload) = decode_json_segment(payload_segment) else { + return false; + }; + + token_is_not_expired(&payload) +} + +fn verify_signature( + header_segment: &str, + payload_segment: &str, + signature_segment: &str, + secret: &str, +) -> bool { + let Some(signature) = decode_base64_url(signature_segment) else { + return false; + }; + + let signing_input = format!("{header_segment}.{payload_segment}"); + let key = hmac::Key::new(hmac::HMAC_SHA256, secret.as_bytes()); + + hmac::verify(&key, signing_input.as_bytes(), &signature).is_ok() +} + +fn token_is_not_expired(payload: &serde_json::Value) -> bool { + let Some(exp) = payload.get("exp").and_then(|exp| exp.as_i64()) else { + return true; + }; + + let Ok(now) = SystemTime::now().duration_since(UNIX_EPOCH) else { + return false; + }; + + exp > now.as_secs() as i64 +} + +fn decode_json_segment(segment: &str) -> Option { + let bytes = decode_base64_url(segment)?; + serde_json::from_slice::(&bytes).ok() +} + +fn decode_base64_url(value: &str) -> Option> { + base64::engine::general_purpose::URL_SAFE_NO_PAD + .decode(value) + .or_else(|_| base64::engine::general_purpose::URL_SAFE.decode(value)) + .ok() +} + +#[cfg(test)] +pub(crate) mod tests { + use base64::Engine; + use ring::hmac; + + use super::validate_hs256_jwt; + + #[test] + fn verifies_hs256_token() { + let token = create_hs256_jwt("jwt-secret", r#"{"sub":"123"}"#); + assert!(validate_hs256_jwt(&format!("Bearer {token}"), "jwt-secret")); + } + + #[test] + fn rejects_expired_token() { + let token = create_hs256_jwt("jwt-secret", r#"{"exp":1}"#); + assert!(!validate_hs256_jwt( + &format!("Bearer {token}"), + "jwt-secret" + )); + } + + #[test] + fn rejects_wrong_secret() { + let token = create_hs256_jwt("jwt-secret", r#"{"sub":"123"}"#); + assert!(!validate_hs256_jwt(&format!("Bearer {token}"), "wrong")); + } + + pub(crate) fn create_hs256_jwt(secret: &str, payload: &str) -> String { + let header = r#"{"alg":"HS256","typ":"JWT"}"#; + let header_segment = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(header); + let payload_segment = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload); + let signing_input = format!("{header_segment}.{payload_segment}"); + let key = hmac::Key::new(hmac::HMAC_SHA256, secret.as_bytes()); + let signature = hmac::sign(&key, signing_input.as_bytes()); + let signature_segment = + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(signature.as_ref()); + + format!("{signing_input}.{signature_segment}") + } +} diff --git a/actions/rest-action/src/auth/mod.rs b/actions/rest-action/src/auth/mod.rs new file mode 100644 index 0000000..91e440e --- /dev/null +++ b/actions/rest-action/src/auth/mod.rs @@ -0,0 +1,65 @@ +mod credentials; +mod jwt; +mod settings; +mod types; + +use hyper::{ + HeaderMap, + header::{AUTHORIZATION, HeaderValue, WWW_AUTHENTICATE}, +}; +use tucana::aquila::ActionFlow; + +use self::credentials::matches_authorization; +use self::settings::{FlowAuthConfig, flow_auth_config}; +pub use self::types::AuthenticationError; +use crate::flow_setting; + +pub fn validate_flow_auth( + flow: &ActionFlow, + headers: &HeaderMap, +) -> Result<(), AuthenticationError> { + let auth_type = match flow_auth_config(flow) { + FlowAuthConfig::Unauthenticated => return Ok(()), + FlowAuthConfig::Invalid => { + log::warn!( + "auth reject: flow_id={} reason=invalid_http_auth", + flow.flow_id + ); + return Err(AuthenticationError::InvalidAuthorization); + } + FlowAuthConfig::Authenticated(auth_type) => auth_type, + }; + + let Some(auth_value) = flow_setting::value(flow, "http_auth_value") else { + log::warn!( + "auth reject: flow_id={} reason=missing_or_invalid_http_auth_value", + flow.flow_id + ); + return Err(AuthenticationError::invalid_for(auth_type)); + }; + + let Some(authorization) = headers + .get(AUTHORIZATION) + .and_then(|value| value.to_str().ok()) + else { + log::debug!( + "auth reject: flow_id={} reason=missing_authorization", + flow.flow_id + ); + return Err(AuthenticationError::missing_for(auth_type)); + }; + + if matches_authorization(auth_type, auth_value, authorization) { + Ok(()) + } else { + log::debug!( + "auth reject: flow_id={} reason=authorization_mismatch", + flow.flow_id + ); + Err(AuthenticationError::invalid_for(auth_type)) + } +} + +pub fn authenticate_header_name() -> hyper::header::HeaderName { + WWW_AUTHENTICATE +} diff --git a/actions/rest-action/src/auth/settings.rs b/actions/rest-action/src/auth/settings.rs new file mode 100644 index 0000000..99de360 --- /dev/null +++ b/actions/rest-action/src/auth/settings.rs @@ -0,0 +1,32 @@ +use tucana::aquila::ActionFlow; + +use super::types::{AuthenticationType, is_unauthenticated_value}; +use crate::flow_setting; + +pub(super) enum FlowAuthConfig { + Unauthenticated, + Authenticated(AuthenticationType), + Invalid, +} + +pub(super) fn flow_auth_config(flow: &ActionFlow) -> FlowAuthConfig { + let Some(raw_auth_type) = flow_setting::as_string(flow, "http_auth") else { + return FlowAuthConfig::Unauthenticated; + }; + + if is_unauthenticated_value(raw_auth_type) { + return FlowAuthConfig::Unauthenticated; + } + + match AuthenticationType::parse(raw_auth_type) { + Some(auth_type) => FlowAuthConfig::Authenticated(auth_type), + None => { + log::warn!( + "auth config invalid: flow_id={} http_auth={:?}", + flow.flow_id, + raw_auth_type + ); + FlowAuthConfig::Invalid + } + } +} diff --git a/actions/rest-action/src/auth/types.rs b/actions/rest-action/src/auth/types.rs new file mode 100644 index 0000000..aa4ad47 --- /dev/null +++ b/actions/rest-action/src/auth/types.rs @@ -0,0 +1,113 @@ +use hyper::{StatusCode, header::HeaderValue}; + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub(crate) enum AuthenticationType { + BearerJwt, + BearerStatic, + Basic, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +pub enum AuthenticationError { + MissingAuthorization(AuthenticationType), + InvalidAuthorizationFor(AuthenticationType), + InvalidAuthorization, +} + +impl AuthenticationError { + pub(super) fn missing_for(auth_type: AuthenticationType) -> Self { + Self::MissingAuthorization(auth_type) + } + + pub(super) fn invalid_for(auth_type: AuthenticationType) -> Self { + Self::InvalidAuthorizationFor(auth_type) + } + + pub fn status_code(self) -> StatusCode { + StatusCode::UNAUTHORIZED + } + + pub fn message(self) -> &'static str { + match self { + Self::MissingAuthorization(_) => "Missing authorization", + Self::InvalidAuthorizationFor(_) | Self::InvalidAuthorization => { + "Invalid authorization" + } + } + } + + pub fn challenge(self) -> HeaderValue { + match self.auth_type() { + Some(AuthenticationType::BearerJwt | AuthenticationType::BearerStatic) => { + HeaderValue::from_static("Bearer") + } + Some(AuthenticationType::Basic) => HeaderValue::from_static("Basic"), + None => HeaderValue::from_static("Bearer"), + } + } + + fn auth_type(self) -> Option { + match self { + Self::MissingAuthorization(auth_type) | Self::InvalidAuthorizationFor(auth_type) => { + Some(auth_type) + } + Self::InvalidAuthorization => None, + } + } +} + +impl AuthenticationType { + pub(super) fn parse(value: &str) -> Option { + let normalized = normalize_auth_type(value); + + match normalized.as_str() { + "bearerjwt" | "jwt" => Some(Self::BearerJwt), + "bearerstatic" | "bearer" | "staticbearer" => Some(Self::BearerStatic), + "basicaccessauth" | "basic" | "basicauth" => Some(Self::Basic), + _ => None, + } + } +} + +pub(super) fn is_unauthenticated_value(value: &str) -> bool { + matches!( + normalize_auth_type(value).as_str(), + "" | "none" | "noauth" | "unauthenticated" + ) +} + +fn normalize_auth_type(value: &str) -> String { + value + .trim() + .replace(['_', '-', ' '], "") + .to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::{AuthenticationType, is_unauthenticated_value}; + + #[test] + fn rest_auth_type_values_parse() { + assert_eq!( + AuthenticationType::parse("Bearer JWT"), + Some(AuthenticationType::BearerJwt) + ); + assert_eq!( + AuthenticationType::parse("Bearer static"), + Some(AuthenticationType::BearerStatic) + ); + assert_eq!( + AuthenticationType::parse("Basic"), + Some(AuthenticationType::Basic) + ); + } + + #[test] + fn unauthenticated_values_are_explicit() { + assert!(is_unauthenticated_value("unauthenticated")); + assert!(is_unauthenticated_value("no-auth")); + assert!(is_unauthenticated_value("none")); + assert!(is_unauthenticated_value("")); + } +} diff --git a/actions/rest-action/src/content_type.rs b/actions/rest-action/src/content_type.rs new file mode 100644 index 0000000..e2af1db --- /dev/null +++ b/actions/rest-action/src/content_type.rs @@ -0,0 +1,336 @@ +//! Request/response body <-> `tucana::shared::Value` conversion, keyed off +//! the HTTP `content-type` header. Both directions go through the same +//! `format_for_content_type` mapping and the same lupus engine, so every +//! format lupus supports (JSON, XML, HTML, CSV, form-urlencoded, plain +//! text) works identically for parsing an incoming request body and for +//! encoding an outgoing response body, so there's no format one side accepts +//! that the other rejects. + +use hyper::{ + HeaderMap, + header::{CONTENT_TYPE, HeaderValue}, +}; +use tucana::shared::Value; + +#[derive(Debug)] +pub enum BodyParseError { + UnsupportedContentType { + observed: String, + }, + InvalidJson(serde_json::Error), + Conversion { + content_type: String, + source: lupus::ConvertError, + }, +} + +impl std::fmt::Display for BodyParseError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnsupportedContentType { observed } => { + write!(f, "unsupported content type: {}", observed) + } + Self::InvalidJson(err) => write!(f, "invalid JSON body: {}", err), + Self::Conversion { + content_type, + source, + } => write!( + f, + "unable to parse request payload as '{}': {}", + content_type, source + ), + } + } +} + +impl std::error::Error for BodyParseError {} + +#[derive(Debug)] +pub enum BodyEncodeError { + UnsupportedContentType { + observed: String, + }, + InvalidJson(serde_json::Error), + Conversion { + content_type: String, + source: lupus::ConvertError, + }, +} + +impl std::fmt::Display for BodyEncodeError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::UnsupportedContentType { observed } => { + write!(f, "unsupported content type: {}", observed) + } + Self::InvalidJson(err) => write!(f, "failed to encode JSON body: {}", err), + Self::Conversion { + content_type, + source, + } => write!( + f, + "unable to convert response payload to '{}': {}", + content_type, source + ), + } + } +} + +impl std::error::Error for BodyEncodeError {} + +pub fn parse_body_from_headers( + headers: &HeaderMap, + body: &[u8], +) -> Result, BodyParseError> { + parse_body(get_content_type(headers), body) +} + +pub fn parse_body( + content_type: Option<&str>, + body: &[u8], +) -> Result, BodyParseError> { + if body.is_empty() { + return Ok(None); + } + + // No content-type header at all: best-effort as plain text, same as a + // request that explicitly says `text/plain`. + let format = match content_type { + Some(content_type) => format_for_content_type(content_type) + .map_err(|observed| BodyParseError::UnsupportedContentType { observed })?, + None => lupus::Format::Text, + }; + + let encoded = lupus::Engine::with_default_codecs() + .convert( + body, + format, + lupus::Format::Protobuf, + &lupus::DecodeContext, + &lupus::EncodeContext::default(), + ) + .map_err(|err| BodyParseError::Conversion { + content_type: content_type.unwrap_or("").to_string(), + source: err, + })?; + + let value = serde_json::from_slice(&encoded).map_err(BodyParseError::InvalidJson)?; + Ok(Some(value)) +} + +pub fn encode_body(content_type: Option<&str>, value: Value) -> Result, BodyEncodeError> { + let content_type = content_type.unwrap_or("application/json"); + let format = format_for_content_type(content_type) + .map_err(|observed| BodyEncodeError::UnsupportedContentType { observed })?; + + let protobuf = serde_json::to_vec(&value).map_err(BodyEncodeError::InvalidJson)?; + lupus::Engine::with_default_codecs() + .convert( + &protobuf, + lupus::Format::Protobuf, + format, + &lupus::DecodeContext, + &lupus::EncodeContext::default(), + ) + .map_err(|err| BodyEncodeError::Conversion { + content_type: content_type.to_string(), + source: err, + }) +} + +/// The content-type -> lupus format mapping, shared by both directions. The +/// error is just the observed content type; each caller wraps it in its own +/// `UnsupportedContentType` variant. +fn format_for_content_type(content_type: &str) -> Result { + let essence = content_type + .split(';') + .next() + .unwrap_or(content_type) + .trim() + .to_ascii_lowercase(); + + let format = match essence.as_str() { + "application/json" | "text/json" => lupus::Format::Json, + value if value.ends_with("+json") => lupus::Format::Json, + "application/xhtml+xml" => lupus::Format::Html, + "application/xml" | "text/xml" => lupus::Format::Xml, + value if value.ends_with("+xml") => lupus::Format::Xml, + "text/html" => lupus::Format::Html, + "text/plain" => lupus::Format::Text, + "text/csv" | "application/csv" => lupus::Format::Csv, + "application/x-www-form-urlencoded" => lupus::Format::HttpForm, + _ => return Err(content_type.to_string()), + }; + Ok(format) +} + +fn get_content_type(headers: &HeaderMap) -> Option<&str> { + headers.get(CONTENT_TYPE).and_then(|h| h.to_str().ok()) +} + +#[cfg(test)] +mod tests { + use super::*; + use tucana::shared::{NumberValue, Struct, number_value, value::Kind}; + + #[test] + fn parse_json_body_to_struct_value() { + let parsed = parse_body(Some("application/json"), br#"{"hello":"world"}"#).unwrap(); + let Some(Value { + kind: Some(Kind::StructValue(Struct { fields })), + }) = parsed + else { + panic!("expected struct value"); + }; + assert!(fields.contains_key("hello")); + } + + #[test] + fn parse_text_body_to_string_value() { + let parsed = parse_body(Some("text/plain"), b"hello").unwrap(); + assert_eq!( + parsed, + Some(Value { + kind: Some(Kind::StringValue("hello".to_string())) + }) + ); + } + + #[test] + fn parse_missing_content_type_falls_back_to_text() { + let parsed = parse_body(None, b"hello").unwrap(); + assert_eq!( + parsed, + Some(Value { + kind: Some(Kind::StringValue("hello".to_string())) + }) + ); + } + + #[test] + fn parse_xml_body_to_struct_value() { + let parsed = parse_body(Some("application/xml"), b"Ada").unwrap(); + let Some(Value { + kind: Some(Kind::StructValue(Struct { fields })), + }) = parsed + else { + panic!("expected struct value"); + }; + assert_eq!( + fields.get("user").and_then(|v| v.kind.as_ref()), + Some(&Kind::StringValue("Ada".to_string())) + ); + } + + #[test] + fn parse_form_urlencoded_body_to_struct_value() { + let parsed = parse_body( + Some("application/x-www-form-urlencoded"), + b"name=Ada+Doe&email=ada%40example.com", + ) + .unwrap(); + let Some(Value { + kind: Some(Kind::StructValue(Struct { fields })), + }) = parsed + else { + panic!("expected struct value"); + }; + assert_eq!( + fields.get("name").and_then(|v| v.kind.as_ref()), + Some(&Kind::StringValue("Ada Doe".to_string())) + ); + assert_eq!( + fields.get("email").and_then(|v| v.kind.as_ref()), + Some(&Kind::StringValue("ada@example.com".to_string())) + ); + } + + #[test] + fn parse_unsupported_content_type_fails() { + let err = parse_body(Some("application/octet-stream"), b"\x00\x01").unwrap_err(); + assert!(matches!(err, BodyParseError::UnsupportedContentType { .. })); + } + + #[test] + fn encode_json_body_from_struct_value() { + let value = Value { + kind: Some(Kind::StructValue(Struct { + fields: [( + "hello".to_string(), + Value { + kind: Some(Kind::StringValue("world".to_string())), + }, + )] + .into_iter() + .collect(), + })), + }; + + let encoded = encode_body(Some("application/json"), value).unwrap(); + let parsed: serde_json::Value = serde_json::from_slice(&encoded).unwrap(); + assert_eq!(parsed["hello"], "world"); + } + + #[test] + fn encode_text_body_from_number_value() { + let value = Value { + kind: Some(Kind::NumberValue(NumberValue { + number: Some(number_value::Number::Integer(42)), + })), + }; + assert_eq!( + encode_body(Some("text/plain"), value).unwrap(), + b"42".to_vec() + ); + } + + #[test] + fn encode_body_converts_struct_to_xml() { + let value = Value { + kind: Some(Kind::StructValue(Struct { + fields: [( + "user".to_string(), + Value { + kind: Some(Kind::StringValue("Ada".to_string())), + }, + )] + .into_iter() + .collect(), + })), + }; + assert_eq!( + encode_body(Some("application/xml"), value).unwrap(), + b"Ada".to_vec() + ); + } + + #[test] + fn encode_missing_content_type_falls_back_to_json() { + let value = Value { + kind: Some(Kind::StringValue("hello".to_string())), + }; + assert_eq!(encode_body(None, value).unwrap(), b"\"hello\"".to_vec()); + } + + #[test] + fn encode_unknown_content_type_fails() { + let value = Value { + kind: Some(Kind::StringValue("x".to_string())), + }; + let err = encode_body(Some("application/octet-stream"), value).unwrap_err(); + assert!(matches!( + err, + BodyEncodeError::UnsupportedContentType { .. } + )); + } + + #[test] + fn round_trips_csv_body_through_parse_and_encode() { + let parsed = parse_body(Some("text/csv"), b"name,role\nTom,admin\nAda,user\n") + .unwrap() + .expect("non-empty body"); + + let encoded = encode_body(Some("text/csv"), parsed).unwrap(); + assert_eq!(encoded, b"name,role\nTom,admin\nAda,user\n".to_vec()); + } +} diff --git a/actions/rest-action/src/data_types.rs b/actions/rest-action/src/data_types.rs new file mode 100644 index 0000000..70861a5 --- /dev/null +++ b/actions/rest-action/src/data_types.rs @@ -0,0 +1,50 @@ +//! Data types specific to this adapter. + +use hercules::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[hercules::data_type( + identifier = "REST_AUTH_TYPE", + name(en_US = "Webhook credential variant"), + display_message(en_US = "Webhook credential variant"), + alias(en_US = "webhook;rest;auth;credential;type;variant;bearer;basic;jwt"), + // A closed string union with `undefined`/`null` isn't representable via + // schemars/JSON Schema on a Rust type, so the structural type is + // hand-written to match code0-definition's reference exactly. + type_override = "'Bearer JWT' | 'Bearer static' | 'Basic' | undefined | null" +)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct RestAuthType(pub String); + +#[hercules::data_type( + identifier = "REST_AUTH_VALUE", + name(en_US = "Webhook credential value"), + display_message(en_US = "Webhook credential value"), + alias(en_US = "webhook;rest;auth;credential;value;bearer;basic;username;password;token"), + generic_keys("T"), + // A generic-conditional type keyed off `REST_AUTH_TYPE`'s value isn't + // representable via schemars either, so it's hand-written to match the + // reference. + type_override = "T extends 'Basic' ? { username: string, password: string } : T extends undefined ? undefined : T extends null ? null : string" +)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(transparent)] +pub struct RestAuthValue(pub serde_json::Value); + +#[hercules::data_type( + identifier = "REST_ADAPTER_INPUT", + name(en_US = "Rest Adapter Input"), + display_message(en_US = "Rest Adapter Input"), + alias(en_US = "http;rest;adapter;input"), + generic_keys("T"), + type_override = "{ payload: T, headers: OBJECT<{}>, query_params: OBJECT<{}>, path_params: OBJECT<{}> }", + linked_data_type_identifiers("OBJECT") +)] +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct RestAdapterInput { + pub payload: serde_json::Value, + pub headers: std::collections::HashMap, + pub query_params: std::collections::HashMap, + pub path_params: std::collections::HashMap, +} diff --git a/actions/rest-action/src/events.rs b/actions/rest-action/src/events.rs new file mode 100644 index 0000000..b5bcceb --- /dev/null +++ b/actions/rest-action/src/events.rs @@ -0,0 +1,72 @@ +//! The `REST` flow trigger. +//! +//! Registering this `RuntimeEvent` is what lets users attach a flow to an +//! HTTP route in the platform UI; Aquila then pushes each such flow down to +//! this action as an `ActionFlow` with these six settings filled in per +//! flow. `server.rs` matches incoming requests against those settings. + +#[hercules::runtime_event( + identifier = "REST", + signature = "(http_schema: HTTP_SCHEMA, http_url: HTTP_URL, http_method: HTTP_METHOD, http_auth: A, http_auth_value: REST_AUTH_VALUE, input_schema: TYPE): REST_ADAPTER_INPUT", + name(en_US = "Webhook"), + description( + en_US = "A Webhook is an HTTP endpoint that listens for incoming requests from external services or clients, allowing you to react to events in real time using standard HTTP methods like GET, POST, PUT, and DELETE." + ), + display_message(en_US = "Webhook on ${http_method} at ${http_url}"), + alias(en_US = "webhook;http;rest;route;web"), + display_icon = "tabler:world-www", + // The event's settings already contribute HTTP_SCHEMA/HTTP_URL/HTTP_METHOD/ + // REST_AUTH_TYPE/REST_AUTH_VALUE; REST_ADAPTER_INPUT is the type of the + // value the event produces as flow input, not any one setting's type, so + // it has to be declared explicitly here. + linked_data_type_identifiers("REST_ADAPTER_INPUT"), + editable +)] +#[setting( + identifier = "http_schema", + name(en_US = "Content Type"), + description( + en_US = "Specifies the MIME type of the incoming request payload, such as application/json, application/xml, or text/plain. This determines the expected format of the payload parameter." + ), + linked_data_type_identifiers("HTTP_SCHEMA") +)] +#[setting( + identifier = "http_url", + unique = "project", + name(en_US = "URL"), + description(en_US = "Specifies the HTTP URL endpoint."), + linked_data_type_identifiers("HTTP_URL") +)] +#[setting( + identifier = "http_method", + name(en_US = "Method"), + description(en_US = "Specifies the HTTP request method (e.g., GET, POST, PUT, DELETE)."), + linked_data_type_identifiers("HTTP_METHOD") +)] +#[setting( + identifier = "http_auth", + name(en_US = "Authentication type"), + description( + en_US = "Specifies the authentication mechanism used for the incoming Webhook request (e.g., Bearer JWT, Bearer static, Basic)." + ), + linked_data_type_identifiers("REST_AUTH_TYPE"), + optional +)] +#[setting( + identifier = "http_auth_value", + name(en_US = "Authentication value"), + description( + en_US = "Provides the credential value matching the selected authentication type (e.g., token string or username/password pair)." + ), + linked_data_type_identifiers("REST_AUTH_VALUE"), + optional +)] +#[setting( + identifier = "input_schema", + name(en_US = "Input schema"), + description( + en_US = "Input schema which defines the expected structure of the incoming request data." + ), + optional +)] +pub struct RestRuntimeEvent; diff --git a/actions/rest-action/src/flow_setting.rs b/actions/rest-action/src/flow_setting.rs new file mode 100644 index 0000000..9b63685 --- /dev/null +++ b/actions/rest-action/src/flow_setting.rs @@ -0,0 +1,28 @@ +//! Reads a typed value out of an `ActionFlow`'s settings by identifier. +//! Shared by route matching (`route.rs`), auth (`auth/`), and schema +//! validation (`server.rs`), none of which care about HTTP transport, just +//! "what did this flow configure for setting X". + +use tucana::aquila::ActionFlow; +use tucana::shared::{Struct, Value, value::Kind}; + +pub fn value<'a>(flow: &'a ActionFlow, flow_setting_id: &str) -> Option<&'a Value> { + flow.settings + .iter() + .find(|setting| setting.flow_setting_id == flow_setting_id) + .and_then(|setting| setting.value.as_ref()) +} + +pub fn as_string<'a>(flow: &'a ActionFlow, flow_setting_id: &str) -> Option<&'a str> { + match value(flow, flow_setting_id)?.kind.as_ref()? { + Kind::StringValue(value) => Some(value.as_str()), + _ => None, + } +} + +pub fn as_struct<'a>(flow: &'a ActionFlow, flow_setting_id: &str) -> Option<&'a Struct> { + match value(flow, flow_setting_id)?.kind.as_ref()? { + Kind::StructValue(value) => Some(value), + _ => None, + } +} diff --git a/actions/rest-action/src/functions.rs b/actions/rest-action/src/functions.rs new file mode 100644 index 0000000..3afb364 --- /dev/null +++ b/actions/rest-action/src/functions.rs @@ -0,0 +1,106 @@ +//! `rest::control::respond`: the function a flow calls to send its HTTP +//! response. Dispatched to this action like any other remote function +//! (`taurus` routes it here because the flow definition's `definitionSource` +//! points at this action). `Respond::run` just hands the payload to whichever +//! `server.rs` HTTP handler is still waiting on `FunctionContext::execution_id`. + +use std::collections::HashMap; + +use hercules::{ + Arguments, FunctionContext, PlainValue, Result, RuntimeFunctionHandler, async_trait, +}; + +use crate::pending::{self, PendingResponses, RespondPayload, RespondSignal}; + +#[hercules::runtime_function( + identifier = "rest::control::respond", + signature = "(http_status_code: HTTP_STATUS_CODE, http_schema: S, payload: HTTP_PAYLOAD, headers?: OBJECT<{}>): void", + name(en_US = "Respond"), + description( + en_US = "Processes an HTTP response and returns it to the requesting client. This function typically completes the HTTP request–response cycle by delivering the server’s final output, such as headers, status codes, and body content, back to the client." + ), + display_message( + en_US = "Sends response with status ${http_status_code} and payload ${payload}" + ), + alias(en_US = "respond;control;http"), + display_icon = "tabler:cube-send", + linked_data_type_identifiers("HTTP_STATUS_CODE", "OBJECT", "HTTP_SCHEMA", "HTTP_PAYLOAD"), + manual +)] +#[parameter( + runtime_name = "http_status_code", + name(en_US = "HTTP Status Code"), + description( + en_US = "An HTTP status code is a three-digit number (100–599) indicating the result of a request (e.g., 200, 404, 500)." + ) +)] +#[parameter( + runtime_name = "http_schema", + name(en_US = "Content Type"), + description( + en_US = "Specifies the MIME type of the response payload, such as application/json, application/xml, or text/plain. This determines the expected format of the payload parameter." + ) +)] +#[parameter( + runtime_name = "payload", + name(en_US = "Response Payload"), + description( + en_US = "Contains the response payload. For application/json the value must be an OBJECT, for all other content types a plain string is expected." + ) +)] +#[parameter( + runtime_name = "headers", + name(en_US = "HTTP response headers"), + description( + en_US = "A collection of key-value pairs containing additional response metadata." + ), + optional +)] +pub struct Respond { + pending: PendingResponses, +} + +impl Respond { + pub fn new(pending: PendingResponses) -> Self { + Self { pending } + } +} + +#[async_trait] +impl RuntimeFunctionHandler for Respond { + async fn run(&self, context: &FunctionContext, args: &Arguments) -> Result { + let status_code: i64 = args.get("http_status_code")?; + let content_type: String = args.get("http_schema")?; + let payload: PlainValue = args.get("payload")?; + let headers: HashMap = args.get("headers").unwrap_or_default(); + + log::info!( + "respond called for execution {}: status {status_code}", + context.execution_id + ); + + match pending::take(&self.pending, &context.execution_id) { + Some(tx) => { + let _ = tx.send(RespondSignal::Respond(RespondPayload { + status_code, + content_type, + payload, + headers, + })); + } + // Not every execution of a flow that calls `respond` came from + // an HTTP request waiting on a response. A manual/test + // execution triggered outside `server.rs` has no such + // connection to begin with, which is expected, not a failure. + // (An HTTP request whose connection already timed out, or that + // already got its response via an earlier `respond` call in the + // same flow, lands here too and is equally harmless.) + None => log::info!( + "respond called for execution {} with no waiting HTTP connection", + context.execution_id + ), + } + + Ok(PlainValue::Null) + } +} diff --git a/actions/rest-action/src/input.rs b/actions/rest-action/src/input.rs new file mode 100644 index 0000000..d8b9425 --- /dev/null +++ b/actions/rest-action/src/input.rs @@ -0,0 +1,168 @@ +//! Builds the flow input value (`payload`/`headers`/`query_params`/ +//! `path_params`, matching `REST_ADAPTER_INPUT`) from an incoming request. +//! Ported from draco's REST adapter unchanged. + +use hyper::{HeaderMap, header::HeaderValue}; +use std::collections::HashMap; +use tucana::aquila::ActionFlow; +use tucana::shared::{Struct, Value, helper::value::ToValue, value::Kind}; + +use crate::route; + +pub fn build_flow_input( + flow: &ActionFlow, + path: &str, + query: Option<&str>, + headers: &HeaderMap, + payload: Option, +) -> Value { + let mut fields = HashMap::new(); + + if let Some(payload) = payload { + fields.insert(String::from("payload"), payload); + } + + fields.insert( + String::from("headers"), + string_map_to_value(header_map(headers)), + ); + fields.insert( + String::from("query_params"), + string_map_to_value(query_params(query)), + ); + fields.insert( + String::from("path_params"), + string_map_to_value(route::extract_path_params(flow, path)), + ); + + Value { + kind: Some(Kind::StructValue(Struct { fields })), + } +} + +fn header_map(headers: &HeaderMap) -> HashMap { + headers + .iter() + .map(|(name, value)| { + let value = value + .to_str() + .map(str::to_owned) + .unwrap_or_else(|_| String::from_utf8_lossy(value.as_bytes()).into_owned()); + (name.as_str().to_owned(), value) + }) + .collect() +} + +fn query_params(query: Option<&str>) -> HashMap { + let Some(query) = query else { + return HashMap::new(); + }; + + // Repeated query keys use last-write-wins because taurus receives a + // simple object here, not a multi-map. + form_urlencoded::parse(query.as_bytes()) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect() +} + +fn string_map_to_value(map: HashMap) -> Value { + Value { + kind: Some(Kind::StructValue(Struct { + fields: map + .into_iter() + .map(|(key, value)| (key, value.to_value())) + .collect(), + })), + } +} + +#[cfg(test)] +mod tests { + use super::{build_flow_input, query_params, string_map_to_value}; + use hyper::HeaderMap; + use tucana::aquila::ActionFlow; + use tucana::shared::{FlowSetting, Struct, Value, value::Kind}; + + #[test] + fn query_params_are_percent_decoded() { + let params = query_params(Some("search=hello+world&tag=a%2Fb&empty=")); + assert_eq!( + params.get("search").map(String::as_str), + Some("hello world") + ); + assert_eq!(params.get("tag").map(String::as_str), Some("a/b")); + assert_eq!(params.get("empty").map(String::as_str), Some("")); + } + + #[test] + fn string_map_is_converted_to_struct_value() { + let value = + string_map_to_value([("id".to_string(), "42".to_string())].into_iter().collect()); + let Value { + kind: Some(Kind::StructValue(Struct { fields })), + } = value + else { + panic!("expected struct value"); + }; + assert_eq!( + fields.get("id").and_then(|value| value.kind.as_ref()), + Some(&Kind::StringValue("42".to_string())) + ); + } + + #[test] + fn flow_input_contains_query_and_path_params() { + let flow = ActionFlow { + flow_id: 1, + project_slug: "project".to_string(), + settings: vec![FlowSetting { + database_id: None, + flow_setting_id: "http_url".to_string(), + value: Some(Value { + kind: Some(Kind::StringValue("/users/:user_id".to_string())), + }), + cast: None, + }], + ..Default::default() + }; + + let input = build_flow_input( + &flow, + "/project/users/42", + Some("search=hello+world"), + &HeaderMap::new(), + None, + ); + + assert_eq!( + nested_string_field(&input, "query_params", "search"), + Some("hello world") + ); + assert_eq!( + nested_string_field(&input, "path_params", "user_id"), + Some("42") + ); + } + + fn nested_string_field<'a>(value: &'a Value, field: &str, nested: &str) -> Option<&'a str> { + let Some(Kind::StructValue(Struct { fields })) = value.kind.as_ref() else { + return None; + }; + let Some(Value { + kind: + Some(Kind::StructValue(Struct { + fields: nested_fields, + })), + }) = fields.get(field) + else { + return None; + }; + let Some(Value { + kind: Some(Kind::StringValue(value)), + }) = nested_fields.get(nested) + else { + return None; + }; + Some(value.as_str()) + } +} diff --git a/actions/rest-action/src/main.rs b/actions/rest-action/src/main.rs new file mode 100644 index 0000000..88e88c0 --- /dev/null +++ b/actions/rest-action/src/main.rs @@ -0,0 +1,109 @@ +mod auth; +mod content_type; +mod data_types; +mod events; +mod flow_setting; +mod functions; +mod input; +mod pending; +mod response; +mod route; +mod server; +mod validation; + +use hercules::{Action, HerculesEvent, ScalingOption, Translation}; +use tokio_stream::StreamExt; + +use functions::Respond; +use pending::new_pending_responses; + +fn env(key: &str, default: &str) -> String { + std::env::var(key).unwrap_or_else(|_| default.to_string()) +} + +/// `RestAuthType`/`RestAuthValue`/`RestAdapterInput` and `RestRuntimeEvent` +/// never appear below. Attaching `#[hercules::data_type]` / +/// `#[hercules::runtime_event]` registered each of them automatically as +/// part of `Action::new` (see `hercules::registration`). `Respond` is the +/// one exception: it needs the shared `pending` map injected at +/// construction time, so it's registered by hand below instead (see its +/// `manual` attribute). +fn build_action(pending: pending::PendingResponses) -> Action { + let mut action = Action::new( + env("HERCULES_ACTION_ID", "rest-action"), + env("HERCULES_SDK_VERSION", "0.0.0"), + ) + .aquila_url(env("HERCULES_AQUILA_URL", "127.0.0.1:8081")) + // Every instance needs to be reachable to serve HTTP traffic, so every + // instance gets every flow rather than splitting them up. + .scaling(ScalingOption::Disabled) + .author("code0-tech") + .icon("tabler:world-www") + .documentation("Webhook triggered by an incoming HTTP request.") + .name([Translation::new("en-US", "Webhook")]); + + action.register_runtime_function(Respond::new(pending)); + action +} + +#[tokio::main] +async fn main() -> hercules::Result<()> { + env_logger::Builder::from_env( + env_logger::Env::default().default_filter_or("rest_action=info,hercules=info"), + ) + .init(); + + let pending = new_pending_responses(); + + let mut args = std::env::args().skip(1); + if args.next().as_deref() == Some("export") { + let dir = args + .next() + .unwrap_or_else(|| "./out/draco-rest".to_string()); + build_action(pending) + .export(&dir) + .unwrap_or_else(|err| panic!("failed to export to {dir:?}: {err}")); + println!("wrote module definitions to {dir}"); + return Ok(()); + } + + let action = build_action(pending.clone()); + let mut events = action.subscribe(); + + let aquila_url = env("HERCULES_AQUILA_URL", "127.0.0.1:8081"); + log::info!("connecting to Aquila at {aquila_url}"); + + let connected = action + .connect(env("HERCULES_AUTH_TOKEN", "value"), None) + .await + .unwrap_or_else(|err| panic!("failed to connect to Aquila: {err}")); + + let host = env("HTTP_SERVER_HOST", "0.0.0.0"); + let port = env("HTTP_SERVER_PORT", "8080"); + let addr = format!("{host}:{port}") + .parse() + .unwrap_or_else(|err| panic!("invalid HTTP_SERVER_HOST/HTTP_SERVER_PORT: {err}")); + + let execution_timeout_secs: u64 = env("HERCULES_EXECUTION_TIMEOUT_SECS", "30") + .parse() + .unwrap_or_else(|err| panic!("invalid HERCULES_EXECUTION_TIMEOUT_SECS: {err}")); + let execution_timeout = std::time::Duration::from_secs(execution_timeout_secs); + + tokio::spawn(server::serve(addr, connected, pending, execution_timeout)); + + while let Some(event) = events.next().await { + match event { + HerculesEvent::Connected => log::info!("connected to Aquila"), + HerculesEvent::Error(error) => panic!("Aquila stream error: {error}"), + HerculesEvent::FlowUpserted(flow) => { + log::info!("flow {} was created/updated", flow.flow_id); + } + HerculesEvent::FlowDeleted(flow_id) => { + log::info!("flow {flow_id} was deleted"); + } + _ => {} + } + } + + Ok(()) +} diff --git a/actions/rest-action/src/pending.rs b/actions/rest-action/src/pending.rs new file mode 100644 index 0000000..09591fc --- /dev/null +++ b/actions/rest-action/src/pending.rs @@ -0,0 +1,54 @@ +//! Correlates a `respond` function call (identified by +//! `FunctionContext::execution_id`) back to the HTTP connection that's +//! still waiting on it. See `functions.rs` (the sender side) and +//! `server.rs` (the receiver side, plus the "flow finished with no respond +//! call" -> 204 fallback). + +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use hercules::{PlainValue, Result}; +use tokio::sync::oneshot; + +pub struct RespondPayload { + pub status_code: i64, + pub content_type: String, + pub payload: PlainValue, + pub headers: HashMap, +} + +pub enum RespondSignal { + Respond(RespondPayload), + FlowFinished(Result), +} + +pub type PendingResponses = Arc>>>; + +pub fn new_pending_responses() -> PendingResponses { + Arc::new(Mutex::new(HashMap::new())) +} + +/// Registers a waiter for `execution_id`, to be fulfilled by [`take`]. +pub fn register( + pending: &PendingResponses, + execution_id: String, + tx: oneshot::Sender, +) { + lock(pending).insert(execution_id, tx); +} + +/// Removes and returns the waiter for `execution_id`, if one is still +/// registered. `None` means someone else (a `respond` call, or the flow +/// finishing) already claimed it first. +pub fn take( + pending: &PendingResponses, + execution_id: &str, +) -> Option> { + lock(pending).remove(execution_id) +} + +fn lock( + pending: &PendingResponses, +) -> std::sync::MutexGuard<'_, HashMap>> { + pending.lock().unwrap_or_else(|err| err.into_inner()) +} diff --git a/actions/rest-action/src/response.rs b/actions/rest-action/src/response.rs new file mode 100644 index 0000000..4c0b11d --- /dev/null +++ b/actions/rest-action/src/response.rs @@ -0,0 +1,149 @@ +//! Turns a `respond` call (or its absence) into the actual `hyper::Response` +//! sent back to the HTTP client. + +use http_body_util::Full; +use hyper::{ + Response, StatusCode, + body::Bytes, + header::{HeaderName, HeaderValue}, +}; +use std::collections::HashMap; + +use crate::content_type; +use crate::pending::RespondPayload; + +pub fn error_to_http_response(status: StatusCode, msg: &str) -> Response> { + let body = format!(r#"{{"error": "{}"}}"#, msg); + Response::builder() + .status(status) + .header("content-type", "application/json") + .body(Full::new(Bytes::from(body))) + .unwrap() +} + +pub fn no_content_response() -> Response> { + Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Full::new(Bytes::new())) + .unwrap() +} + +pub fn respond_payload_to_http_response(payload: RespondPayload) -> Response> { + let RespondPayload { + status_code, + content_type: header_content_type, + payload, + mut headers, + } = payload; + + if find_header_value_case_insensitive(&headers, "content-type").is_none() { + headers.insert("content-type".to_string(), header_content_type.clone()); + } + let content_type_header = find_header_value_case_insensitive(&headers, "content-type"); + + let value = tucana::shared::helper::value::from_json_value(payload); + let encoded_body = match content_type::encode_body(content_type_header, value) { + Ok(body) => body, + Err(err) => { + log::error!("failed to encode response payload: {}", err); + return error_to_http_response( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to encode response payload", + ); + } + }; + + let status = u16::try_from(status_code) + .ok() + .and_then(|code| StatusCode::from_u16(code).ok()) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + + create_http_response(status, headers, encoded_body) +} + +fn find_header_value_case_insensitive<'a>( + headers: &'a HashMap, + key: &str, +) -> Option<&'a str> { + headers + .iter() + .find(|(k, _)| k.eq_ignore_ascii_case(key)) + .map(|(_, v)| v.as_str()) +} + +fn create_http_response( + status: StatusCode, + headers: HashMap, + body: Vec, +) -> Response> { + let mut builder = Response::builder().status(status); + + { + let h = builder.headers_mut().unwrap(); + for (k, v) in headers { + let name = match HeaderName::from_bytes(k.as_bytes()) { + Ok(n) => n, + Err(_) => { + log::warn!("dropping invalid header name: {}", k); + continue; + } + }; + let value = match HeaderValue::from_str(&v) { + Ok(v) => v, + Err(_) => { + log::warn!("dropping invalid header value for {}: {:?}", k, v); + continue; + } + }; + h.insert(name, value); + } + } + + builder.body(Full::new(Bytes::from(body))).unwrap() +} + +#[cfg(test)] +mod tests { + use super::*; + use http_body_util::BodyExt; + + #[tokio::test] + async fn respond_payload_is_used_as_response() { + let payload = RespondPayload { + status_code: 201, + content_type: "application/json".to_string(), + payload: serde_json::json!("response body"), + headers: [ + ("content-type".to_string(), "text/plain".to_string()), + ("x-flow-response".to_string(), "matched".to_string()), + ] + .into_iter() + .collect(), + }; + + let response = respond_payload_to_http_response(payload); + + assert_eq!(response.status(), StatusCode::CREATED); + assert_eq!(response.headers()["content-type"], "text/plain"); + assert_eq!(response.headers()["x-flow-response"], "matched"); + assert_eq!( + response.into_body().collect().await.unwrap().to_bytes(), + "response body" + ); + } + + #[tokio::test] + async fn finished_flow_without_response_returns_no_content() { + let response = no_content_response(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert!( + response + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .is_empty() + ); + } +} diff --git a/actions/rest-action/src/route.rs b/actions/rest-action/src/route.rs new file mode 100644 index 0000000..90e9b9d --- /dev/null +++ b/actions/rest-action/src/route.rs @@ -0,0 +1,280 @@ +//! Matches an incoming HTTP request against this action's flows, and reads +//! path parameters back out of a match. Ported from draco's route matcher; +//! ownership of "which flow does this request belong to" moves from a +//! NATS-KV scan (`AdapterStore::get_possible_flow_match`) to a plain filter +//! over `Connected::flows()`. + +use std::collections::HashMap; +use tucana::aquila::ActionFlow; + +use crate::flow_setting; + +pub fn find_matching_flow( + flows: &[ActionFlow], + method: &hyper::Method, + path: &str, +) -> Option { + flows + .iter() + .find(|flow| matches_request(flow, method, path)) + .cloned() +} + +fn matches_request(flow: &ActionFlow, method: &hyper::Method, path: &str) -> bool { + let Some(flow_method) = flow_setting::as_string(flow, "http_method") else { + log::debug!( + "route reject: flow_id={} reason=missing_or_invalid_http_method", + flow.flow_id + ); + return false; + }; + + if !flow_method.eq_ignore_ascii_case(method.as_str()) { + return false; + } + + let Some(flow_http_url) = flow_setting::as_string(flow, "http_url") else { + log::debug!( + "route reject: flow_id={} reason=missing_or_invalid_http_url", + flow.flow_id + ); + return false; + }; + + let pattern = flow_route_pattern(flow, flow_http_url); + let is_match = matches_route_pattern(&pattern, path); + log::debug!( + "route check: flow_id={} pattern={:?} path={:?} matched={}", + flow.flow_id, + pattern, + path, + is_match + ); + is_match +} + +pub fn extract_path_params(flow: &ActionFlow, path: &str) -> HashMap { + let Some(flow_http_url) = flow_setting::as_string(flow, "http_url") else { + return HashMap::new(); + }; + + extract_named_route_captures(&flow_route_pattern(flow, flow_http_url), path) +} + +fn flow_route_pattern(flow: &ActionFlow, flow_http_url: &str) -> String { + format!("/{}{}", flow.project_slug, flow_http_url) +} + +fn matches_route_pattern(pattern: &str, route: &str) -> bool { + let Ok(anchored_pattern) = compile_route_pattern(pattern) else { + return false; + }; + let Ok(regex) = regex::Regex::new(&anchored_pattern) else { + return false; + }; + regex.is_match(route) +} + +fn extract_named_route_captures(pattern: &str, route: &str) -> HashMap { + let Ok(anchored_pattern) = compile_route_pattern(pattern) else { + return HashMap::new(); + }; + let Ok(regex) = regex::Regex::new(&anchored_pattern) else { + return HashMap::new(); + }; + + let Some(captures) = regex.captures(route) else { + return HashMap::new(); + }; + + regex + .capture_names() + .flatten() + .filter_map(|name| { + captures.name(name).map(|value| { + ( + name.to_string(), + percent_encoding::percent_decode_str(value.as_str()) + .decode_utf8_lossy() + .into_owned(), + ) + }) + }) + .collect() +} + +fn compile_route_pattern(pattern: &str) -> Result { + if is_url_pattern_style(pattern) { + return compile_url_pattern_path(pattern).map(|pattern| format!("^{}$", pattern)); + } + Ok(format!("^{}$", pattern)) +} + +fn is_url_pattern_style(pattern: &str) -> bool { + let bytes = pattern.as_bytes(); + bytes.iter().enumerate().any(|(index, byte)| { + (*byte == b':' + && bytes + .get(index + 1) + .is_some_and(|next| next.is_ascii_alphabetic() || *next == b'_')) + || (*byte == b'*' && bytes.get(index.wrapping_sub(1)) != Some(&b'.')) + }) +} + +fn compile_url_pattern_path(pattern: &str) -> Result { + let mut compiled = String::new(); + let chars: Vec = pattern.chars().collect(); + let mut index = 0; + + while index < chars.len() { + match chars[index] { + ':' if is_param_start(chars.get(index + 1).copied()) => { + let (name, next_index) = read_param_name(&chars, index + 1); + index = next_index; + + let (capture_pattern, next_index) = if chars.get(index) == Some(&'(') { + read_balanced_group(&chars, index)? + } else { + (String::from("[^/]+"), index) + }; + + compiled.push_str(&format!("(?P<{name}>{capture_pattern})")); + index = next_index; + } + '*' => { + compiled.push_str(".*"); + index += 1; + } + value => { + compiled.push_str(®ex::escape(&value.to_string())); + index += 1; + } + } + } + + Ok(compiled) +} + +fn is_param_start(value: Option) -> bool { + value.is_some_and(|value| value.is_ascii_alphabetic() || value == '_') +} + +fn read_param_name(chars: &[char], start: usize) -> (String, usize) { + let mut index = start; + let mut name = String::new(); + + while let Some(value) = chars.get(index) { + if value.is_ascii_alphanumeric() || *value == '_' { + name.push(*value); + index += 1; + } else { + break; + } + } + + (name, index) +} + +fn read_balanced_group(chars: &[char], start: usize) -> Result<(String, usize), String> { + let mut depth = 0; + let mut index = start; + let mut pattern = String::new(); + + while let Some(value) = chars.get(index) { + match value { + '(' => { + depth += 1; + if depth > 1 { + pattern.push(*value); + } + } + ')' => { + depth -= 1; + if depth == 0 { + return Ok((pattern, index + 1)); + } + pattern.push(*value); + } + _ => pattern.push(*value), + } + index += 1; + } + + Err(String::from("unclosed parameter regex group")) +} + +#[cfg(test)] +mod tests { + use super::{compile_route_pattern, extract_named_route_captures, matches_route_pattern}; + + #[test] + fn exact_literal_match_works() { + assert!(matches_route_pattern("test2", "test2")); + } + + #[test] + fn substring_match_is_rejected() { + assert!(!matches_route_pattern("test", "test2")); + } + + #[test] + fn dynamic_route_params_match_path_segments() { + assert!(matches_route_pattern( + "/project/users/:id", + "/project/users/42" + )); + assert!(!matches_route_pattern( + "/project/users/:id", + "/project/users/42/orders" + )); + } + + #[test] + fn dynamic_route_params_are_returned_as_path_params() { + let params = extract_named_route_captures( + "/project/books/:category/:id", + "/project/books/classics/12345", + ); + + assert_eq!(params.get("category").map(String::as_str), Some("classics")); + assert_eq!(params.get("id").map(String::as_str), Some("12345")); + } + + #[test] + fn dynamic_route_wildcards_match_remaining_path() { + assert!(matches_route_pattern( + "/project/assets/*", + "/project/assets/images/profile.jpg" + )); + } + + #[test] + fn legacy_regex_patterns_still_work() { + assert_eq!( + compile_route_pattern("/project/users/(?P[^/]+)").unwrap(), + "^/project/users/(?P[^/]+)$" + ); + assert!(matches_route_pattern( + "/project/users/(?P[^/]+)", + "/project/users/42" + )); + } + + #[test] + fn invalid_regex_returns_false() { + assert!(!matches_route_pattern("(", "/test")); + } + + #[test] + fn named_route_captures_are_percent_decoded() { + let params = extract_named_route_captures( + "/project/files/(?P[^/]+)", + "/project/files/report%202026.txt", + ); + + assert_eq!( + params.get("file_name").map(String::as_str), + Some("report 2026.txt") + ); + } +} diff --git a/actions/rest-action/src/server.rs b/actions/rest-action/src/server.rs new file mode 100644 index 0000000..8ef7537 --- /dev/null +++ b/actions/rest-action/src/server.rs @@ -0,0 +1,225 @@ +//! The inbound HTTP server: accepts connections, matches each request to +//! one of this action's flows via `Connected::flows()` (replacing draco's +//! NATS-KV-backed `AdapterStore`), runs it, and waits for either a +//! `respond` call (see `functions.rs`) or the flow's own completion (if +//! `respond` was never called -> 204) before answering the client. + +use std::convert::Infallible; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use hercules::Connected; +use http_body_util::{BodyExt, Full}; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::{ + Request, Response, StatusCode, + body::{Bytes, Incoming}, +}; +use hyper_util::rt::TokioIo; +use tokio::net::TcpListener; +use tokio::sync::oneshot; + +use crate::pending::{self, PendingResponses, RespondSignal}; +use crate::{auth, content_type, flow_setting, input, response, route, validation}; + +pub async fn serve( + addr: SocketAddr, + connected: Connected, + pending: PendingResponses, + execution_timeout: Duration, +) -> std::io::Result<()> { + let listener = TcpListener::bind(addr).await?; + log::info!("listening for webhook requests on {addr}"); + + loop { + let (stream, peer_addr) = listener.accept().await?; + let io = TokioIo::new(stream); + let connected = connected.clone(); + let pending = Arc::clone(&pending); + + tokio::spawn(async move { + let svc = service_fn(move |req| { + handle(req, connected.clone(), Arc::clone(&pending), execution_timeout) + }); + + if let Err(err) = http1::Builder::new().serve_connection(io, svc).await { + log::debug!("connection from {peer_addr} closed with error: {err:?}"); + } + }); + } +} + +async fn handle( + req: Request, + connected: Connected, + pending: PendingResponses, + execution_timeout: Duration, +) -> Result>, Infallible> { + let method = req.method().clone(); + let path = req.uri().path().to_string(); + let query = req.uri().query().map(str::to_owned); + let headers = req.headers().clone(); + + log::info!("{method} {path}: request received"); + + let body_bytes = match BodyExt::collect(req.into_body()).await { + Ok(collected) => collected.to_bytes().to_vec(), + Err(err) => { + log::error!("{method} {path}: failed to read request body: {err}"); + return Ok(response::error_to_http_response( + StatusCode::BAD_REQUEST, + "Failed to read request body", + )); + } + }; + + let flows = connected.flows(); + let Some(flow) = route::find_matching_flow(&flows, &method, &path) else { + log::info!( + "{method} {path}: no flow matched, checked {} flow(s)", + flows.len() + ); + return Ok(response::error_to_http_response( + StatusCode::NOT_FOUND, + "No flow found for path", + )); + }; + + log::info!("{method} {path}: matched flow {}", flow.flow_id); + + if let Err(err) = auth::validate_flow_auth(&flow, &headers) { + log::warn!( + "{method} {path}: flow {} rejected: {}", + flow.flow_id, + err.message() + ); + let mut response = response::error_to_http_response(err.status_code(), err.message()); + response + .headers_mut() + .insert(auth::authenticate_header_name(), err.challenge()); + return Ok(response); + } + + let request_body_value = match content_type::parse_body_from_headers(&headers, &body_bytes) { + Ok(value) => value, + Err(err) => { + log::warn!( + "{method} {path}: flow {} failed to parse request body: {err}", + flow.flow_id + ); + let status = match err { + content_type::BodyParseError::UnsupportedContentType { .. } => { + StatusCode::UNSUPPORTED_MEDIA_TYPE + } + _ => StatusCode::BAD_REQUEST, + }; + return Ok(response::error_to_http_response(status, &err.to_string())); + } + }; + + let input_schema = flow_setting::as_struct(&flow, "input_schema"); + if let Err(err) = + validation::validate_body_against_schema(input_schema, request_body_value.as_ref()) + { + log::warn!( + "{method} {path}: flow {} failed input schema validation: {err}", + flow.flow_id + ); + return Ok(response::error_to_http_response( + StatusCode::BAD_REQUEST, + &err.to_string(), + )); + } + + let flow_input = + input::build_flow_input(&flow, &path, query.as_deref(), &headers, request_body_value); + let payload = tucana::shared::helper::value::to_json_value(flow_input); + + Ok(execute_and_await_response( + connected, + pending, + flow.flow_id, + payload, + &method, + &path, + execution_timeout, + ) + .await) +} + +async fn execute_and_await_response( + connected: Connected, + pending: PendingResponses, + flow_id: i64, + payload: hercules::PlainValue, + method: &hyper::Method, + path: &str, + execution_timeout: Duration, +) -> Response> { + let execution_id = connected.reserve_execution_id(); + let (tx, rx) = oneshot::channel(); + pending::register(&pending, execution_id.clone(), tx); + + log::info!("{method} {path}: flow {flow_id} executing as {execution_id}"); + + { + let connected = connected.clone(); + let pending = Arc::clone(&pending); + let execution_id = execution_id.clone(); + tokio::spawn(async move { + let result = connected + .execute_flow_with_id(execution_id.clone(), flow_id.to_string(), payload) + .await; + // If `respond` was already called, it already claimed this + // entry, so this only fires for the "flow finished without + // ever calling respond" case. + if let Some(tx) = pending::take(&pending, &execution_id) { + let _ = tx.send(RespondSignal::FlowFinished(result)); + } + }); + } + + match tokio::time::timeout(execution_timeout, rx).await { + Err(_) => { + // Nobody claimed the entry within the deadline; remove it so a + // late `respond` call or flow completion doesn't find it and + // send into the void. + pending::take(&pending, &execution_id); + log::warn!( + "{method} {path}: flow {flow_id} (execution {execution_id}) timed out after {execution_timeout:?} waiting for a result" + ); + response::error_to_http_response(StatusCode::GATEWAY_TIMEOUT, "Flow execution timed out") + } + Ok(Ok(RespondSignal::Respond(payload))) => { + log::info!( + "{method} {path}: flow {flow_id} (execution {execution_id}) responded with status {}", + payload.status_code + ); + response::respond_payload_to_http_response(payload) + } + Ok(Ok(RespondSignal::FlowFinished(Ok(_)))) => { + log::info!( + "{method} {path}: flow {flow_id} (execution {execution_id}) finished without calling respond, answering 204" + ); + response::no_content_response() + } + Ok(Ok(RespondSignal::FlowFinished(Err(err)))) => { + log::error!("{method} {path}: flow {flow_id} (execution {execution_id}) failed: {err}"); + response::error_to_http_response( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error", + ) + } + Ok(Err(_)) => { + log::error!( + "{method} {path}: pending response for flow {flow_id} (execution {execution_id}) was dropped unexpectedly" + ); + response::error_to_http_response( + StatusCode::INTERNAL_SERVER_ERROR, + "Internal server error", + ) + } + } +} diff --git a/actions/rest-action/src/validation.rs b/actions/rest-action/src/validation.rs new file mode 100644 index 0000000..6abcf48 --- /dev/null +++ b/actions/rest-action/src/validation.rs @@ -0,0 +1,146 @@ +//! Validates a request body against a flow's `input_schema` setting (a JSON +//! Schema stored as a `shared.Struct`). Ported from draco's REST adapter +//! unchanged. + +use lupus::data::{Data, Number}; +use std::collections::BTreeMap; +use tucana::shared::{Struct, Value, helper::value::to_json_value, value::Kind}; + +#[derive(Debug)] +pub enum BodyValidationError { + InvalidSchema(String), + InvalidBody(String), + Validation(String), +} + +impl std::fmt::Display for BodyValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidSchema(msg) => write!(f, "flow input schema is invalid: {}", msg), + Self::InvalidBody(msg) => write!(f, "request body could not be validated: {}", msg), + Self::Validation(msg) => write!(f, "request body failed schema validation: {}", msg), + } + } +} + +impl std::error::Error for BodyValidationError {} + +/// A flow without an `input_schema` (or with an empty one) accepts any body +/// unvalidated. +pub fn validate_body_against_schema( + input_schema: Option<&Struct>, + body: Option<&Value>, +) -> Result<(), BodyValidationError> { + let Some(input_schema) = input_schema.filter(|schema| !schema.fields.is_empty()) else { + return Ok(()); + }; + + let schema_json = to_json_value(Value { + kind: Some(Kind::StructValue(input_schema.clone())), + }); + let raw = serde_json::to_string(&schema_json) + .map_err(|err| BodyValidationError::InvalidSchema(err.to_string()))?; + let schema = lupus::JsonSchema { raw }; + + let body_value = body.cloned().unwrap_or(Value { + kind: Some(Kind::NullValue(0)), + }); + let data = json_value_to_data(to_json_value(body_value))?; + + lupus::validation::validate_json_schema(&data, &schema) + .map_err(|err| BodyValidationError::Validation(err.to_string())) +} + +/// Mirrors lupus's internal JSON-to-`Data` conversion; can't reuse +/// `lupus::formats::json` directly since the `tucana::shared::Value` type +/// here and the one `lupus` depends on resolve to different (semver +/// incompatible 0.0.x) versions of `tucana`. +fn json_value_to_data(value: serde_json::Value) -> Result { + match value { + serde_json::Value::Null => Ok(Data::Null), + serde_json::Value::Bool(value) => Ok(Data::Bool(value)), + serde_json::Value::Number(value) => { + if let Some(value) = value.as_i64() { + Ok(Data::Number(Number::I64(value))) + } else if let Some(value) = value.as_u64() { + Ok(Data::Number(Number::U64(value))) + } else if let Some(value) = value.as_f64() { + Ok(Data::Number(Number::F64(value))) + } else { + Err(BodyValidationError::InvalidBody( + "unsupported JSON number".to_string(), + )) + } + } + serde_json::Value::String(value) => Ok(Data::String(value)), + serde_json::Value::Array(values) => values + .into_iter() + .map(json_value_to_data) + .collect::, _>>() + .map(Data::Array), + serde_json::Value::Object(fields) => fields + .into_iter() + .map(|(key, value)| Ok((key, json_value_to_data(value)?))) + .collect::, _>>() + .map(Data::Object), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn schema_struct(raw_schema: serde_json::Value) -> Struct { + let Value { + kind: Some(Kind::StructValue(schema)), + } = tucana::shared::helper::value::from_json_value(raw_schema) + else { + panic!("expected object schema"); + }; + schema + } + + #[test] + fn missing_schema_allows_any_body() { + let body = Value { + kind: Some(Kind::StringValue("anything".to_string())), + }; + assert!(validate_body_against_schema(None, Some(&body)).is_ok()); + } + + #[test] + fn empty_schema_allows_any_body() { + let schema = Struct { + fields: HashMap::new(), + }; + let body = Value { + kind: Some(Kind::StringValue("anything".to_string())), + }; + assert!(validate_body_against_schema(Some(&schema), Some(&body)).is_ok()); + } + + #[test] + fn matching_body_passes_validation() { + let schema = schema_struct(serde_json::json!({ + "type": "object", + "required": ["name"], + "properties": { "name": { "type": "string" } } + })); + let body = + tucana::shared::helper::value::from_json_value(serde_json::json!({ "name": "Ada" })); + assert!(validate_body_against_schema(Some(&schema), Some(&body)).is_ok()); + } + + #[test] + fn mismatched_body_fails_validation() { + let schema = schema_struct(serde_json::json!({ + "type": "object", + "required": ["name"], + "properties": { "name": { "type": "string" } } + })); + let body = tucana::shared::helper::value::from_json_value(serde_json::json!({ "age": 42 })); + let err = validate_body_against_schema(Some(&schema), Some(&body)).unwrap_err(); + assert!(matches!(err, BodyValidationError::Validation(_))); + } +}