diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..15e4606 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1135 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "addr2line" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a30b2e23b9e17a9f90641c7ab1549cd9b44f296d3ccbf309d2863cfe398a0cb" +dependencies = [ + "gimli", +] + +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + +[[package]] +name = "android-tzdata" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e999941b234f3131b00bc13c22d06e8c5ff726d1b6318ac7eb276997bbb4fef0" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "autocfg" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" + +[[package]] +name = "backtrace" +version = "0.3.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2089b7e3f35b9dd2d0ed921ead4f6d318c27680d4a5bd167b3ee120edb105837" +dependencies = [ + "addr2line", + "cc", + "cfg-if", + "libc", + "miniz_oxide", + "object", + "rustc-demangle", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f30e7476521f6f8af1a1c4c0b8cc94f0bee37d91763d0ca2665f299b6cd8aec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2bd12c1caf447e69cd4528f47f94d203fd2582878ecb9e9465484c4148a8223" + +[[package]] +name = "cc" +version = "1.0.83" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1174fb0b6ec23863f8b971027804a42614e347eafb0a95bf0b12cdae21fc4d0" +dependencies = [ + "libc", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "chrono" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5bc015644b92d5890fab7489e49d21f879d5c990186827d42ec511919404f38b" +dependencies = [ + "android-tzdata", + "iana-time-zone", + "num-traits", + "serde", + "windows-targets 0.52.0", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06ea2b9bc92be3c2baa9334a323ebca2d6f074ff852cd1d7b11064035cd3868f" + +[[package]] +name = "cpufeatures" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53fe5e26ff1b7aef8bca9c6080520cfb8d9333c7568e1829cef191a9723e5504" +dependencies = [ + "libc", +] + +[[package]] +name = "crypto-common" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bfb12502f3fc46cca1bb51ac28df9d618d813cdc3d2f25b9fe775a34af26bb3" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc5d6b04b3fd0ba9926f945895de7d806260a2d7431ba82e7edaecb043c4c6b8" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04e48a959bcd5c761246f5d090ebc2fbf7b9cd527a492b07a67510c108f1e7e3" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1545d67a2149e1d93b7e5c7752dce5a7426eb5d1357ddcfd89336b94444f77" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "data-encoding" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e962a19be5cfc3f3bf6dd8f61eb50107f356ad6270fbb3ed41476571db78be5" + +[[package]] +name = "deranged" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b42b6fa04a440b495c8b04d0e71b707c585f83cb9cb28cf8cd0d976c315e31b4" +dependencies = [ + "powerfmt", + "serde", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "equivalent" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5443807d6dff69373d433ab9ef5378ad8df50ca6298caf15de6e52e24aaf54d5" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13624c2627564efccf4934284bdd98cbaa14e79b0b5a141218e507b3a823456" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-core" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" + +[[package]] +name = "futures-macro" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "futures-sink" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" + +[[package]] +name = "futures-task" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" + +[[package]] +name = "futures-util" +version = "0.3.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +dependencies = [ + "futures-core", + "futures-macro", + "futures-sink", + "futures-task", + "pin-project-lite", + "pin-utils", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "gimli" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4271d37baee1b8c7e4b708028c57d816cf9d2434acb33a549475f78c181f6253" + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.14.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f1a1d9242c78d09ce40a5e87e7554ee637af1351968159f4952f028f75604" + +[[package]] +name = "hermit-abi" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0c62115964e08cb8039170eb33c1d0e2388a256930279edca206fff675f82c3" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "http" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b32afd38673a8016f7c9ae69e5af41a58f81b1d31689040f2f1959594ce194ea" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "httparse" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d897f394bad6a705d5f4104762e116a75639e470d80901eed05a860a95cb1904" + +[[package]] +name = "iana-time-zone" +version = "0.1.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7ffbb5a1b541ea2561f8c41c087286cc091e21e556a4f09a8f6cbf17b69b141" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "634d9b1461af396cad843f47fdba5597a4f9e6ddd4bfb6ff5d85028c25cb12f6" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233cf39063f058ea2caae4091bf4a3ef70a653afbc026f5c4a4135d114e3c177" +dependencies = [ + "equivalent", + "hashbrown 0.14.3", + "serde", +] + +[[package]] +name = "itoa" +version = "1.0.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1a46d1a171d865aa5f83f92695765caa047a9b4cbae2cbf37dbd613a793fd4c" + +[[package]] +name = "js-sys" +version = "0.3.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "406cda4b368d531c842222cf9d2600a9a4acce8d29423695379c6868a143a9ee" +dependencies = [ + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "lock_api" +version = "0.4.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c168f8615b12bc01f9c17e2eb0cc07dcae1940121185446edc3744920e8ef45" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5e6163cb8c49088c2c36f57875e58ccd8c87c7427f7fbd50ea6710b2f3f2e8f" + +[[package]] +name = "memchr" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "523dc4f511e55ab87b694dc30d0f820d60906ef06413f93d4d7a1385599cc149" + +[[package]] +name = "miniz_oxide" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d811f3e15f28568be3407c8e7fdb6514c1cda3cb30683f15b6a1a1dc4ea14a7" +dependencies = [ + "adler", +] + +[[package]] +name = "mio" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3d0b296e374a4e6f3c7b0a1f5a51d748a0d34c85e7dc48fc3fa9a87657fe09" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]] +name = "num-traits" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0df0e5185db44f69b44f26786fe401b6c293d1907744beaa7fa62b2e5a517a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi", + "libc", +] + +[[package]] +name = "object" +version = "0.32.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6a622008b6e321afc04970976f62ee297fdbaa6f95318ca343e3eebb9648441" +dependencies = [ + "memchr", +] + +[[package]] +name = "once_cell" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fdb12b2476b595f9358c5161aa467c2438859caa136dec86c26fdd2efe17b92" + +[[package]] +name = "parking_lot" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3742b2c103b9f06bc9fff0a37ff4912935851bee6d36f3c02bcc755bcfec228f" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c42a9226546d68acdd9c0a280d17ce19bfe27a46bf68784e4066115788d008e" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-targets 0.48.5", +] + +[[package]] +name = "percent-encoding" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3148f5046208a5d56bcfc03053e3ca6334e51da8dfb19b6cdc8b306fae3283e" + +[[package]] +name = "pin-project-lite" +version = "0.2.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8afb450f006bf6385ca15ef45d71d2288452bc3683ce2e2cacc0d18e4be60b58" + +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "proc-macro2" +version = "1.0.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2422ad645d89c99f8f3e6b88a9fdeca7fabeac836b1002371c4367c8f984aae" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "redox_syscall" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4722d768eff46b75989dd134e5c353f0d6296e5aaa3132e776cbdb56be7731aa" +dependencies = [ + "bitflags", +] + +[[package]] +name = "rustc-demangle" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d626bb9dae77e28219937af045c257c28bfd3f69333c512553507f5f9798cb76" + +[[package]] +name = "ryu" +version = "1.0.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98d2aa92eebf49b69786be48e4477826b256916e84a57ff2a4f21923b48eb4c" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.196" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "870026e60fa08c69f064aa766c10f10b1d62db9ccd4d0abb206472bee0ce3b32" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.196" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33c85360c95e7d137454dc81d9a4ed2b8efd8fbe19cee57357b32b9771fccb67" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.113" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69801b70b1c3dac963ecb03a364ba0ceda9cf60c71cfe475e99864759c8b8a79" +dependencies = [ + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15d167997bd841ec232f5b2b8e0e26606df2e7caa4c31b95ea9ca52b200bd270" +dependencies = [ + "base64", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.2.3", + "serde", + "serde_derive", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "865f9743393e638991566a8b7a479043c2c8da94a33e0a31f18214c9cae0a64d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8229b473baa5980ac72ef434c4415e70c4b5e71b423043adb4ba059f89c99a1" +dependencies = [ + "libc", +] + +[[package]] +name = "slab" +version = "0.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f92a496fb766b417c996b9c5e57daf2f7ad3b0bebe1ccfca4856390e3d3bb67" +dependencies = [ + "autocfg", +] + +[[package]] +name = "smallvec" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6ecd384b10a64542d77071bd64bd7b231f4ed5940fba55e98c3de13824cf3d7" + +[[package]] +name = "socket2" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b5fac59a5cb5dd637972e5fca70daf0523c9067fcdc4842f053dae04a18f8e9" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "syn" +version = "2.0.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f3531638e407dfc0814761abb7c00a5b54992b849452a0646b7f65c9f770f3f" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "thiserror" +version = "1.0.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e45bcbe8ed29775f228095caf2cd67af7a4ccf756ebff23a306bf3e8b47b24b" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a953cb265bef375dae3de6663da4d3804eee9682ea80d8e2542529b73c531c81" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "time" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8248b6521bb14bc45b4067159b9b6ad792e2d6d754d6c41fb50e29fefe38749" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef927ca75afb808a4d64dd374f00a2adf8d0fcff8e7b184af886c3c87ec4a3f3" + +[[package]] +name = "time-macros" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ba3a3ef41e6672a2f0f001392bb5dcd3ff0a9992d618ca761a11c3121547774" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinyvec" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87cc5ceb3875bb20c2890005a4e226a4651264a5c75edb2421b52861a0a0cb50" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61285f6515fa018fb2d1e46eb21223fff441ee8db5d0f1435e8ab4f5cdb80931" +dependencies = [ + "backtrace", + "bytes", + "libc", + "mio", + "num_cpus", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a1e28f2deaa14e508979454cb3a223b10b938b45af148bc0986de36f1923b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c83b561d025642014097b66e6c1bb422783339e0909e4429cde4749d1990bc38" +dependencies = [ + "futures-util", + "log", + "tokio", + "tungstenite", +] + +[[package]] +name = "tungstenite" +version = "0.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ef1a641ea34f399a848dea702823bbecfb4c486f911735368f1f137cb8257e1" +dependencies = [ + "byteorder", + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand", + "sha1", + "thiserror", + "url", + "utf-8", +] + +[[package]] +name = "typenum" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42ff0bf0c66b8238c6f3b578df37d0b7848e55df8577b3f74f92a69acceeb825" + +[[package]] +name = "unicode-bidi" +version = "0.3.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08f95100a766bf4f8f28f90d77e0a5461bbdb219042e7679bebe79004fed8d75" + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "unicode-normalization" +version = "0.1.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c5713f0fc4b5db668a2ac63cdb7bb4469d8c9fed047b1d0292cc7b0ce2ba921" +dependencies = [ + "tinyvec", +] + +[[package]] +name = "url" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e6302e3bb753d46e83516cae55ae196fc0c309407cf11ab35cc51a4c2a4633" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "version_check" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49874b5167b65d7193b8aba1567f5c7d93d001cafc34600cee003eda787e483f" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "wasm-bindgen" +version = "0.2.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1e124130aee3fb58c5bdd6b639a0509486b0338acaaae0c84a5124b0f588b7f" +dependencies = [ + "cfg-if", + "wasm-bindgen-macro", +] + +[[package]] +name = "wasm-bindgen-backend" +version = "0.2.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e7e1900c352b609c8488ad12639a311045f40a35491fb69ba8c12f758af70b" +dependencies = [ + "bumpalo", + "log", + "once_cell", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b30af9e2d358182b5c7449424f017eba305ed32a7010509ede96cdc4696c46ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "642f325be6301eb8107a83d12a8ac6c1e1c54345a7ef1a9261962dfefda09e66" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-backend", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f186bd2dcf04330886ce82d6f33dd75a7bfcf69ecf5763b89fcde53b6ac9838" + +[[package]] +name = "windows-core" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9" +dependencies = [ + "windows-targets 0.52.0", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a18201040b24831fbb9e4eb208f8892e1f50a37feb53cc7ff887feb8f50e7cd" +dependencies = [ + "windows_aarch64_gnullvm 0.52.0", + "windows_aarch64_msvc 0.52.0", + "windows_i686_gnu 0.52.0", + "windows_i686_msvc 0.52.0", + "windows_x86_64_gnu 0.52.0", + "windows_x86_64_gnullvm 0.52.0", + "windows_x86_64_msvc 0.52.0", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7764e35d4db8a7921e09562a0304bf2f93e0a51bfccee0bd0bb0b666b015ea" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbaa0368d4f1d2aaefc55b6fcfee13f41544ddf36801e793edbbfd7d7df075ef" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28637cb1fa3560a16915793afb20081aba2c92ee8af57b4d5f28e4b3e7df313" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffe5e8e31046ce6230cc7215707b816e339ff4d4d67c65dffa206fd0f7aa7b9a" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d6fa32db2bc4a2f5abeacf2b69f7992cd09dca97498da74a151a3132c26befd" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a657e1e9d3f514745a572a6846d3c7aa7dbe1658c056ed9c3344c4109a6949e" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dff9641d1cd4be8d1a070daf9e3773c5f67e78b4d9d42263020c057706765c04" + +[[package]] +name = "ws-mock" +version = "0.0.1" +dependencies = [ + "futures-util", + "serde_json", + "serde_with", + "tokio", + "tokio-tungstenite", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..faede84 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "ws-mock" +version = "0.0.1" +edition = "2021" +authors = ["Brendan Blanchard"] +description = "A websocket mock test server, inspired by Wiremock." +license = "MIT" +repository = "https://github.com/Brendan-Blanchard/ws-mock" +exclude = [".idea", ".gitignore", ".github"] +keywords = ["tokio", "async", "websocket", "mock", "test"] + + +[dependencies] +futures-util = "0.3.30" +tokio-tungstenite = "0.21.0" +tokio = { version = "1.33.0", features= ["full"] } +serde_with = { version = "3.4.0", features = ["chrono"] } +serde_json = "1.0.113" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..7e1c50d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Brendan Blanchard + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3bb11f4 --- /dev/null +++ b/README.md @@ -0,0 +1,58 @@ +# ws-mock +A mock server for websocket testing with the ability to match arbitrarily on received messages and respond accordingly. + +![badge](https://github.com/Brendan-Blanchard/ws-mock/actions/workflows/main.yml/badge.svg)[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) + +WS-Mock allows you to set up a mockserver to test websocket code against, with dynamic responses based on the received +data. + +# Example: Responding to a Heartbeat Message +Using the `JsonExact` matcher, `WsMock` will match on messages that contain exactly the same json as it was given, +and respond with the string "heartbeat". + +Many mocks can be mounted on a single `WsMockServer`, and calling `server.verify().await` will check that every mock's +expectations were met by incoming messages. Any failures will cause a panic, detailing what messages were seen and +expected. + +Either `.respond_with(...)` or `expect(...)` is required for a mock, since mounting a mock that does not respond or +expect any calls will have no discernible effects. This produces a panic if a `WsMock` is mounted without a response or +expected number of calls. It's also perfectly valid to `.expect(0)` calls to a mock if verifying a certain type of data +was never received. + +```rust +use futures_util::{SinkExt, StreamExt}; +use serde_json::json; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Message; +use ws_mock_test::matchers::JsonExact; +use ws_mock_test::ws_mock_server::{WSMock, WSMockServer}; + +#[tokio::main] +pub async fn main() { + let expected_json = json!({"message": "heartbeat"}); + let json_msg = serde_json::to_string(&expected_json).expect("Failed to serialize message"); + + let server = WSMockServer::start().await; + + WsMock::new() + .matcher(JsonExact::new(expected_json)) + .respond_with("heartbeat".to_string()) + .expect(1) + .mount(&server) + .await; + + let (stream, _resp) = connect_async(server.uri().await) + .await + .expect("Connecting failed"); + + let (mut send, _recv) = stream.split(); + + send.send(Message::from(json_msg)).await.unwrap(); + + server.verify().await; +} +``` + +# Contributions +Please reach out or submit a PR! Particularly if you have new general purpose `Matcher` implementations that would +benefit users. \ No newline at end of file diff --git a/examples/any_match.rs b/examples/any_match.rs new file mode 100644 index 0000000..7f38cc0 --- /dev/null +++ b/examples/any_match.rs @@ -0,0 +1,36 @@ +use futures_util::{SinkExt, StreamExt}; +use std::time::Duration; +use tokio::time::timeout; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Message; +use ws_mock::matchers::Any; +use ws_mock::ws_mock_server::{WsMock, WsMockServer}; + +#[tokio::main] +pub async fn main() { + let server = WsMockServer::start().await; + + WsMock::new() + .matcher(Any::new()) + .respond_with("Hello World".to_string()) + .expect(1) + .mount(&server) + .await; + + let (stream, _resp) = connect_async(server.uri().await) + .await + .expect("Connecting failed"); + + let (mut send, mut recv) = stream.split(); + + send.send(Message::from("some message")).await.unwrap(); + + let mut received = Vec::new(); + + while let Ok(Some(Ok(message))) = timeout(Duration::from_millis(100), recv.next()).await { + received.push(message.to_string()); + } + + server.verify().await; + assert_eq!(vec!["Hello World"], received); +} diff --git a/examples/json_match.rs b/examples/json_match.rs new file mode 100644 index 0000000..f2a4112 --- /dev/null +++ b/examples/json_match.rs @@ -0,0 +1,42 @@ +use futures_util::{SinkExt, StreamExt}; +use serde_json::json; +use std::time::Duration; +use tokio::time::timeout; +use tokio_tungstenite::connect_async; +use tokio_tungstenite::tungstenite::Message; +use ws_mock::matchers::JsonExact; +use ws_mock::ws_mock_server::{WsMock, WsMockServer}; + +#[tokio::main] +pub async fn main() { + let expected_json = json!({"message": "heartbeat"}); + let json_msg = serde_json::to_string(&expected_json).expect("Failed to serialize message"); + + let server = WsMockServer::start().await; + + WsMock::new() + .matcher(JsonExact::new(expected_json)) + .respond_with("heartbeat".to_string()) + .expect(1) + .mount(&server) + .await; + + let (stream, _resp) = connect_async(server.uri().await) + .await + .expect("Connecting failed"); + + let (mut send, mut recv) = stream.split(); + + send.send(Message::from(json_msg)).await.unwrap(); + + let mut received = Vec::new(); + + while let Ok(Some(Ok(message))) = timeout(Duration::from_millis(100), recv.next()).await { + received.push(message.to_string()); + } + + server.verify().await; + assert_eq!(vec!["heartbeat"], received); + + server.verify().await; +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..9334211 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,13 @@ +//! A simple websocket mock framework heavily inspired by [`Wiremock`] in Rust. +//! +//! Ws-Mock is meant to provide a simple framework for expecting, verifying, and responding to +//! messages for tests. +//! +//! [`Wiremock`]: https://docs.rs/wiremock/latest/wiremock/ + +/// A common trait and useful implementations for matching against received messages. +pub mod matchers; + +/// The mock server implementation that handles `WsMock`s, expecting, verifying, and responding to +/// messages. +pub mod ws_mock_server; diff --git a/src/matchers.rs b/src/matchers.rs new file mode 100644 index 0000000..c41f8dd --- /dev/null +++ b/src/matchers.rs @@ -0,0 +1,424 @@ +/// A common [`Matcher`] trait and useful implementations for matching on JSON data. +use serde_json::{Map, Value}; +use std::fmt::Debug; +use std::mem::discriminant; + +/// An implementable trait accepted by [`WsMock`], allowing extension for arbitrary matching. +/// +/// Users of this crate can implement any logic required for matching, so long as the implementation +/// is Send + Sync for Tokio async and thread safety. +/// +/// [`WsMock`]: crate::ws_mock_server::WsMock +pub trait Matcher: Send + Sync + Debug { + fn matches(&self, text: &str) -> bool; +} + +/// Matches on every message it sees. This will rarely be used in combination +/// with other matchers, since it will respond to all messages. +#[derive(Debug)] +pub struct Any {} + +impl Any { + pub fn new() -> Any { + Any {} + } +} + +impl Default for Any { + fn default() -> Self { + Self::new() + } +} + +impl Matcher for Any { + fn matches(&self, _: &str) -> bool { + true + } +} + +/// Matches on arbitrary logic provided by a closure. +/// +/// For anything you can fit in an `fn(&str) -> bool` closure, this is a great option to avoid +/// having to create your own custom matcher for some on-the-fly matching that's needed. +/// +/// Several provided matchers like [`Any`], [`StringExact`], and [`StringContains`] are easily +/// expressed as closures for [`AnyThat`], but are provided explicitly for clarity and convenience. +/// +/// # Example: Matching on Any i64-Parseable Message +/// ``` +/// use std::str::FromStr; +/// use ws_mock::matchers::{AnyThat, Matcher}; +/// +/// let matcher = AnyThat::new(|text| i64::from_str(text).is_ok()); +/// let matching_message = "42"; +/// let non_number_message = "..."; +/// let non_matching_message = "42.000001"; +/// +/// assert!(matcher.matches(matching_message)); +/// assert!(!matcher.matches(non_number_message)); +/// assert!(!matcher.matches(non_matching_message)); +/// ``` +/// +/// # Example: Function Pointers +/// Rust allows regular functions to be referred to via function pointers using the same [`fn`] +/// syntax, allowing for more complex logic than you may want to express in an in-line closure. +/// Merely for example, the same closure used above can be expressed as: +/// ``` +/// use std::str::FromStr; +/// use ws_mock::matchers::{AnyThat, Matcher}; +/// +/// fn parses_to_i64(text: &str) -> bool { +/// i64::from_str(text).is_ok() +/// } +/// +/// let matcher = AnyThat::new(parses_to_i64); +/// +/// let matching_message = "42"; +/// let non_matching_message = "42.000001"; +/// +/// assert!(matcher.matches(matching_message)); +/// assert!(!matcher.matches(non_matching_message)); +/// ``` +/// [`fn`]: https://doc.rust-lang.org/std/primitive.fn.html +#[derive(Debug)] +pub struct AnyThat { + f: fn(&str) -> bool, +} + +impl AnyThat { + pub fn new(f: fn(&str) -> bool) -> AnyThat { + AnyThat { f } + } +} + +impl Matcher for AnyThat { + fn matches(&self, text: &str) -> bool { + (self.f)(text) + } +} + +/// Matches on any message containing a given string. +/// +/// # Example: Matching Any Message Content +/// ``` +/// use ws_mock::matchers::{Matcher, StringContains}; +/// +/// let matcher = StringContains::new("data"); +/// let matching_message = "anything with data in it"; +/// let non_matching_message = "anything but 'd-a-t-a'"; +/// +/// assert!(matcher.matches(matching_message)); +/// assert!(!matcher.matches(non_matching_message)); +/// ``` +#[derive(Debug)] +pub struct StringContains<'a> { + string: &'a str, +} + +impl<'a> StringContains<'a> { + pub fn new(string: &'a str) -> Self { + Self { string } + } +} + +impl<'a> Matcher for StringContains<'a> { + fn matches(&self, text: &str) -> bool { + text.contains(self.string) + } +} + +/// Matches on exact string messages. +#[derive(Debug)] +pub struct StringExact<'a> { + string: &'a str, +} + +impl<'a> StringExact<'a> { + pub fn new(string: &'a str) -> Self { + Self { string } + } +} + +impl<'a> Matcher for StringExact<'a> { + fn matches(&self, text: &str) -> bool { + text == self.string + } +} + +/// Matches on exact JSON data. This will be most useful when the exact contents +/// of a message are important for matching, and any failure to match should cause an error. +/// +/// # Example +/// ``` +/// use serde_json::json; +/// use ws_mock::matchers::{JsonExact, Matcher}; +/// +/// let matching_data = r#"{ "data": 42 }"#; +/// let non_matching_data = r#"{ "data": 0 }"#; +/// +/// let expected = json!({"data": 42}); +/// +/// let matcher = JsonExact::new(expected); +/// +/// assert!(matcher.matches(matching_data)); +/// assert!(!matcher.matches(non_matching_data)); +/// ``` +#[derive(Debug)] +pub struct JsonExact { + json: Value, +} + +impl JsonExact { + pub fn new(json: Value) -> Self { + JsonExact { json } + } +} + +impl Matcher for JsonExact { + fn matches(&self, text: &str) -> bool { + let json: Value = serde_json::from_str(text).expect("Message failed to deserialize"); + json == self.json + } +} + +/// Matches on JSON patterns, useful for matching on all messages that have a +/// certain field, or matching data of only some type. +/// +/// [`JsonPartial`] takes a `serde_json` [`Value`], and will match on anything that exhibits the same +/// structure and matches all primitives and arrays given by the pattern. Objects are matched recursively, +/// meaning that any keys present in the pattern must be present and compare equally to those in the +/// object being matched, but the object being matched can have other keys not present in the pattern. +/// +/// # Example: Matching by Message Type +/// Matching on only data with a particular field (without caring about additional data) can be useful +/// for matching messages of a particular type, such as disregarding heartbeats or metadata to +/// focus on data messages only. +/// ``` +/// use serde_json::json; +/// use ws_mock::matchers::{JsonExact, JsonPartial, Matcher}; +/// +/// let heartbeat = r#"{"type": "heartbeat"}"#; +/// let data = r#"{"type": "data", "data": [0, 1, 0]}"#; +/// let metadata = r#"{"type": "metadata", "data": "details"}"#; +/// +/// let pattern = json!({"type": "data"}); +/// let matcher = JsonPartial::new(pattern); +/// +/// assert!(!matcher.matches(heartbeat)); +/// assert!(matcher.matches(data)); +/// assert!(!matcher.matches(metadata)); +/// +/// +/// ``` +/// ['Value']: https://docs.rs/serde_json/latest/serde_json/value/enum.Value.html +#[derive(Debug)] +pub struct JsonPartial { + pattern: Value, +} + +impl JsonPartial { + pub fn new(pattern: Value) -> Self { + JsonPartial { pattern } + } + + fn match_json(data: &Value, pattern: &Value) -> bool { + if discriminant(data) == discriminant(pattern) { + match pattern { + Value::Null => data.is_null(), + Value::Bool(b) => *b == data.as_bool().unwrap(), + Value::Number(n) => Some(n) == data.as_number(), + Value::String(s) => Some(s.as_str()) == data.as_str(), + Value::Array(a) => Some(a) == data.as_array(), + Value::Object(o) => Self::match_object(data.as_object().unwrap(), o), + } + } else { + false + } + } + + fn match_object(data: &Map, pattern: &Map) -> bool { + pattern.keys().all(|k| { + data.contains_key(k) && Self::match_json(data.get(k).unwrap(), pattern.get(k).unwrap()) + }) + } +} + +impl Matcher for JsonPartial { + fn matches(&self, text: &str) -> bool { + let json: Value = serde_json::from_str(text).expect("Message failed to deserialize"); + Self::match_json(&json, &self.pattern) + } +} + +#[cfg(test)] +mod tests { + use crate::matchers::{ + Any, AnyThat, JsonExact, JsonPartial, Matcher, StringContains, StringExact, + }; + use serde_json; + use serde_json::{json, Value}; + use std::str::FromStr; + + #[test] + fn any_matches_anything() { + // ::default() is same as ::new() + let matcher = Any::default(); + + assert!(matcher.matches("")); + assert!(matcher.matches("[42]")); + assert!(matcher.matches("AnyText")); + } + + #[test] + fn string_contains() { + let matcher = StringContains::new("heartbeat"); + let matching_message = "heartbeats keep websockets alive"; + let non_matching_message = "typos don't match: heatbeat"; + + assert!(matcher.matches(matching_message)); + assert!(!matcher.matches(non_matching_message)); + } + + #[test] + fn string_exact() { + let matcher = StringExact::new("typos"); + let matching_message = "typos"; + let non_matching_message = "typo"; + let non_matching_message_2 = "typographical issue"; + + assert!(matcher.matches(matching_message)); + assert!(!matcher.matches(non_matching_message)); + assert!(!matcher.matches(non_matching_message_2)); + } + + #[test] + fn any_that() { + let matcher = AnyThat::new(|text| text.contains(' ')); + let matching_message = "contains spaces"; + let non_matching_message = "doesNotContainSpaces"; + + assert!(matcher.matches(matching_message)); + assert!(!matcher.matches(non_matching_message)); + } + + #[test] + fn any_that_parses_to_i64() { + let matcher = AnyThat::new(|text| i64::from_str(text).is_ok()); + let matching_message = "42"; + let invalid_message = "..."; + let non_matching_message = "42.000001"; + + assert!(matcher.matches(matching_message)); + assert!(!matcher.matches(invalid_message)); + assert!(!matcher.matches(non_matching_message)); + } + + #[test] + fn json_exact_matches_only_exact_json() { + let expected_json = json!(["A", "B"]); + let unexpected_json = json!(["A", "B", "Z"]); + let serialized_expected = serde_json::to_string(&expected_json).unwrap(); + let serialized_unexpected = serde_json::to_string(&unexpected_json).unwrap(); + + let matcher = JsonExact::new(expected_json); + + assert!(matcher.matches(serialized_expected.as_str())); + assert!(!matcher.matches(serialized_unexpected.as_str())); + } + + #[test] + #[should_panic(expected = "Message failed to deserialize")] + fn json_exact_fails_on_invalid_data() { + let expected_json = json!({}); + let matcher = JsonExact::new(expected_json); + + matcher.matches("-"); + } + + #[test] + #[should_panic(expected = "Message failed to deserialize")] + fn json_partial_fails_on_invalid_data() { + let expected_json = json!({}); + let matcher = JsonPartial::new(expected_json); + + matcher.matches("-"); + } + + #[test] + fn json_partial_null() { + let data = json!(null); + let other = json!("someString"); + + assert_partial_matching_and_non_matching(&data, &other); + } + + #[test] + fn json_partial_string() { + let data = json!("someString"); + let other = json!("someOtherString"); + + assert_partial_matching_and_non_matching(&data, &other); + } + + #[test] + fn json_partial_number() { + let data = json!(42); + let other = json!(17); + + assert_partial_matching_and_non_matching(&data, &other); + } + + #[test] + fn json_partial_bool() { + let data = json!(true); + let other = json!(false); + + assert_partial_matching_and_non_matching(&data, &other); + } + + #[test] + fn json_partial_arrays() { + let data = json!([1, 2, 3]); + let other = json!([1, 2, 3, 4]); + + assert_partial_matching_and_non_matching(&data, &other); + } + + #[test] + fn json_partial_object_matching() { + let data = json!({"a": 0, "b": [1, 2]}); + let matching_pattern = json!({"a": 0}); + + let matcher = JsonPartial::new(matching_pattern.clone()); + + let serialized_exact = serde_json::to_string(&matching_pattern).unwrap(); + let serialized_partial = serde_json::to_string(&data).unwrap(); + + assert!(matcher.matches(&serialized_exact)); + assert!(matcher.matches(&serialized_partial)); + } + + #[test] + fn json_partial_object_non_matching() { + let data = json!({"a": 0, "b": [1, 2]}); + let non_matching_pattern = json!({"a": 0, "c": 1}); + + let matcher = JsonPartial::new(non_matching_pattern.clone()); + + let serialized_exact = serde_json::to_string(&non_matching_pattern).unwrap(); + let serialized_unexpected = serde_json::to_string(&data).unwrap(); + + assert!(matcher.matches(&serialized_exact)); + assert!(!matcher.matches(&serialized_unexpected)); + } + + fn assert_partial_matching_and_non_matching(data: &Value, other: &Value) { + let matcher = JsonPartial::new(data.clone()); + + let serialized_expected = serde_json::to_string(&data).unwrap(); + let serialized_unexpected = serde_json::to_string(&other).unwrap(); + + assert!(matcher.matches(&serialized_expected)); + assert!(!matcher.matches(&serialized_unexpected)); + } +} diff --git a/src/ws_mock_server.rs b/src/ws_mock_server.rs new file mode 100644 index 0000000..095d1c9 --- /dev/null +++ b/src/ws_mock_server.rs @@ -0,0 +1,412 @@ +use crate::matchers::Matcher; +use futures_util::{SinkExt, StreamExt}; +use std::sync::Arc; +use std::time::Duration; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::{Notify, RwLock}; +use tokio::time::sleep; +use tokio_tungstenite::accept_async; +use tokio_tungstenite::tungstenite::Message; + +const INCOMPLETE_MOCK_PANIC: &str = "A mock must have a response or expected number of calls. Add `.expect(...)` or `.respond_with(...)` before mounting the mock."; + +/// An individual mock that matches on one or more matchers, and expects a particular number of +/// calls and/or responds with configured data. +/// +/// Each [`WsMock`] can have many [`Matcher`]s added to it before mounting it to a [`WsMockServer`], +/// and will only respond if all the added matchers match successfully. A mock must either have an +/// expected number of calls, or respond with data before being mounted. +/// +/// Mocks *must* be mounted to a [WsMockServer] to have any effect! Failing to call +/// `server.verify().await` will also erroneously pass tests, as this is how the server is told to +/// verify that all mocks were called as expected. If you rely only on the response from the server +/// as part of a test and have no `.expect(...)` call, the call to `.verify()` can be omitted. +/// +/// # Example +/// The below [WsMock] will match on any incoming data and respond with "Hello World". In this case, +/// it expects no messages, since we don't send it any. +/// +/// ``` +/// use ws_mock::matchers::Any; +/// use ws_mock::ws_mock_server::{WsMock, WsMockServer}; +/// +/// #[tokio::main] +/// async fn main() -> () { +/// let server = WsMockServer::start().await; +/// +/// WsMock::new() +/// .matcher(Any::new()) +/// .respond_with("Hello World".to_string()) +/// .expect(0) +/// .mount(&server) +/// .await; +/// +/// server.verify().await; +/// } +/// ``` +#[derive(Debug)] +pub struct WsMock { + matchers: Vec>, + response_data: Option, + expected_calls: Option, + calls: usize, +} + +impl Default for WsMock { + fn default() -> Self { + Self::new() + } +} + +impl WsMock { + pub fn new() -> WsMock { + WsMock { + matchers: Vec::new(), + response_data: None, + expected_calls: None, + calls: 0, + } + } + + /// Add a [Matcher] to this [WsMock] instance. + /// + /// All attached matchers must match for this mock to respond with any data or record a call. + pub fn matcher(mut self, matcher: T) -> Self { + self.matchers.push(Box::new(matcher)); + self + } + + /// Respond with a message, if/when all attached matchers match on a message. + pub fn respond_with(mut self, data: String) -> Self { + self.response_data = Some(data); + self + } + + /// Expect for this mock to be matched against `n` times. + /// + /// Calling `server.verify().await` will panic if this mock did not match accordingly. + pub fn expect(mut self, n: usize) -> Self { + self.expected_calls = Some(n); + self + } + + /// Mount this mock to an instance of [WsMockServer] + /// + /// Mounting a mock without having called `.respond_with(...)`, or `.expect(...)` will panic, + /// since the mock by definition has no effect. + pub async fn mount(self, server: &WsMockServer) { + if self.response_data.is_none() && self.expected_calls.is_none() { + panic!("{}", INCOMPLETE_MOCK_PANIC); + } + + let mut state = server.state.write().await; + state.mount(self); + } + + /// Check if all attached [Matcher]s match on the given text, used to determine if the server + /// should log a call and respond with data (if set). + #[doc(hidden)] + fn matches_all(&self, text: &str) -> bool { + self.matchers.iter().all(|m| m.matches(text)) + } +} + +/// The internal state of the mock, generally passed around as `Arc>`. +/// +/// `ready_notify` allows for a server in a different task/thread to communicate its readiness. +#[doc(hidden)] +struct MockHandle { + connection_string: String, + ready_notify: Arc, + mocks: Vec, + calls: Vec, +} + +impl MockHandle { + pub fn new(url: String, port: u16, notify: Arc) -> MockHandle { + MockHandle { + connection_string: format!("{}:{}", url, port), + ready_notify: notify, + mocks: Vec::new(), + calls: Vec::new(), + } + } + + /// Mount a [WsMock] to the server's internal state. + pub fn mount(&mut self, mock: WsMock) { + self.mocks.push(mock); + } +} + +/// A mock server that exposes a uri and accepts connections. +/// +/// Once mocks are mounted to a [WsMockServer], if matched against, they will log calls and respond +/// according to their configuration. +/// +/// # Example: Creating and Matching +/// Here we start a [WsMockServer], create a [WsMock] that will match on any incoming messages and +/// respond with "Hello World", and mount it to the server. Once mounted, any messages sent to the +/// server will trigger a response and be recorded. +/// +/// ```rust +/// use futures_util::{SinkExt, StreamExt}; +/// use std::time::Duration; +/// use tokio::time::timeout; +/// use tokio_tungstenite::connect_async; +/// use tokio_tungstenite::tungstenite::Message; +/// use ws_mock::matchers::Any; +/// use ws_mock::ws_mock_server::{WsMock, WsMockServer}; +/// +/// #[tokio::main] +/// pub async fn main() { +/// let server = WsMockServer::start().await; +/// +/// WsMock::new() +/// .matcher(Any::new()) +/// .respond_with("Hello World".to_string()) +/// .expect(1) +/// .mount(&server) +/// .await; +/// +/// let (stream, _resp) = connect_async(server.uri().await) +/// .await +/// .expect("Connecting failed"); +/// +/// let (mut send, mut recv) = stream.split(); +/// +/// send.send(Message::from("some message")).await.unwrap(); +/// +/// let mut received = Vec::new(); +/// +/// // this times out and continues after receiving one response from the server +/// while let Ok(Some(Ok(message))) = timeout(Duration::from_millis(100), recv.next()).await { +/// received.push(message.to_string()); +/// } +/// +/// server.verify().await; +/// assert_eq!(vec!["Hello World"], received); +/// } +/// ``` +pub struct WsMockServer { + state: Arc>, +} + +impl WsMockServer { + /// Start the server on a random port assigned by the operating system. + /// + /// This creates a new internal state object, starts the server as a task, and waits for the + /// handler to signal readiness before returning the server to the caller. + pub async fn start() -> WsMockServer { + let ready_notify = Arc::new(Notify::new()); + let state = Arc::new(RwLock::new(MockHandle::new( + "127.0.0.1".to_string(), + 0, + ready_notify.clone(), + ))); + + let server = WsMockServer::new(state.clone()); + + tokio::spawn(async move { Self::listen(state).await }); + + ready_notify.notified().await; + + server + } + + /// Create a new instance using the given state. + #[doc(hidden)] + fn new(state: Arc>) -> WsMockServer { + WsMockServer { state } + } + + /// Returns the ip address and port of the server as `format!("{}:{}", ip, port)` + pub async fn get_connection_string(&self) -> String { + let state = self.state.read().await; + state.connection_string.clone() + } + + /// Returns the uri necessary for a client to connect to this mock server instance. + pub async fn uri(&self) -> String { + format!("ws://{}", self.get_connection_string().await) + } + + /// Using the provided state, listen and accept connections. + /// + /// This is static to avoid any ownership issues, with the expectation that the caller has + /// cloned `state` if they have other uses for it. + #[doc(hidden)] + async fn listen(state: Arc>) { + let listener = Self::get_listener(state.clone()).await; + + if let Ok((stream, _peer)) = listener.accept().await { + let state = state.clone(); + tokio::spawn(WsMockServer::handle_connection(stream, state)); + } + } + + /// Creates the TcpListener needed to accept connections. Once connected, it signals readiness + /// via the `ready_notify` instance on the provided state before returning. + #[doc(hidden)] + async fn get_listener(state: Arc>) -> TcpListener { + let mut state = state.write().await; + let listener = TcpListener::bind(state.connection_string.as_str()) + .await + .expect("Failed to listen to port"); + + let listener_addr = listener + .local_addr() + .expect("Listener had no local address"); + + // may connect using 0 to get automatic port from OS + // re-assign the real port that was bound + state.connection_string = format!("{}:{}", listener_addr.ip(), listener_addr.port()); + + state.ready_notify.notify_one(); + + listener + } + + /// Handles a single connection using the provided `TcpStream` and `MockHandle`. + /// + /// This is responsible for checking if mocks match, and updating any call counts or responding + /// with configured data. + #[doc(hidden)] + async fn handle_connection(stream: TcpStream, state: Arc>) { + let ws_stream = accept_async(stream) + .await + .expect("Failed to accept connection"); + + let (mut send, mut recv) = ws_stream.split(); + + while let Some(Ok(msg)) = recv.next().await { + let text = msg.to_text().expect("Message was not text").to_string(); + println!("Received: '{:?}'", text); + let mut state_guard = state.write().await; + + state_guard.calls.push(text.clone()); + + for mock in &mut state_guard.mocks { + if mock.matches_all(&text) { + mock.calls += 1; + if let Some(data) = &mock.response_data { + send.send(Message::text(data)).await.unwrap(); + } + } + } + } + } + + /// Verify the status of all mocks, and panic if expectations have not been met. + /// + /// This must be called in order for mock expectations to be verified. Failure to do so, if not + /// also relying on messages sent by the server to verify behavior, will result in faulty tests. + pub async fn verify(&self) { + sleep(Duration::from_millis(100)).await; + let state_guard = self.state.read().await; + + let mut results = Vec::new(); + + for mock in &state_guard.mocks { + if let Some(expected) = mock.expected_calls { + if expected != mock.calls { + results.push(format!( + "Expected {} matching calls, but received {}\nCalled With:", + expected, mock.calls + )); + } + } + } + + if !results.is_empty() { + for mock_call in &state_guard.calls { + results.push(format!("\t{}", mock_call)); + } + panic!("{}", results.join("\n")); + } + } +} + +#[cfg(test)] +mod tests { + use crate::matchers::Any; + use crate::ws_mock_server::{WsMock, WsMockServer}; + use futures_util::stream::SplitStream; + use futures_util::{SinkExt, StreamExt}; + use std::time::Duration; + use tokio::net::TcpStream; + use tokio::time::timeout; + use tokio_tungstenite::tungstenite::Message; + use tokio_tungstenite::{connect_async, MaybeTlsStream, WebSocketStream}; + + #[tokio::test] + async fn test_wss_mockserver() { + let server = WsMockServer::start().await; + + WsMock::new() + .matcher(Any::new()) + // no response given is okay + .expect(1) + .mount(&server) + .await; + + // ::default() is same as ::new() + WsMock::default() + .matcher(Any::new()) + .respond_with("Mock-2".to_string()) + .expect(1) + .mount(&server) + .await; + + let mut recv = send_to_server(&server, "{ data: [42] }".into()).await; + + let mut received = Vec::new(); + + while let Ok(Some(Ok(message))) = timeout(Duration::from_millis(250), recv.next()).await { + received.push(message.to_string()); + } + + server.verify().await; + assert_eq!(vec!["Mock-2"], received); + } + + #[should_panic(expected = "Expected 2 matching calls, but received 1\nCalled With:\n\t{}")] + #[tokio::test] + async fn test_ws_mockserver_verify_failure() { + let server = WsMockServer::start().await; + + WsMock::new() + .matcher(Any::new()) + .respond_with("Mock-1".to_string()) + .expect(2) + .mount(&server) + .await; + + let _recv = send_to_server(&server, "{}".into()).await; + server.verify().await; + } + + #[should_panic( + expected = "A mock must have a response or expected number of calls. Add `.expect(...)` or `.respond_with(...)` before mounting the mock." + )] + #[tokio::test] + async fn test_incomplete_mock_failure() { + let server = WsMockServer::start().await; + + WsMock::new().matcher(Any::new()).mount(&server).await; + } + + async fn send_to_server( + server: &WsMockServer, + message: String, + ) -> SplitStream>> { + let (stream, _resp) = connect_async(server.uri().await) + .await + .expect("Connecting failed"); + + let (mut send, recv) = stream.split(); + + send.send(Message::from(message)).await.unwrap(); + + recv + } +}