diff --git a/Cargo.lock b/Cargo.lock index c219695..55154b2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -56,7 +56,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -67,7 +67,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -110,12 +110,24 @@ dependencies = [ "syn 3.0.5", ] +[[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 = "base64" +version = "0.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" + [[package]] name = "benshi" version = "0.1.0" @@ -217,6 +229,10 @@ name = "benshi-sync" version = "0.1.0" dependencies = [ "benshi-core", + "reqwest", + "rustls", + "serde", + "serde_json", "thiserror", "tokio", ] @@ -319,12 +335,49 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "combine" +version = "4.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfc320937d09e6de266b31b9afb480f197d7a861be86be7cb2ea7e5d1bfffc5e" +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 = "core_detect" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" +[[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.5", +] + [[package]] name = "encoding_rs" version = "0.8.41" @@ -380,7 +433,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -433,6 +486,24 @@ 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 = "futures-channel" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.34" @@ -476,6 +547,17 @@ dependencies = [ "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.4.3" @@ -526,12 +608,213 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[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.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +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 = "humantime" version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" +[[package]] +name = "hyper" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfa8e654703247911e29c23fbeaa261834bd9bb74efba2f9acddc37bfb127f53" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "httparse", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "icu_collections" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_properties" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" + +[[package]] +name = "icu_provider" +version = "2.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" +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.2" @@ -542,6 +825,12 @@ dependencies = [ "hashbrown 0.17.1", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -554,6 +843,55 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[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 = "js-sys" version = "0.3.105" @@ -588,6 +926,18 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" + +[[package]] +name = "log" +version = "0.4.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + [[package]] name = "memchr" version = "2.8.3" @@ -611,7 +961,7 @@ checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "wasi", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -653,6 +1003,12 @@ 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 = "ordered-stream" version = "0.2.0" @@ -669,6 +1025,12 @@ version = "2.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -681,6 +1043,15 @@ version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" +[[package]] +name = "potential_utf" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" +dependencies = [ + "zerovec", +] + [[package]] name = "proc-macro-crate" version = "3.5.0" @@ -743,6 +1114,54 @@ version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" +[[package]] +name = "reqwest" +version = "0.13.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" +dependencies = [ + "base64", + "bytes", + "futures-core", + "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", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[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 = "rsqlite-vfs" version = "0.1.1" @@ -768,6 +1187,15 @@ dependencies = [ "sqlite-wasm-rs", ] +[[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" @@ -778,7 +1206,80 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a073f5dc7469f984c52ad2752b63b0807745133b6de880b7b64c1ac4c48aec4" +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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1167586491e2b18b8bfbb293e8180ec17c201c4f076d7cb3070ca964e7598f98" +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.61.2", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" + +[[package]] +name = "rustls-webpki" +version = "0.103.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", ] [[package]] @@ -787,12 +1288,59 @@ 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 = "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", + "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" @@ -863,6 +1411,16 @@ dependencies = [ "libc", ] +[[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" @@ -888,7 +1446,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -903,12 +1461,24 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[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" @@ -931,6 +1501,26 @@ dependencies = [ "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.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -938,10 +1528,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -964,6 +1554,16 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "tinystr" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.13.3" @@ -984,7 +1584,7 @@ dependencies = [ "socket2", "tokio-macros", "tracing", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -998,6 +1598,16 @@ dependencies = [ "syn 3.0.5", ] +[[package]] +name = "tokio-rustls" +version = "0.26.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9cc2678c2cdd569ef8215e2afd7954ada2ae20b4fdd2c5fe6139a3b02d105db" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -1028,6 +1638,51 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "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" @@ -1059,6 +1714,12 @@ 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 = "uds_windows" version = "1.2.1" @@ -1067,7 +1728,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys", + "windows-sys 0.61.2", ] [[package]] @@ -1085,6 +1746,30 @@ dependencies = [ "tinyvec", ] +[[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" @@ -1108,6 +1793,25 @@ version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec4cdd0dd910afe868b7ef477227d8d538b46b3075031afee8a9f2acb0a2ed0b" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1127,6 +1831,16 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.78" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ef4c5d3d2cdf5c54f4231181768f5510842e350db025faf1f7163b1030ed928" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.128" @@ -1159,12 +1873,49 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "web-sys" +version = "0.3.105" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9fbddc4a036f00ec4f18c83445bd3115cb306a91da554919a099d9222fe4a7f8" +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.61.2", +] + [[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" @@ -1174,6 +1925,70 @@ 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 = "winnow" version = "1.0.4" @@ -1183,6 +1998,35 @@ dependencies = [ "memchr", ] +[[package]] +name = "writeable" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", + "synstructure", +] + [[package]] name = "zbus" version = "5.19.0" @@ -1206,7 +2050,7 @@ dependencies = [ "tracing", "uds_windows", "uuid", - "windows-sys", + "windows-sys 0.61.2", "winnow", "zbus_macros", "zbus_names", @@ -1248,6 +2092,66 @@ dependencies = [ "serde", ] +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" + +[[package]] +name = "zerotrie" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + [[package]] name = "zmij" version = "1.0.23" diff --git a/crates/core/src/list.rs b/crates/core/src/list.rs index e428147..e078fdc 100644 --- a/crates/core/src/list.rs +++ b/crates/core/src/list.rs @@ -27,7 +27,7 @@ pub struct Entry { /// Every other spelling the service gives of the title. pub spellings: Vec, /// What kind of release the work is, and none where the service does - /// not say. + /// not say or names one this build does not know. pub format: Option, /// How many episodes the work has, and none where the service holds no /// count. diff --git a/crates/store/src/list.rs b/crates/store/src/list.rs index 5195caa..865a6b0 100644 --- a/crates/store/src/list.rs +++ b/crates/store/src/list.rs @@ -291,7 +291,11 @@ mod tests { #[test] fn a_list_filed_is_listed_as_it_was_filed() { let (_home, mut store) = a_store(); - let list = [a_series(21, "Show Title"), a_film(437), a_work_to_come(9)]; + let list = [ + a_series(21, "Show Title"), + a_film(112_151), + a_work_to_come(9), + ]; store.file(BACKEND, &list).expect("the list is filed"); @@ -333,7 +337,7 @@ mod tests { // entry's. let (_home, mut store) = a_store(); store - .file(BACKEND, &[a_series(21, "Show Title"), a_film(437)]) + .file(BACKEND, &[a_series(21, "Show Title"), a_film(112_151)]) .expect("the list is filed"); store @@ -356,7 +360,7 @@ mod tests { [ Value::Integer(2), Value::Text(BACKEND.to_owned()), - Value::Integer(437) + Value::Integer(112_151) ], ] ); @@ -366,7 +370,7 @@ mod tests { #[test] fn an_entry_that_comes_back_to_the_list_is_the_show_it_was() { let (_home, mut store) = a_store(); - let list = [a_series(21, "Show Title"), a_film(437)]; + let list = [a_series(21, "Show Title"), a_film(112_151)]; store.file(BACKEND, &list).expect("the list is filed"); let first = store.listed(BACKEND).expect("the list is read"); store @@ -384,7 +388,11 @@ mod tests { #[test] fn filing_the_same_list_twice_changes_nothing() { let (_home, mut store) = a_store(); - let list = [a_series(21, "Show Title"), a_film(437), a_work_to_come(9)]; + let list = [ + a_series(21, "Show Title"), + a_film(112_151), + a_work_to_come(9), + ]; store.file(BACKEND, &list).expect("the list is filed"); let once = every_row(&store); @@ -493,7 +501,7 @@ mod tests { .expect("the list is filed"); let before = every_row(&store); - let refused = store.file(BACKEND, &[a_film(437), a_film(437)]); + let refused = store.file(BACKEND, &[a_film(112_151), a_film(112_151)]); assert!( matches!( @@ -511,14 +519,14 @@ mod tests { let (_home, mut store) = a_store(); store - .file(BACKEND, &[a_film(437)]) + .file(BACKEND, &[a_film(112_151)]) .expect("the list is filed"); assert_eq!( rows(&store, "list_entries"), [[ Value::Text(BACKEND.to_owned()), - Value::Integer(437), + Value::Integer(112_151), Value::Text("[]".to_owned()), Value::Text("film".to_owned()), Value::Integer(1), diff --git a/crates/sync/Cargo.toml b/crates/sync/Cargo.toml index 80ecd94..3624336 100644 --- a/crates/sync/Cargo.toml +++ b/crates/sync/Cargo.toml @@ -10,6 +10,10 @@ repository.workspace = true [dependencies] benshi-core = { path = "../core" } +reqwest = { version = "0.13.4", default-features = false, features = ["rustls-no-provider"] } +rustls = { version = "0.23.43", default-features = false, features = ["ring", "std", "tls12"] } +serde = { version = "1.0.229", features = ["derive"] } +serde_json = "1.0.151" thiserror = "2.0.21" tokio = { version = "1.53.1", features = ["time"] } @@ -17,4 +21,4 @@ tokio = { version = "1.53.1", features = ["time"] } workspace = true [dev-dependencies] -tokio = { version = "1.53.1", features = ["macros", "rt", "test-util", "time"] } +tokio = { version = "1.53.1", features = ["io-util", "macros", "net", "rt", "test-util", "time"] } diff --git a/crates/sync/src/anilist/mod.rs b/crates/sync/src/anilist/mod.rs new file mode 100644 index 0000000..f7bb393 --- /dev/null +++ b/crates/sync/src/anilist/mod.rs @@ -0,0 +1,957 @@ +//! AniList: an account's list over its GraphQL API. + +mod query; +pub mod transport; + +use std::collections::HashSet; +use std::sync::{Mutex, PoisonError}; +use std::time::Duration; + +use benshi_core::list::{Entry, EntryId, Format, Rating, Status}; +use serde::de::DeserializeOwned; +use serde_json::{Value, json}; +use tokio::time::Instant; + +use crate::{Error, within}; +use query::{Envelope, List, ListData, ListEntry, ListStatus, Title, ViewerData}; +use transport::{Answer, Request, Transport}; + +/// How long the service is given to answer one request. +pub const DEADLINE: Duration = Duration::from_secs(15); + +/// How far apart two requests are sent. +pub const PACE: Duration = Duration::from_secs(2); + +/// The wait a refusal for too many requests is read as where it does not +/// say: the one-minute timeout AniList's documentation gives for going past +/// its rate limit. +pub const WAIT: Duration = Duration::from_secs(60); + +/// How far past the wait a refusal for too many requests asks for the next +/// request is sent, as the user's importer of lists waits. +const MARGIN: Duration = Duration::from_secs(1); + +/// The account a token is of. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Account { + /// AniList's number for the user. + pub id: i64, + /// The user's name. + pub name: String, +} + +/// An account's list on AniList. +#[derive(Debug)] +pub struct AniList { + transport: T, + next: Mutex>, +} + +impl AniList { + /// A backend that sends its requests through `transport`. + #[must_use] + pub fn new(transport: T) -> Self { + Self { + transport, + next: Mutex::new(None), + } + } + + /// The account the token is of. + /// + /// # Errors + /// + /// An [`Error`] of the class the failure of the service is. + pub async fn account(&self) -> Result { + let data: ViewerData = self.ask(query::VIEWER, json!({})).await?; + + Ok(Account { + id: data.viewer.id, + name: data.viewer.name, + }) + } + + /// Every entry of the account's list of anime. + /// + /// The list is asked for the account the token is of, a chunk at a time. + /// A work on several of the account's lists is read once, from the list + /// of its status where it is on one, and else from a list of the + /// account's own making. + /// + /// # Errors + /// + /// An [`Error`] of the class the failure of the service is, and + /// [`Error::Unreadable`] for an entry with no title, a score past a + /// hundred, or a chunk that holds no entry and says more follow. + pub async fn list(&self) -> Result, Error> { + let account = self.account().await?; + let mut entries = Vec::new(); + let mut read = HashSet::new(); + for chunk in 1_u32.. { + let data: ListData = self + .ask(query::LIST, json!({ "user": account.id, "chunk": chunk })) + .await?; + let held: usize = data + .collection + .lists + .iter() + .map(|list| list.entries.len()) + .sum(); + if held == 0 && data.collection.has_next_chunk { + return Err(Error::Unreadable( + format!("chunk {chunk} holds no entry and says more follow").into(), + )); + } + let (of_status, own): (Vec, Vec) = data + .collection + .lists + .into_iter() + .partition(|list| !list.is_custom_list); + for list in of_status.into_iter().chain(own) { + for entry in list.entries { + if read.insert(entry.media_id) { + entries.push(entry_of(entry)?); + } + } + } + if !data.collection.has_next_chunk { + break; + } + } + + Ok(entries) + } + + /// Send one request in its turn, and read its answer. + /// + /// A refusal for too many requests is answered as a wait and not waited + /// out here, and no request is sent again before the wait and a second + /// past it have passed. + async fn ask(&self, query: &str, variables: Value) -> Result { + tokio::time::sleep_until(self.turn()).await; + let request = Request { + query: query.to_owned(), + variables, + }; + let answer = within(DEADLINE, self.transport.send(&request)).await?; + if answer.status == 429 { + self.hold_off(answer.retry_after.unwrap_or(WAIT) + MARGIN); + } + + read(&answer) + } + + /// Send no request before this long has passed. + fn hold_off(&self, wait: Duration) { + let mut next = self.next.lock().unwrap_or_else(PoisonError::into_inner); + let until = Instant::now() + wait; + *next = Some(next.map_or(until, |next| next.max(until))); + } + + /// When the next request may be sent, with the turn after it kept for + /// the request after it, so that requests made at once go out in turn. + fn turn(&self) -> Instant { + let mut next = self.next.lock().unwrap_or_else(PoisonError::into_inner); + let now = Instant::now(); + let turn = next.map_or(now, |next| next.max(now)); + *next = Some(turn + PACE); + + turn + } +} + +/// What an answer holds, or the failure it is. +/// +/// A refusal for too many requests is a wait, and a status of 500 or above +/// is a failure on the side of the service. Any other answer is read: an +/// error it carries is a refusal in the service's words, and its data is what +/// was asked for. +fn read(answer: &Answer) -> Result { + let status = answer.status; + if status == 429 { + return Err(Error::Wait { + after: answer.retry_after.unwrap_or(WAIT), + }); + } + if status >= 500 { + return Err(Error::Unreachable(format!("HTTP {status}").into())); + } + let unreadable = + |cause: &dyn std::fmt::Display| Error::Unreadable(format!("HTTP {status}: {cause}").into()); + let envelope: Envelope = + serde_json::from_str(&answer.body).map_err(|cause| unreadable(&cause))?; + if let Some(problem) = envelope.errors.into_iter().next() { + return Err(Error::Refused(problem.message)); + } + let data = envelope + .data + .ok_or_else(|| unreadable(&"no data and no error"))?; + + serde_json::from_value(data).map_err(|cause| unreadable(&cause)) +} + +/// An entry as the list reads it. +/// +/// The title is the one the account prefers, and else the first of the +/// romaji, the English and the native; the spellings are the others and the +/// synonyms, each once. A work is released unless the service says it is +/// not yet. +fn entry_of(entry: ListEntry) -> Result { + let id = EntryId(entry.media_id); + let unreadable = |what: String| Error::Unreadable(format!("work {} {what}", id.0).into()); + let media = entry.media; + let Title { + user_preferred, + romaji, + english, + native, + } = media.title; + let title = user_preferred + .or_else(|| romaji.clone()) + .or_else(|| english.clone()) + .or_else(|| native.clone()) + .ok_or_else(|| unreadable("has no title".to_owned()))?; + let mut spellings: Vec = Vec::new(); + for spelling in [romaji, english, native] + .into_iter() + .flatten() + .chain(media.synonyms.unwrap_or_default()) + { + if spelling != title && !spellings.contains(&spelling) { + spellings.push(spelling); + } + } + let score = match entry.score { + None | Some(0) => None, + Some(points) => Some( + Rating::new(points) + .ok_or_else(|| unreadable(format!("has a score of {points}, past a hundred")))?, + ), + }; + + Ok(Entry { + id, + title, + spellings, + format: media.format.as_deref().and_then(format_of), + episodes: media.episodes, + episode_length: media + .duration + .map(|minutes| Duration::from_mins(u64::from(minutes))), + year: media + .season_year + .or(media.start_date.and_then(|date| date.year)), + released: media.status.as_deref() != Some("NOT_YET_RELEASED"), + status: status_of(entry.status), + progress: entry.progress.unwrap_or(0), + rewatches: entry.repeat.unwrap_or(0), + score, + }) +} + +/// The format AniList names, and none for one this build does not know. +fn format_of(said: &str) -> Option { + match said { + "TV" => Some(Format::Tv), + "TV_SHORT" => Some(Format::TvShort), + "MOVIE" => Some(Format::Film), + "SPECIAL" => Some(Format::Special), + "OVA" => Some(Format::Ova), + "ONA" => Some(Format::Ona), + "MUSIC" => Some(Format::Music), + _ => None, + } +} + +/// Where the account stands, as AniList's status says it. +fn status_of(said: ListStatus) -> Status { + match said { + ListStatus::Current => Status::Watching, + ListStatus::Planning => Status::Planned, + ListStatus::Completed => Status::Completed, + ListStatus::Dropped => Status::Dropped, + ListStatus::Paused => Status::OnHold, + ListStatus::Repeating => Status::Rewatching, + } +} + +#[cfg(test)] +mod tests { + use std::collections::VecDeque; + use std::sync::{Mutex, PoisonError}; + use std::time::Duration; + + use benshi_core::list::{Entry, EntryId, Format, Rating, Status}; + use serde_json::{Value, json}; + use tokio::time::Instant; + + use super::query::ViewerData; + use super::transport::{Answer, Request, Transport}; + use super::{Account, AniList, DEADLINE, PACE, WAIT, read}; + use crate::Error; + + /// A service that answers what it was given to answer, in order, and + /// keeps what it was sent and when. + #[derive(Default)] + struct Script { + /// Each answer in turn, and none for a request never answered. + answers: Mutex>>, + sent: Mutex>, + } + + impl Script { + fn answering(answers: impl IntoIterator>) -> Self { + Self { + answers: Mutex::new(answers.into_iter().collect()), + sent: Mutex::default(), + } + } + + fn sent(&self) -> Vec<(Instant, Request)> { + self.sent + .lock() + .unwrap_or_else(PoisonError::into_inner) + .clone() + } + } + + impl Transport for Script { + async fn send(&self, request: &Request) -> Result { + self.sent + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push((Instant::now(), request.clone())); + let answer = self + .answers + .lock() + .unwrap_or_else(PoisonError::into_inner) + .pop_front() + .expect("the script has an answer for every request"); + match answer { + Some(answer) => Ok(answer), + None => std::future::pending().await, + } + } + } + + fn an_answer(status: u16, body: &Value) -> Answer { + Answer { + status, + retry_after: None, + body: body.to_string(), + } + } + + /// The test account, as `Viewer` answered for it. + fn the_viewer() -> Answer { + an_answer( + 200, + &json!({ "data": { "Viewer": { "id": 8_344_475, "name": "bentest" } } }), + ) + } + + /// A list of one chunk holding these entries. + fn a_list(entries: &[Value]) -> Answer { + a_chunk(entries, false) + } + + fn a_chunk(entries: &[Value], more: bool) -> Answer { + an_answer( + 200, + &json!({ "data": { "MediaListCollection": { + "hasNextChunk": more, + "lists": [{ "isCustomList": false, "entries": entries }], + } } }), + ) + } + + /// An entry of a series of twelve, as AniList answers one. + fn a_series_entry() -> Value { + json!({ + "mediaId": 16_498, + "status": "PAUSED", + "progress": 3, + "repeat": 1, + "score": 80, + "media": { + "format": "TV", + "episodes": 12, + "duration": 24, + "status": "FINISHED", + "seasonYear": 2009, + "startDate": { "year": 2009 }, + "title": { + "userPreferred": "Show Title", + "romaji": "Show Title", + "english": "The Show Title", + "native": "Show Native Title" + }, + "synonyms": ["Show Title Spelt Otherwise", "The Show Title"] + } + }) + } + + /// The entry of the series, as the list reads it. + fn the_series() -> Entry { + Entry { + id: EntryId(16_498), + title: "Show Title".to_owned(), + spellings: vec![ + "The Show Title".to_owned(), + "Show Native Title".to_owned(), + "Show Title Spelt Otherwise".to_owned(), + ], + format: Some(Format::Tv), + episodes: Some(12), + episode_length: Some(Duration::from_mins(24)), + year: Some(2009), + released: true, + status: Status::OnHold, + progress: 3, + rewatches: 1, + score: Rating::new(80), + } + } + + /// The series' entry with one field of its media replaced. + fn with_media(field: &str, value: Value) -> Value { + let mut entry = a_series_entry(); + entry["media"][field] = value; + entry + } + + async fn the_list( + answers: impl IntoIterator>, + ) -> Result, Error> { + AniList::new(Script::answering( + std::iter::once(Some(the_viewer())).chain(answers), + )) + .list() + .await + } + + /// The one entry a list of one answers. + async fn the_one_entry(entry: Value) -> Entry { + let list = the_list([Some(a_list(&[entry]))]) + .await + .expect("the list is read"); + let [entry] = list.as_slice() else { + panic!("one entry was answered, and {list:?} was read") + }; + entry.clone() + } + + #[test] + fn a_refusal_for_too_many_requests_is_the_wait_the_service_asked_for() { + let answer = Answer { + retry_after: Some(Duration::from_secs(30)), + ..an_answer( + 429, + &json!({ "errors": [{ "message": "Too Many Requests." }] }), + ) + }; + + let read = read::(&answer); + + assert!( + matches!(read, Err(Error::Wait { after }) if after == Duration::from_secs(30)), + "got {read:?}" + ); + } + + #[test] + fn a_refusal_for_too_many_requests_that_names_no_wait_is_a_minute() { + let read = read::(&an_answer(429, &json!({}))); + + assert!( + matches!(read, Err(Error::Wait { after }) if after == WAIT), + "got {read:?}" + ); + assert_eq!(WAIT, Duration::from_mins(1)); + } + + #[test] + fn a_failure_on_the_side_of_the_service_is_out_of_reach() { + for status in [500, 502, 503, 599] { + let read = read::(&an_answer(status, &json!({}))); + + assert!( + matches!(&read, Err(Error::Unreachable(cause)) if cause.to_string() == format!("HTTP {status}")), + "{status} got {read:?}" + ); + } + } + + #[test] + fn a_token_the_service_does_not_take_is_refused_in_its_words() { + // The answer the service gave a token that is not one. + let answer = an_answer( + 400, + &json!({ "data": null, "errors": [{ "message": "Invalid token", "status": 400 }] }), + ); + + let read = read::(&answer); + + assert!( + matches!(&read, Err(Error::Refused(said)) if said == "Invalid token"), + "got {read:?}" + ); + } + + #[test] + fn a_request_with_no_token_is_refused_in_its_words() { + // The answer the service gave `Viewer` with no token. + let answer = an_answer( + 401, + &json!({ + "errors": [{ "message": "Unauthorized.", "status": 401, + "locations": [{ "line": 1, "column": 9 }] }], + "data": { "Viewer": null } + }), + ); + + let read = read::(&answer); + + assert!( + matches!(&read, Err(Error::Refused(said)) if said == "Unauthorized."), + "got {read:?}" + ); + } + + #[test] + fn an_error_in_an_answer_that_succeeded_is_a_refusal() { + // AniList's documentation: an error can arrive with HTTP 200. + let answer = an_answer( + 200, + &json!({ "data": null, "errors": [{ "message": "Something went wrong." }] }), + ); + + let read = read::(&answer); + + assert!( + matches!(&read, Err(Error::Refused(said)) if said == "Something went wrong."), + "got {read:?}" + ); + } + + #[test] + fn an_answer_that_is_not_json_is_unreadable_and_says_its_status() { + let answer = Answer { + status: 404, + retry_after: None, + body: "Not Found".to_owned(), + }; + + let read = read::(&answer); + + assert!( + matches!(&read, Err(Error::Unreadable(cause)) if cause.to_string().starts_with("HTTP 404: ")), + "got {read:?}" + ); + } + + #[test] + fn an_answer_with_neither_data_nor_errors_is_unreadable() { + let read = read::(&an_answer(200, &json!({ "data": null }))); + + assert!( + matches!(&read, Err(Error::Unreadable(cause)) if cause.to_string() == "HTTP 200: no data and no error"), + "got {read:?}" + ); + } + + #[tokio::test(start_paused = true)] + async fn the_account_is_the_one_the_token_is_of() { + let anilist = AniList::new(Script::answering([Some(the_viewer())])); + + let account = anilist.account().await; + + assert_eq!( + account.expect("the account is read"), + Account { + id: 8_344_475, + name: "bentest".to_owned() + } + ); + let sent = anilist.transport.sent(); + assert_eq!(sent.len(), 1); + assert_eq!(sent[0].1.query, super::query::VIEWER); + } + + #[tokio::test(start_paused = true)] + async fn a_first_request_is_sent_at_once() { + let started = Instant::now(); + let anilist = AniList::new(Script::answering([Some(the_viewer())])); + + anilist.account().await.expect("the account is read"); + + assert_eq!(anilist.transport.sent()[0].0, started); + } + + #[tokio::test(start_paused = true)] + async fn requests_one_after_another_are_sent_two_seconds_apart() { + let anilist = AniList::new(Script::answering([Some(the_viewer()), Some(the_viewer())])); + + anilist.account().await.expect("the account is read"); + anilist.account().await.expect("the account is read again"); + + let sent = anilist.transport.sent(); + assert_eq!(sent[1].0 - sent[0].0, PACE); + assert_eq!(PACE, Duration::from_secs(2)); + } + + #[tokio::test(start_paused = true)] + async fn requests_made_at_once_are_sent_two_seconds_apart() { + let anilist = AniList::new(Script::answering([ + Some(the_viewer()), + Some(the_viewer()), + Some(the_viewer()), + ])); + + let (first, second, third) = + tokio::join!(anilist.account(), anilist.account(), anilist.account()); + + for answer in [first, second, third] { + answer.expect("the account is read"); + } + let mut times: Vec = anilist.transport.sent().iter().map(|(at, _)| *at).collect(); + times.sort_unstable(); + assert_eq!(times[1] - times[0], PACE); + assert_eq!(times[2] - times[1], PACE); + } + + #[tokio::test(start_paused = true)] + async fn a_request_after_a_quiet_spell_is_sent_at_once_and_the_pace_kept_after_it() { + // The third request is what tells the pace kept from the pace + // forgotten: a turn in the past is a turn at once either way. + let anilist = AniList::new(Script::answering([ + Some(the_viewer()), + Some(the_viewer()), + Some(the_viewer()), + ])); + anilist.account().await.expect("the account is read"); + tokio::time::sleep(Duration::from_secs(10)).await; + let asked = Instant::now(); + + anilist.account().await.expect("the account is read again"); + anilist + .account() + .await + .expect("the account is read a third time"); + + let sent = anilist.transport.sent(); + assert_eq!(sent[1].0, asked); + assert_eq!(sent[2].0 - sent[1].0, PACE); + } + + /// A refusal for too many requests that asks for this long a wait. + fn too_many(after: u64) -> Answer { + Answer { + retry_after: Some(Duration::from_secs(after)), + ..an_answer( + 429, + &json!({ "errors": [{ "message": "Too Many Requests." }] }), + ) + } + } + + #[tokio::test(start_paused = true)] + async fn a_refusal_for_too_many_requests_is_answered_and_not_waited_out() { + // Whoever made the call decides when to make it again. + let anilist = AniList::new(Script::answering([Some(too_many(30))])); + let started = Instant::now(); + + let account = anilist.account().await; + + assert!( + matches!(account, Err(Error::Wait { after }) if after == Duration::from_secs(30)), + "got {account:?}" + ); + assert_eq!(anilist.transport.sent().len(), 1); + assert_eq!(started.elapsed(), Duration::ZERO); + } + + #[tokio::test(start_paused = true)] + async fn the_request_after_a_refusal_for_too_many_waits_a_second_past_the_wait() { + let anilist = AniList::new(Script::answering([Some(too_many(30)), Some(the_viewer())])); + anilist + .account() + .await + .expect_err("the service asks for a wait"); + + anilist.account().await.expect("the account is read"); + + let sent = anilist.transport.sent(); + assert_eq!(sent[1].0 - sent[0].0, Duration::from_secs(31)); + } + + #[tokio::test(start_paused = true)] + async fn a_service_that_never_answers_is_a_timeout_of_fifteen_seconds() { + let anilist = AniList::new(Script::answering([None])); + let started = Instant::now(); + + // Bounded at twice the deadline, so that a request with none fails the + // test rather than leaving a paused clock nothing to move to. + let account = tokio::time::timeout(DEADLINE * 2, anilist.account()) + .await + .expect("the request ends within twice its deadline"); + + assert!( + matches!(account, Err(Error::Timeout { deadline }) if deadline == DEADLINE), + "got {account:?}" + ); + assert_eq!(started.elapsed(), DEADLINE); + assert_eq!(DEADLINE, Duration::from_secs(15)); + } + + #[tokio::test(start_paused = true)] + async fn the_list_is_read_for_the_account_the_token_is_of() { + let script = Script::answering([Some(the_viewer()), Some(a_list(&[a_series_entry()]))]); + let anilist = AniList::new(script); + + let list = anilist.list().await; + + assert_eq!(list.expect("the list is read"), [the_series()]); + let sent = anilist.transport.sent(); + assert_eq!(sent.len(), 2); + assert_eq!(sent[0].1.query, super::query::VIEWER); + assert_eq!(sent[1].1.query, super::query::LIST); + assert_eq!( + sent[1].1.variables, + json!({ "user": 8_344_475, "chunk": 1 }) + ); + } + + #[tokio::test(start_paused = true)] + async fn a_list_in_chunks_is_read_whole() { + let film = json!({ + "mediaId": 112_151, "status": "COMPLETED", "progress": 1, "repeat": 0, "score": 0, + "media": { "format": "MOVIE", "episodes": 1, "duration": 95, "status": "FINISHED", + "seasonYear": 1988, "startDate": { "year": 1988 }, + "title": { "userPreferred": "A Film", "romaji": "A Film", + "english": null, "native": null }, + "synonyms": [] } + }); + let script = Script::answering([ + Some(the_viewer()), + Some(a_chunk(&[a_series_entry()], true)), + Some(a_chunk(&[film], false)), + ]); + let anilist = AniList::new(script); + + let list = anilist.list().await.expect("the list is read"); + + let ids: Vec = list.iter().map(|entry| entry.id).collect(); + assert_eq!(ids, [EntryId(16_498), EntryId(112_151)]); + let sent = anilist.transport.sent(); + assert_eq!( + sent[2].1.variables, + json!({ "user": 8_344_475, "chunk": 2 }) + ); + } + + #[tokio::test(start_paused = true)] + async fn a_chunk_that_holds_nothing_and_says_more_follow_is_unreadable() { + // Followed, such a chunk would be asked for again and again for as + // long as the service said so, and the reading would never end. + let script = Script::answering([ + Some(the_viewer()), + Some(a_chunk(&[a_series_entry()], true)), + Some(a_chunk(&[], true)), + ]); + let anilist = AniList::new(script); + + let list = anilist.list().await; + + assert!( + matches!(&list, Err(Error::Unreadable(cause)) if cause.to_string() == "chunk 2 holds no entry and says more follow"), + "got {list:?}" + ); + assert_eq!(anilist.transport.sent().len(), 3); + } + + #[tokio::test(start_paused = true)] + async fn an_entry_is_read_with_every_fact_the_service_gives() { + assert_eq!(the_one_entry(a_series_entry()).await, the_series()); + } + + #[tokio::test(start_paused = true)] + async fn what_the_service_does_not_give_is_none() { + let entry = json!({ + "mediaId": 9, "status": "PLANNING", "progress": null, "repeat": null, "score": null, + "media": { "format": null, "episodes": null, "duration": null, + "status": "NOT_YET_RELEASED", "seasonYear": null, "startDate": null, + "title": { "userPreferred": "A Work To Come", "romaji": null, + "english": null, "native": null }, + "synonyms": null } + }); + + assert_eq!( + the_one_entry(entry).await, + Entry { + id: EntryId(9), + title: "A Work To Come".to_owned(), + spellings: Vec::new(), + format: None, + episodes: None, + episode_length: None, + year: None, + released: false, + status: Status::Planned, + progress: 0, + rewatches: 0, + score: None, + } + ); + } + + #[tokio::test(start_paused = true)] + async fn each_status_is_read_as_its_own() { + let every = [ + ("CURRENT", Status::Watching), + ("PLANNING", Status::Planned), + ("COMPLETED", Status::Completed), + ("DROPPED", Status::Dropped), + ("PAUSED", Status::OnHold), + ("REPEATING", Status::Rewatching), + ]; + + for (said, status) in every { + let mut entry = a_series_entry(); + entry["status"] = json!(said); + + assert_eq!(the_one_entry(entry).await.status, status, "{said}"); + } + } + + #[tokio::test(start_paused = true)] + async fn a_status_this_build_does_not_know_is_unreadable() { + let mut entry = a_series_entry(); + entry["status"] = json!("BINGING"); + + let list = the_list([Some(a_list(&[entry]))]).await; + + assert!(matches!(list, Err(Error::Unreadable(_))), "got {list:?}"); + } + + #[tokio::test(start_paused = true)] + async fn each_format_is_read_as_its_own_and_one_this_build_does_not_know_as_none() { + let every = [ + ("TV", Some(Format::Tv)), + ("TV_SHORT", Some(Format::TvShort)), + ("MOVIE", Some(Format::Film)), + ("SPECIAL", Some(Format::Special)), + ("OVA", Some(Format::Ova)), + ("ONA", Some(Format::Ona)), + ("MUSIC", Some(Format::Music)), + ("MANGA", None), + ]; + + for (said, format) in every { + let entry = with_media("format", json!(said)); + + assert_eq!(the_one_entry(entry).await.format, format, "{said}"); + } + } + + #[tokio::test(start_paused = true)] + async fn a_work_on_a_list_of_the_accounts_own_making_is_listed_once() { + let answer = an_answer( + 200, + &json!({ "data": { "MediaListCollection": { + "hasNextChunk": false, + "lists": [ + { "isCustomList": true, "entries": [a_series_entry()] }, + { "isCustomList": false, "entries": [a_series_entry()] }, + ], + } } }), + ); + + let list = the_list([Some(answer)]).await; + + assert_eq!(list.expect("the list is read"), [the_series()]); + } + + #[tokio::test(start_paused = true)] + async fn a_work_on_a_list_of_the_accounts_own_making_alone_is_listed() { + let answer = an_answer( + 200, + &json!({ "data": { "MediaListCollection": { + "hasNextChunk": false, + "lists": [{ "isCustomList": true, "entries": [a_series_entry()] }], + } } }), + ); + + let list = the_list([Some(answer)]).await; + + assert_eq!(list.expect("the list is read"), [the_series()]); + } + + #[tokio::test(start_paused = true)] + async fn the_year_is_the_seasons_and_else_the_year_the_work_began() { + let mut entry = with_media("seasonYear", Value::Null); + entry["media"]["startDate"] = json!({ "year": 2008 }); + + assert_eq!(the_one_entry(entry).await.year, Some(2008)); + } + + #[tokio::test(start_paused = true)] + async fn the_title_is_the_one_the_account_prefers_and_else_the_romaji() { + let mut entry = a_series_entry(); + entry["media"]["title"]["userPreferred"] = Value::Null; + entry["media"]["title"]["romaji"] = json!("Show Title Romaji"); + + let read = the_one_entry(entry).await; + + assert_eq!(read.title, "Show Title Romaji"); + assert!( + !read.spellings.contains(&read.title), + "the title is among its own spellings: {read:?}" + ); + } + + #[tokio::test(start_paused = true)] + async fn a_work_with_no_title_at_all_is_unreadable() { + let entry = with_media( + "title", + json!({ "userPreferred": null, "romaji": null, "english": null, "native": null }), + ); + + let list = the_list([Some(a_list(&[entry]))]).await; + + assert!( + matches!(&list, Err(Error::Unreadable(cause)) if cause.to_string() == "work 16498 has no title"), + "got {list:?}" + ); + } + + #[tokio::test(start_paused = true)] + async fn a_score_of_nought_is_no_score() { + let mut entry = a_series_entry(); + entry["score"] = json!(0); + + assert_eq!(the_one_entry(entry).await.score, None); + } + + #[tokio::test(start_paused = true)] + async fn a_score_past_a_hundred_is_unreadable() { + let mut entry = a_series_entry(); + entry["score"] = json!(101); + + let list = the_list([Some(a_list(&[entry]))]).await; + + assert!( + matches!(&list, Err(Error::Unreadable(cause)) if cause.to_string() == "work 16498 has a score of 101, past a hundred"), + "got {list:?}" + ); + } + + #[tokio::test(start_paused = true)] + async fn a_work_is_released_unless_the_service_says_not_yet() { + for status in ["FINISHED", "RELEASING", "HIATUS", "CANCELLED"] { + let entry = with_media("status", json!(status)); + + assert!(the_one_entry(entry).await.released, "{status}"); + } + } +} diff --git a/crates/sync/src/anilist/query.rs b/crates/sync/src/anilist/query.rs new file mode 100644 index 0000000..f2a6104 --- /dev/null +++ b/crates/sync/src/anilist/query.rs @@ -0,0 +1,122 @@ +//! What the backend asks AniList, and the shape of what it answers. + +use serde::Deserialize; +use serde_json::Value; + +/// The account the token is of. +pub(super) const VIEWER: &str = "query { Viewer { id name } }"; + +/// One chunk of an account's list of anime, with what each entry holds. +/// +/// The score is asked for in a hundred points, whatever format the account +/// shows it in. +pub(super) const LIST: &str = "query ($user: Int, $chunk: Int) { \ + MediaListCollection(userId: $user, type: ANIME, chunk: $chunk) { \ + hasNextChunk lists { isCustomList entries { \ + mediaId status progress repeat score(format: POINT_100) \ + media { format episodes duration status seasonYear startDate { year } \ + title { userPreferred romaji english native } synonyms } } } } }"; + +/// An answer's body: its data, or the errors it carries in place of it, or +/// both. +/// +/// The data is read as it stands before it is read as what was asked for: an +/// answer that carries an error can hold the data asked for with a field of +/// it null, and the error is what it says. +#[derive(Debug, Deserialize)] +pub(super) struct Envelope { + pub data: Option, + #[serde(default)] + pub errors: Vec, +} + +/// One error an answer carries. +#[derive(Debug, Deserialize)] +pub(super) struct Problem { + pub message: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ViewerData { + #[serde(rename = "Viewer")] + pub viewer: Viewer, +} + +#[derive(Debug, Deserialize)] +pub(super) struct Viewer { + pub id: i64, + pub name: String, +} + +#[derive(Debug, Deserialize)] +pub(super) struct ListData { + #[serde(rename = "MediaListCollection")] + pub collection: Collection, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct Collection { + pub has_next_chunk: bool, + pub lists: Vec, +} + +/// One of the lists an account's anime are filed in: by status, by format +/// where the account splits its completed list that way, or one of the +/// account's own making. +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct List { + pub is_custom_list: bool, + pub entries: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct ListEntry { + pub media_id: i64, + pub status: ListStatus, + pub progress: Option, + pub repeat: Option, + pub score: Option, + pub media: Media, +} + +/// `MediaListStatus`. +#[derive(Debug, Clone, Copy, Deserialize)] +#[serde(rename_all = "SCREAMING_SNAKE_CASE")] +pub(super) enum ListStatus { + Current, + Planning, + Completed, + Dropped, + Paused, + Repeating, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct Media { + pub format: Option, + pub episodes: Option, + pub duration: Option, + pub status: Option, + pub season_year: Option, + pub start_date: Option, + pub title: Title, + pub synonyms: Option>, +} + +#[derive(Debug, Deserialize)] +pub(super) struct FuzzyDate { + pub year: Option, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub(super) struct Title { + pub user_preferred: Option, + pub romaji: Option, + pub english: Option, + pub native: Option, +} diff --git a/crates/sync/src/anilist/transport.rs b/crates/sync/src/anilist/transport.rs new file mode 100644 index 0000000..48fb0a1 --- /dev/null +++ b/crates/sync/src/anilist/transport.rs @@ -0,0 +1,317 @@ +//! How a request reaches AniList and its answer comes back. +//! +//! One method, a request to an answer, so that a test can stand in for the +//! service with answers it scripts or recorded. [`Http`] is the one that +//! speaks to the service. + +use std::time::Duration; + +use reqwest::header::{ACCEPT, CONTENT_TYPE, RETRY_AFTER}; +use serde_json::{Value, json}; + +use crate::Error; + +/// The address of AniList's GraphQL API. +pub const ANILIST: &str = "https://graphql.anilist.co"; + +/// One GraphQL request: a query and its variables. +#[derive(Debug, Clone, PartialEq)] +pub struct Request { + /// The query. + pub query: String, + /// Its variables, a JSON object. + pub variables: Value, +} + +impl Request { + /// The body the request is posted as. + #[must_use] + pub fn body(&self) -> String { + json!({ "query": self.query, "variables": self.variables }).to_string() + } +} + +/// What the service answered, before it is read. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Answer { + /// The HTTP status. + pub status: u16, + /// How long the service asked to be left alone, from `Retry-After`. + pub retry_after: Option, + /// The body. + pub body: String, +} + +/// A way to send a request and take its answer. +pub trait Transport { + /// Send one request. + /// + /// # Errors + /// + /// [`Error::Unreachable`] where the service cannot be reached, and + /// [`Error::Unreadable`] where the body of its answer is not text. + fn send(&self, request: &Request) -> impl Future> + Send; +} + +/// Requests over HTTPS, with a token. +#[derive(Debug)] +pub struct Http { + client: reqwest::Client, + url: String, + token: String, +} + +impl Http { + /// A transport to `url` that sends `token` with every request. + /// + /// `reqwest` builds its TLS configuration from the process's default + /// provider of cryptography, and panics where none is installed. It is + /// installed here, `ring` where nothing has installed one, so that every + /// `Http`, in a test as much as in the daemon, works without a step of its + /// own. Installing fails only where another was installed in the + /// meantime, which serves as well. + /// + /// # Errors + /// + /// Where the HTTP client cannot be built. + pub fn new(url: &str, token: &str) -> Result { + if rustls::crypto::CryptoProvider::get_default().is_none() { + rustls::crypto::ring::default_provider() + .install_default() + .ok(); + } + + Ok(Self { + client: reqwest::Client::builder().build()?, + url: url.to_owned(), + token: token.to_owned(), + }) + } +} + +impl Transport for Http { + async fn send(&self, request: &Request) -> Result { + let unreachable = |cause: reqwest::Error| Error::Unreachable(cause.into()); + let response = self + .client + .post(&self.url) + .bearer_auth(&self.token) + .header(CONTENT_TYPE, "application/json") + .header(ACCEPT, "application/json") + .body(request.body()) + .send() + .await + .map_err(unreachable)?; + let status = response.status().as_u16(); + let retry_after = response + .headers() + .get(RETRY_AFTER) + .and_then(|value| value.to_str().ok()) + .and_then(seconds); + let body = response.bytes().await.map_err(unreachable)?; + let body = + String::from_utf8(body.to_vec()).map_err(|cause| Error::Unreadable(cause.into()))?; + + Ok(Answer { + status, + retry_after, + body, + }) + } +} + +/// The seconds of a `Retry-After` header, and none for any other form. +/// +/// The header may also hold a date. AniList's documentation gives it in +/// seconds alone. +fn seconds(value: &str) -> Option { + value.parse().ok().map(Duration::from_secs) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use serde_json::{Value, json}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::TcpListener; + + use super::{Answer, Http, Request, Transport, seconds}; + use crate::Error; + + const TOKEN: &str = "a-token-for-tests"; + + fn a_request() -> Request { + Request { + query: "query { Viewer { id name } }".to_owned(), + variables: json!({ "chunk": 1 }), + } + } + + /// A listener on a free port between 20000 and 40000. + async fn a_listener() -> (TcpListener, u16) { + let start = u16::try_from(std::process::id() % 20_000).expect("below 20000"); + for offset in 0..20_000 { + let port = 20_000 + (start + offset) % 20_000; + if let Ok(listener) = TcpListener::bind(("127.0.0.1", port)).await { + return (listener, port); + } + } + panic!("no port between 20000 and 40000 is free"); + } + + /// Take one request off the listener, answer it with `answer`, and hand + /// back the request as it was sent. + async fn answer_one(listener: TcpListener, answer: Vec) -> String { + let (mut socket, _) = listener.accept().await.expect("the client connects"); + let mut received = Vec::new(); + let mut chunk = [0; 4096]; + loop { + let read = socket.read(&mut chunk).await.expect("the request is read"); + assert_ne!(read, 0, "the client went before its request was whole"); + received.extend_from_slice(&chunk[..read]); + let text = String::from_utf8_lossy(&received); + if let Some(end) = text.find("\r\n\r\n") { + let length: usize = text[..end] + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length: ") + .map(str::to_owned) + }) + .map_or(0, |length| length.parse().expect("a length")); + if received.len() >= end + 4 + length { + break; + } + } + } + socket + .write_all(&answer) + .await + .expect("the answer is written"); + socket.shutdown().await.expect("the answer ends"); + + String::from_utf8(received).expect("the request is text") + } + + /// What the server was sent, once the client has had its answer. + /// + /// Bounded, so that a client that never connects fails the test rather + /// than leaving the server waiting on it for ever. + async fn received(server: tokio::task::JoinHandle) -> String { + tokio::time::timeout(Duration::from_secs(5), server) + .await + .expect("the client reached the server within five seconds") + .expect("the server ran") + } + + #[tokio::test] + async fn a_request_is_posted_as_json_with_the_token() { + let (listener, port) = a_listener().await; + let server = tokio::spawn(answer_one( + listener, + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}".to_vec(), + )); + let http = Http::new(&format!("http://127.0.0.1:{port}/"), TOKEN).expect("a client"); + + http.send(&a_request()) + .await + .expect("the request is answered"); + + let sent = received(server).await; + let (head, body) = sent.split_once("\r\n\r\n").expect("a head and a body"); + let head = head.to_ascii_lowercase(); + assert!(head.starts_with("post / http/1.1\r\n"), "{head}"); + assert!( + head.contains(&format!("authorization: bearer {TOKEN}")), + "{head}" + ); + assert!(head.contains("content-type: application/json"), "{head}"); + assert!(head.contains("accept: application/json"), "{head}"); + assert_eq!( + serde_json::from_str::(body).expect("the body is JSON"), + json!({ "query": "query { Viewer { id name } }", "variables": { "chunk": 1 } }) + ); + } + + #[tokio::test] + async fn an_answer_carries_its_status_its_wait_and_its_body() { + let (listener, port) = a_listener().await; + let server = tokio::spawn(answer_one( + listener, + b"HTTP/1.1 429 Too Many Requests\r\nRetry-After: 30\r\nContent-Length: 14\r\n\r\n{\"errors\":[]}\n" + .to_vec(), + )); + let http = Http::new(&format!("http://127.0.0.1:{port}/"), TOKEN).expect("a client"); + + let answer = http + .send(&a_request()) + .await + .expect("the request is answered"); + + received(server).await; + assert_eq!( + answer, + Answer { + status: 429, + retry_after: Some(Duration::from_secs(30)), + body: "{\"errors\":[]}\n".to_owned(), + } + ); + } + + #[tokio::test] + async fn an_answer_that_is_not_text_is_unreadable() { + let (listener, port) = a_listener().await; + let server = tokio::spawn(answer_one( + listener, + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n\xff\xfe".to_vec(), + )); + let http = Http::new(&format!("http://127.0.0.1:{port}/"), TOKEN).expect("a client"); + + let answer = http.send(&a_request()).await; + + assert!( + matches!(answer, Err(Error::Unreadable(_))), + "got {answer:?}" + ); + received(server).await; + } + + /// A port between 20000 and 40000 held by a socket that is bound and + /// never listens, so that a connection to it is refused and no other + /// test can take it in the meantime. + fn a_port_nobody_listens_on() -> (tokio::net::TcpSocket, u16) { + let start = u16::try_from(std::process::id() % 20_000).expect("below 20000"); + for offset in (0..20_000).rev() { + let port = 20_000 + (start + offset) % 20_000; + let socket = tokio::net::TcpSocket::new_v4().expect("a socket"); + if socket.bind(([127, 0, 0, 1], port).into()).is_ok() { + return (socket, port); + } + } + panic!("no port between 20000 and 40000 is free"); + } + + #[tokio::test] + async fn a_service_nobody_listens_for_cannot_be_reached() { + let (_held, port) = a_port_nobody_listens_on(); + let http = Http::new(&format!("http://127.0.0.1:{port}/"), TOKEN).expect("a client"); + + let answer = http.send(&a_request()).await; + + assert!( + matches!(answer, Err(Error::Unreachable(_))), + "got {answer:?}" + ); + } + + #[test] + fn a_wait_is_read_in_whole_seconds_and_no_other_form() { + assert_eq!(seconds("30"), Some(Duration::from_secs(30))); + assert_eq!(seconds("0"), Some(Duration::ZERO)); + assert_eq!(seconds("Wed, 21 Oct 2026 07:28:00 GMT"), None); + assert_eq!(seconds("-1"), None); + assert_eq!(seconds(""), None); + } +} diff --git a/crates/sync/src/lib.rs b/crates/sync/src/lib.rs index 593f6cf..31bd202 100644 --- a/crates/sync/src/lib.rs +++ b/crates/sync/src/lib.rs @@ -13,6 +13,7 @@ //! A new backend is finished when it passes the shared contract suite, in //! `tests/contract`, not when it seems to work. +pub mod anilist; pub mod memory; use std::time::Duration; diff --git a/crates/sync/tests/anilist_record.rs b/crates/sync/tests/anilist_record.rs new file mode 100644 index 0000000..c876bfb --- /dev/null +++ b/crates/sync/tests/anilist_record.rs @@ -0,0 +1,108 @@ +//! Records the fixtures of the AniList backend from the test account. +//! +//! Ignored, because it speaks to the service. It runs by hand, with the +//! test account's token in the environment: +//! +//! ```text +//! BENSHI_ANILIST_TOKEN=... cargo test -p benshi-sync --test anilist_record -- --ignored +//! ``` +//! +//! It files three works on the account's list, records the backend reading +//! the list, and takes the three away again, two seconds between every +//! request. The account is the one made for tests: no user's own list is in +//! a fixture. + +mod recorded; + +use std::sync::{Arc, Mutex, PoisonError}; + +use benshi_sync::anilist::transport::{ANILIST, Http, Request, Transport}; +use benshi_sync::anilist::{AniList, PACE}; +use serde_json::{Value, json}; + +use recorded::Recording; + +/// The works filed for the recording: a series set aside after its third +/// episode, an OVA being watched, and a film seen and scored. +const WORKS: [(i64, &str, u32, Option); 3] = [ + (16_498, "PAUSED", 3, None), + (227, "CURRENT", 1, None), + (112_151, "COMPLETED", 1, Some(80)), +]; + +/// A request that files a work on the list. +fn save(work: i64, status: &str, progress: u32, score: Option) -> Request { + match score { + None => Request { + query: "mutation($mediaId: Int, $status: MediaListStatus, $progress: Int) { \ + SaveMediaListEntry(mediaId: $mediaId, status: $status, progress: $progress) \ + { id } }" + .to_owned(), + variables: json!({ "mediaId": work, "status": status, "progress": progress }), + }, + Some(points) => Request { + query: "mutation($mediaId: Int, $status: MediaListStatus, $progress: Int, \ + $scoreRaw: Int) { SaveMediaListEntry(mediaId: $mediaId, status: $status, \ + progress: $progress, scoreRaw: $scoreRaw) { id } }" + .to_owned(), + variables: json!({ + "mediaId": work, "status": status, "progress": progress, "scoreRaw": points + }), + }, + } +} + +/// A request that takes an entry off the list. +fn delete(entry: i64) -> Request { + Request { + query: "mutation($id: Int) { DeleteMediaListEntry(id: $id) { deleted } }".to_owned(), + variables: json!({ "id": entry }), + } +} + +/// Send one request out of the backend's hands, in its turn. +async fn send(http: &Http, request: &Request) -> Value { + tokio::time::sleep(PACE).await; + let answer = http.send(request).await.expect("the service answers"); + assert_eq!(answer.status, 200, "{}", answer.body); + + serde_json::from_str(&answer.body).expect("the answer is JSON") +} + +#[tokio::test] +#[ignore = "speaks to AniList with the test account's token, from BENSHI_ANILIST_TOKEN"] +async fn record_the_list_of_the_test_account() { + let token = std::env::var("BENSHI_ANILIST_TOKEN") + .expect("BENSHI_ANILIST_TOKEN holds the test account's token"); + let http = Http::new(ANILIST, token.trim()).expect("a client"); + + let mut filed = Vec::new(); + for (work, status, progress, score) in WORKS { + let saved = send(&http, &save(work, status, progress, score)).await; + filed.push( + saved["data"]["SaveMediaListEntry"]["id"] + .as_i64() + .expect("the entry has an id"), + ); + } + + tokio::time::sleep(PACE).await; + let kept = Arc::new(Mutex::new(Vec::new())); + let anilist = AniList::new(Recording { + http: &http, + kept: Arc::clone(&kept), + }); + let read = anilist.list().await; + drop(anilist); + + for entry in filed { + send(&http, &delete(entry)).await; + } + + let list = read.expect("the list is read"); + assert_eq!(list.len(), WORKS.len(), "{list:?}"); + let exchanges = kept.lock().unwrap_or_else(PoisonError::into_inner).clone(); + let text = serde_json::to_string_pretty(&exchanges).expect("the exchanges are JSON") + "\n"; + assert!(!text.contains(token.trim()), "the token is in the fixture"); + std::fs::write(recorded::fixture("list.json"), text).expect("the fixture is written"); +} diff --git a/crates/sync/tests/anilist_recorded.rs b/crates/sync/tests/anilist_recorded.rs new file mode 100644 index 0000000..017322a --- /dev/null +++ b/crates/sync/tests/anilist_recorded.rs @@ -0,0 +1,127 @@ +//! The AniList backend over answers recorded from the test account. +//! +//! `fixtures/anilist/list.json` was recorded by `anilist_record.rs`, with +//! three works filed on the account's list. The replay fails a test where +//! the backend asks anything other than what was recorded. + +mod recorded; + +use std::time::Duration; + +use benshi_core::list::{Entry, EntryId, Format, Rating, Status}; +use benshi_sync::anilist::AniList; + +/// An entry with the facts the service gives a work, and no spellings, +/// which a test of their own checks. +// One argument for each fact of an entry, which is what the table below is. +#[allow(clippy::too_many_arguments)] +fn an_entry( + id: i64, + title: &str, + format: Format, + episodes: u32, + minutes: u64, + year: u16, + status: Status, + progress: u32, + score: Option, +) -> Entry { + Entry { + id: EntryId(id), + title: title.to_owned(), + spellings: Vec::new(), + format: Some(format), + episodes: Some(episodes), + episode_length: Some(Duration::from_mins(minutes)), + year: Some(year), + released: true, + status, + progress, + rewatches: 0, + score, + } +} + +#[tokio::test(start_paused = true)] +async fn the_list_of_the_test_account_is_read_from_its_recorded_answers() { + let anilist = AniList::new(recorded::Replay::of("list.json")); + + let list = anilist.list().await.expect("the list is read"); + + let without_spellings: Vec = list + .iter() + .map(|entry| Entry { + spellings: Vec::new(), + ..entry.clone() + }) + .collect(); + assert_eq!( + without_spellings, + [ + an_entry( + 16_498, + "Shingeki no Kyojin", + Format::Tv, + 25, + 24, + 2013, + Status::OnHold, + 3, + None + ), + an_entry( + 227, + "FLCL", + Format::Ova, + 6, + 25, + 2000, + Status::Watching, + 1, + None + ), + an_entry( + 112_151, + "Kimetsu no Yaiba: Mugen Ressha-hen", + Format::Film, + 1, + 117, + 2020, + Status::Completed, + 1, + Rating::new(80), + ), + ] + ); +} + +#[tokio::test(start_paused = true)] +async fn the_spellings_recorded_are_every_other_title_and_synonym_once() { + let anilist = AniList::new(recorded::Replay::of("list.json")); + + let list = anilist.list().await.expect("the list is read"); + + let spellings: Vec<(EntryId, usize, bool)> = list + .iter() + .map(|entry| { + ( + entry.id, + entry.spellings.len(), + entry.spellings.contains(&entry.title), + ) + }) + .collect(); + // The series has an English and a native title and 15 synonyms. The + // OVA's romaji and English titles are both its title, so its native + // title and its 2 synonyms are left. The film has an English and a + // native title and 12. + assert_eq!( + spellings, + [ + (EntryId(16_498), 17, false), + (EntryId(227), 3, false), + (EntryId(112_151), 14, false), + ] + ); + assert!(list[0].spellings.contains(&"Attack on Titan".to_owned())); +} diff --git a/crates/sync/tests/fakes/mod.rs b/crates/sync/tests/fakes/mod.rs index bd7a0f1..0e7c1dd 100644 --- a/crates/sync/tests/fakes/mod.rs +++ b/crates/sync/tests/fakes/mod.rs @@ -19,7 +19,7 @@ pub const DEADLINE: Duration = Duration::from_secs(15); pub const LISTED: EntryId = EntryId(21); /// A work the service has and the list does not hold. -pub const UNLISTED: EntryId = EntryId(437); +pub const UNLISTED: EntryId = EntryId(112_151); /// A number the service has no work under. pub const MISSING: EntryId = EntryId(9); diff --git a/crates/sync/tests/fixtures/anilist/list.json b/crates/sync/tests/fixtures/anilist/list.json new file mode 100644 index 0000000..c9f203c --- /dev/null +++ b/crates/sync/tests/fixtures/anilist/list.json @@ -0,0 +1,153 @@ +[ + { + "query": "query { Viewer { id name } }", + "variables": {}, + "status": 200, + "retry_after": null, + "body": { + "data": { + "Viewer": { + "id": 8344475, + "name": "bentest" + } + } + } + }, + { + "query": "query ($user: Int, $chunk: Int) { MediaListCollection(userId: $user, type: ANIME, chunk: $chunk) { hasNextChunk lists { isCustomList entries { mediaId status progress repeat score(format: POINT_100) media { format episodes duration status seasonYear startDate { year } title { userPreferred romaji english native } synonyms } } } } }", + "variables": { + "chunk": 1, + "user": 8344475 + }, + "status": 200, + "retry_after": null, + "body": { + "data": { + "MediaListCollection": { + "hasNextChunk": false, + "lists": [ + { + "entries": [ + { + "media": { + "duration": 24, + "episodes": 25, + "format": "TV", + "seasonYear": 2013, + "startDate": { + "year": 2013 + }, + "status": "FINISHED", + "synonyms": [ + "SnK", + "AoT", + "Ataque a los Titanes", + "Ataque dos Titãs", + "L'Attacco dei Giganti", + "מתקפת הטיטאנים", + "进击的巨人", + "L’Attaque des Titans", + "الهجوم على العمالقة", + "ผ่าพิภพไททัน", + "حمله به تایتان", + "Ataque de Titãs", + "Atak Tytanów", + "Атака титанов", + "Đại Chiến Titan" + ], + "title": { + "english": "Attack on Titan", + "native": "進撃の巨人", + "romaji": "Shingeki no Kyojin", + "userPreferred": "Shingeki no Kyojin" + } + }, + "mediaId": 16498, + "progress": 3, + "repeat": 0, + "score": 0, + "status": "PAUSED" + } + ], + "isCustomList": false + }, + { + "entries": [ + { + "media": { + "duration": 25, + "episodes": 6, + "format": "OVA", + "seasonYear": 2000, + "startDate": { + "year": 2000 + }, + "status": "FINISHED", + "synonyms": [ + "Fooly Cooly", + "Furi Kuri" + ], + "title": { + "english": "FLCL", + "native": "フリクリ", + "romaji": "FLCL", + "userPreferred": "FLCL" + } + }, + "mediaId": 227, + "progress": 1, + "repeat": 0, + "score": 0, + "status": "CURRENT" + } + ], + "isCustomList": false + }, + { + "entries": [ + { + "media": { + "duration": 117, + "episodes": 1, + "format": "MOVIE", + "seasonYear": 2020, + "startDate": { + "year": 2020 + }, + "status": "FINISHED", + "synonyms": [ + "KnY Movie", + "Els Guardians de la Nit: El Tren Infinit", + "Guardianes de la Noche: Tren Infinito", + "Demon Slayer: Mugen Treni", + "Demon Slayer: Il Treno Mugen", + "鬼灭之刃:无限列车篇", + "قاتل الشياطين الفيلم: قطار اللانهاية", + "ดาบพิฆาตอสูร เดอะมูฟวี่ : ศึกรถไฟสู่นิรันดร์", + "Demon Slayer: Kimetsu no Yaiba - Le film : Le train de l'Infini", + "ΚΥΝΗΓΟΣ ΔΑΙΜΟΝΩΝ: KIMETSU NO YAIBA – Η ΤΑΙΝΙΑ: ΤΟ ΤΡΕΝΟ ΜΟΥΓΚΕΝ", + "극장판 귀멸의 칼날: 무한열차편", + "Клинок, Рассекающий Демонов: Бесконечный Поезд" + ], + "title": { + "english": "Demon Slayer -Kimetsu no Yaiba- The Movie: Mugen Train", + "native": "鬼滅の刃 無限列車編", + "romaji": "Kimetsu no Yaiba: Mugen Ressha-hen", + "userPreferred": "Kimetsu no Yaiba: Mugen Ressha-hen" + } + }, + "mediaId": 112151, + "progress": 1, + "repeat": 0, + "score": 80, + "status": "COMPLETED" + } + ], + "isCustomList": false + } + ] + } + } + } + } +] diff --git a/crates/sync/tests/recorded/mod.rs b/crates/sync/tests/recorded/mod.rs new file mode 100644 index 0000000..a6234af --- /dev/null +++ b/crates/sync/tests/recorded/mod.rs @@ -0,0 +1,128 @@ +//! Answers recorded from AniList, and the transports that write and replay +//! them. +//! +//! A fixture is a JSON array of exchanges, each a request and the answer it +//! had. No token is in one: [`Http`] sends it in a header, and neither a +//! request nor an answer carries it. + +// Each test binary that includes this uses one half of it: the recorder the +// recording transport, and a test the replay. +#![allow(dead_code)] + +use std::path::PathBuf; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; + +use benshi_sync::Error; +use benshi_sync::anilist::transport::{Answer, Http, Request, Transport}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +/// One request and the answer it had. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Exchange { + pub query: String, + pub variables: Value, + pub status: u16, + /// `Retry-After`, in whole seconds. + pub retry_after: Option, + /// The body, read as JSON where it is, and kept as text where it is not. + pub body: Value, +} + +/// Where a fixture of this name is kept. +pub fn fixture(name: &str) -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/anilist") + .join(name) +} + +/// A service that answers what was recorded, in order, and fails the test +/// where a request is not the one that was recorded next. +pub struct Replay { + exchanges: Mutex>, +} + +impl Replay { + /// The exchanges of the fixture of this name. + pub fn of(name: &str) -> Self { + let text = std::fs::read_to_string(fixture(name)).expect("the fixture is there"); + let mut exchanges: Vec = + serde_json::from_str(&text).expect("the fixture is JSON"); + exchanges.reverse(); + Self { + exchanges: Mutex::new(exchanges), + } + } +} + +// A replay answers from memory, and the trait asks for a future either way. +// The `async` therefore stays where there is nothing to await, rather than +// being spelled out as the future it desugars to. +#[allow(clippy::unused_async_trait_impl)] +impl Transport for Replay { + async fn send(&self, request: &Request) -> Result { + let exchange = self + .exchanges + .lock() + .unwrap_or_else(PoisonError::into_inner) + .pop() + .expect("the fixture has an answer for every request"); + assert_eq!( + (request.query.as_str(), &request.variables), + (exchange.query.as_str(), &exchange.variables), + "the request is the one that was recorded" + ); + + Ok(Answer { + status: exchange.status, + retry_after: exchange.retry_after.map(Duration::from_secs), + body: match exchange.body { + Value::String(text) => text, + json => json.to_string(), + }, + }) + } +} + +/// A replay that ends with exchanges nobody asked for fails the test: the +/// backend stopped before it had asked what it asked when it was recorded. +/// Not while the test is failing already, where a second panic would abort +/// the process. +impl Drop for Replay { + fn drop(&mut self) { + let left = self + .exchanges + .get_mut() + .unwrap_or_else(PoisonError::into_inner) + .len(); + if !std::thread::panicking() { + assert_eq!(left, 0, "{left} recorded exchanges were never asked for"); + } + } +} + +/// The service over HTTPS, with every exchange kept. +pub struct Recording<'a> { + pub http: &'a Http, + pub kept: Arc>>, +} + +impl Transport for Recording<'_> { + async fn send(&self, request: &Request) -> Result { + let answer = self.http.send(request).await?; + self.kept + .lock() + .unwrap_or_else(PoisonError::into_inner) + .push(Exchange { + query: request.query.clone(), + variables: request.variables.clone(), + status: answer.status, + retry_after: answer.retry_after.map(|wait| wait.as_secs()), + body: serde_json::from_str(&answer.body) + .unwrap_or_else(|_| Value::String(answer.body.clone())), + }); + + Ok(answer) + } +}