A better Rust ATProto crate

changelog, data deserialization bugfix, readme update, mod example

+377 -150
+8
CHANGELOG.md
··· 36 36 37 37 **Examples** 38 38 - Updated `create_post.rs` to demonstrate richtext parsing with automatic facet detection 39 + - New `moderated_timeline.rs` to demonstrate fetching timeline with labelers enabled and applying moderation decisions 40 + 41 + ### Fixed 42 + 43 + **Data deserialization** (`jacquard-common`) 44 + - Fixed `Option<Vec<T>>` deserialization from `Data` values 45 + - Implemented explicit `deserialize_option` for `Data` and `RawData` deserializers 46 + - Properly handles null vs present array values when deserializing into optional fields 39 47 40 48 41 49 ## [0.6.0] - 2025-10-18
+56 -87
Cargo.lock
··· 1291 1291 checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" 1292 1292 dependencies = [ 1293 1293 "libc", 1294 - "windows-sys 0.61.2", 1294 + "windows-sys 0.59.0", 1295 1295 ] 1296 1296 1297 1297 [[package]] ··· 1940 1940 "libc", 1941 1941 "percent-encoding", 1942 1942 "pin-project-lite", 1943 - "socket2 0.6.1", 1943 + "socket2 0.5.10", 1944 1944 "system-configuration", 1945 1945 "tokio", 1946 1946 "tower-service", ··· 1960 1960 "js-sys", 1961 1961 "log", 1962 1962 "wasm-bindgen", 1963 - "windows-core 0.62.2", 1963 + "windows-core", 1964 1964 ] 1965 1965 1966 1966 [[package]] ··· 2242 2242 2243 2243 [[package]] 2244 2244 name = "jacquard" 2245 - version = "0.6.1" 2245 + version = "0.7.0" 2246 2246 dependencies = [ 2247 2247 "bon", 2248 2248 "bytes", ··· 2251 2251 "getrandom 0.2.16", 2252 2252 "http", 2253 2253 "image", 2254 - "jacquard-api 0.6.2", 2255 - "jacquard-common 0.6.0", 2256 - "jacquard-derive 0.6.1", 2257 - "jacquard-identity 0.6.0", 2254 + "jacquard-api 0.7.0", 2255 + "jacquard-common 0.7.0", 2256 + "jacquard-derive 0.7.0", 2257 + "jacquard-identity 0.7.0", 2258 2258 "jacquard-oauth", 2259 2259 "jose-jwk", 2260 2260 "miette", ··· 2281 2281 2282 2282 [[package]] 2283 2283 name = "jacquard-api" 2284 - version = "0.6.0" 2285 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#861d9e86c582939ed1d50201954ef1368a91f9b7" 2284 + version = "0.7.0" 2286 2285 dependencies = [ 2287 2286 "bon", 2288 2287 "bytes", 2289 - "jacquard-common 0.6.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", 2290 - "jacquard-derive 0.6.0", 2288 + "jacquard-common 0.7.0", 2289 + "jacquard-derive 0.7.0", 2291 2290 "miette", 2292 2291 "serde", 2293 2292 "serde_ipld_dagcbor", ··· 2296 2295 2297 2296 [[package]] 2298 2297 name = "jacquard-api" 2299 - version = "0.6.2" 2298 + version = "0.7.0" 2299 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#ef09feb782cf34eda616c9511fa12e439e1062c6" 2300 2300 dependencies = [ 2301 2301 "bon", 2302 2302 "bytes", 2303 - "jacquard-common 0.6.0", 2304 - "jacquard-derive 0.6.1", 2303 + "jacquard-common 0.7.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", 2304 + "jacquard-derive 0.7.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", 2305 2305 "miette", 2306 2306 "serde", 2307 2307 "serde_ipld_dagcbor", ··· 2319 2319 "bytes", 2320 2320 "chrono", 2321 2321 "jacquard", 2322 - "jacquard-common 0.6.0", 2323 - "jacquard-derive 0.6.1", 2324 - "jacquard-identity 0.6.0", 2322 + "jacquard-common 0.7.0", 2323 + "jacquard-derive 0.7.0", 2324 + "jacquard-identity 0.7.0", 2325 2325 "k256", 2326 2326 "miette", 2327 2327 "multibase", ··· 2341 2341 2342 2342 [[package]] 2343 2343 name = "jacquard-common" 2344 - version = "0.6.0" 2344 + version = "0.7.0" 2345 2345 dependencies = [ 2346 2346 "base64 0.22.1", 2347 2347 "bon", ··· 2385 2385 2386 2386 [[package]] 2387 2387 name = "jacquard-common" 2388 - version = "0.6.0" 2389 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#861d9e86c582939ed1d50201954ef1368a91f9b7" 2388 + version = "0.7.0" 2389 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#ef09feb782cf34eda616c9511fa12e439e1062c6" 2390 2390 dependencies = [ 2391 2391 "base64 0.22.1", 2392 2392 "bon", ··· 2422 2422 2423 2423 [[package]] 2424 2424 name = "jacquard-derive" 2425 - version = "0.6.0" 2426 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#861d9e86c582939ed1d50201954ef1368a91f9b7" 2425 + version = "0.7.0" 2427 2426 dependencies = [ 2427 + "jacquard-common 0.7.0", 2428 2428 "proc-macro2", 2429 2429 "quote", 2430 + "serde", 2431 + "serde_json", 2430 2432 "syn 2.0.106", 2431 2433 ] 2432 2434 2433 2435 [[package]] 2434 2436 name = "jacquard-derive" 2435 - version = "0.6.1" 2437 + version = "0.7.0" 2438 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#ef09feb782cf34eda616c9511fa12e439e1062c6" 2436 2439 dependencies = [ 2437 - "jacquard-common 0.6.0", 2438 2440 "proc-macro2", 2439 2441 "quote", 2440 - "serde", 2441 - "serde_json", 2442 2442 "syn 2.0.106", 2443 2443 ] 2444 2444 2445 2445 [[package]] 2446 2446 name = "jacquard-identity" 2447 - version = "0.6.0" 2447 + version = "0.7.0" 2448 2448 dependencies = [ 2449 2449 "bon", 2450 2450 "bytes", 2451 2451 "hickory-resolver", 2452 2452 "http", 2453 - "jacquard-api 0.6.2", 2454 - "jacquard-common 0.6.0", 2453 + "jacquard-api 0.7.0", 2454 + "jacquard-common 0.7.0", 2455 2455 "miette", 2456 2456 "n0-future", 2457 2457 "percent-encoding", ··· 2469 2469 2470 2470 [[package]] 2471 2471 name = "jacquard-identity" 2472 - version = "0.6.0" 2473 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#861d9e86c582939ed1d50201954ef1368a91f9b7" 2472 + version = "0.7.0" 2473 + source = "git+https://tangled.org/@nonbinary.computer/jacquard#ef09feb782cf34eda616c9511fa12e439e1062c6" 2474 2474 dependencies = [ 2475 2475 "bon", 2476 2476 "bytes", 2477 2477 "http", 2478 - "jacquard-api 0.6.0", 2479 - "jacquard-common 0.6.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", 2478 + "jacquard-api 0.7.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", 2479 + "jacquard-common 0.7.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", 2480 2480 "miette", 2481 2481 "percent-encoding", 2482 2482 "reqwest", ··· 2492 2492 2493 2493 [[package]] 2494 2494 name = "jacquard-lexicon" 2495 - version = "0.6.1" 2495 + version = "0.7.0" 2496 2496 dependencies = [ 2497 2497 "async-trait", 2498 2498 "clap", ··· 2500 2500 "clap_mangen", 2501 2501 "glob", 2502 2502 "heck 0.5.0", 2503 - "jacquard-api 0.6.0", 2504 - "jacquard-common 0.6.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", 2505 - "jacquard-identity 0.6.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", 2503 + "jacquard-api 0.7.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", 2504 + "jacquard-common 0.7.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", 2505 + "jacquard-identity 0.7.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", 2506 2506 "kdl", 2507 2507 "miette", 2508 2508 "prettyplease", ··· 2522 2522 2523 2523 [[package]] 2524 2524 name = "jacquard-oauth" 2525 - version = "0.6.0" 2525 + version = "0.7.0" 2526 2526 dependencies = [ 2527 2527 "base64 0.22.1", 2528 2528 "bytes", ··· 2530 2530 "dashmap", 2531 2531 "elliptic-curve", 2532 2532 "http", 2533 - "jacquard-common 0.6.0", 2534 - "jacquard-identity 0.6.0", 2533 + "jacquard-common 0.7.0", 2534 + "jacquard-identity 0.7.0", 2535 2535 "jose-jwa", 2536 2536 "jose-jwk", 2537 2537 "miette", ··· 3071 3071 source = "registry+https://github.com/rust-lang/crates.io-index" 3072 3072 checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" 3073 3073 dependencies = [ 3074 - "windows-sys 0.61.2", 3074 + "windows-sys 0.59.0", 3075 3075 ] 3076 3076 3077 3077 [[package]] ··· 3648 3648 "quinn-udp", 3649 3649 "rustc-hash", 3650 3650 "rustls", 3651 - "socket2 0.6.1", 3651 + "socket2 0.5.10", 3652 3652 "thiserror 2.0.17", 3653 3653 "tokio", 3654 3654 "tracing", ··· 3685 3685 "cfg_aliases", 3686 3686 "libc", 3687 3687 "once_cell", 3688 - "socket2 0.6.1", 3688 + "socket2 0.5.10", 3689 3689 "tracing", 3690 - "windows-sys 0.60.2", 3690 + "windows-sys 0.59.0", 3691 3691 ] 3692 3692 3693 3693 [[package]] ··· 4106 4106 "errno", 4107 4107 "libc", 4108 4108 "linux-raw-sys 0.11.0", 4109 - "windows-sys 0.61.2", 4109 + "windows-sys 0.59.0", 4110 4110 ] 4111 4111 4112 4112 [[package]] ··· 4769 4769 "getrandom 0.3.4", 4770 4770 "once_cell", 4771 4771 "rustix 1.1.2", 4772 - "windows-sys 0.61.2", 4772 + "windows-sys 0.59.0", 4773 4773 ] 4774 4774 4775 4775 [[package]] ··· 5628 5628 source = "registry+https://github.com/rust-lang/crates.io-index" 5629 5629 checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" 5630 5630 dependencies = [ 5631 - "windows-sys 0.61.2", 5631 + "windows-sys 0.48.0", 5632 5632 ] 5633 5633 5634 5634 [[package]] ··· 5644 5644 checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" 5645 5645 dependencies = [ 5646 5646 "windows-collections", 5647 - "windows-core 0.61.2", 5647 + "windows-core", 5648 5648 "windows-future", 5649 5649 "windows-link 0.1.3", 5650 5650 "windows-numerics", ··· 5656 5656 source = "registry+https://github.com/rust-lang/crates.io-index" 5657 5657 checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" 5658 5658 dependencies = [ 5659 - "windows-core 0.61.2", 5659 + "windows-core", 5660 5660 ] 5661 5661 5662 5662 [[package]] ··· 5668 5668 "windows-implement", 5669 5669 "windows-interface", 5670 5670 "windows-link 0.1.3", 5671 - "windows-result 0.3.4", 5672 - "windows-strings 0.4.2", 5673 - ] 5674 - 5675 - [[package]] 5676 - name = "windows-core" 5677 - version = "0.62.2" 5678 - source = "registry+https://github.com/rust-lang/crates.io-index" 5679 - checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" 5680 - dependencies = [ 5681 - "windows-implement", 5682 - "windows-interface", 5683 - "windows-link 0.2.1", 5684 - "windows-result 0.4.1", 5685 - "windows-strings 0.5.1", 5671 + "windows-result", 5672 + "windows-strings", 5686 5673 ] 5687 5674 5688 5675 [[package]] ··· 5691 5678 source = "registry+https://github.com/rust-lang/crates.io-index" 5692 5679 checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" 5693 5680 dependencies = [ 5694 - "windows-core 0.61.2", 5681 + "windows-core", 5695 5682 "windows-link 0.1.3", 5696 5683 "windows-threading", 5697 5684 ] ··· 5736 5723 source = "registry+https://github.com/rust-lang/crates.io-index" 5737 5724 checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" 5738 5725 dependencies = [ 5739 - "windows-core 0.61.2", 5726 + "windows-core", 5740 5727 "windows-link 0.1.3", 5741 5728 ] 5742 5729 ··· 5747 5734 checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" 5748 5735 dependencies = [ 5749 5736 "windows-link 0.1.3", 5750 - "windows-result 0.3.4", 5751 - "windows-strings 0.4.2", 5737 + "windows-result", 5738 + "windows-strings", 5752 5739 ] 5753 5740 5754 5741 [[package]] ··· 5761 5748 ] 5762 5749 5763 5750 [[package]] 5764 - name = "windows-result" 5765 - version = "0.4.1" 5766 - source = "registry+https://github.com/rust-lang/crates.io-index" 5767 - checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" 5768 - dependencies = [ 5769 - "windows-link 0.2.1", 5770 - ] 5771 - 5772 - [[package]] 5773 5751 name = "windows-strings" 5774 5752 version = "0.4.2" 5775 5753 source = "registry+https://github.com/rust-lang/crates.io-index" 5776 5754 checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" 5777 5755 dependencies = [ 5778 5756 "windows-link 0.1.3", 5779 - ] 5780 - 5781 - [[package]] 5782 - name = "windows-strings" 5783 - version = "0.5.1" 5784 - source = "registry+https://github.com/rust-lang/crates.io-index" 5785 - checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" 5786 - dependencies = [ 5787 - "windows-link 0.2.1", 5788 5757 ] 5789 5758 5790 5759 [[package]]
+14 -11
README.md
··· 8 8 9 9 It is also designed around zero-copy/borrowed deserialization: types like [`Post<'_>`](https://tangled.org/@nonbinary.computer/jacquard/blob/main/crates/jacquard-api/src/app_bsky/feed/post.rs) can borrow data (via the [`CowStr<'_>`](https://docs.rs/jacquard/latest/jacquard/cowstr/enum.CowStr.html) type and a host of other types built on top of it) directly from the response buffer instead of allocating owned copies. Owned versions are themselves mostly inlined or reference-counted pointers and are therefore still quite efficient. The `IntoStatic` trait (which is derivable) makes it easy to get an owned version and avoid worrying about lifetimes. 10 10 11 - ## 0.6.0 Release Highlights: 11 + ## 0.7.0 Release Highlights: 12 12 13 - - **WebSocket streaming** (gated behind feature: "streaming" in `jacquard` and "websocket" in `jacquard-common`) 14 - - Base level HTTP streamed responses and (on non-wasm platforms) request support (gated behind feature: "streaming" in `jacquard-common`) 15 - - **Support for atproto event stream endpoints** (e.g. subscribeRepos, subscribeLabels, firehose) 16 - - **Jetstream subscriber support and implementation** 17 - - **zstd compression support** for JSON websocket endpoints 18 - - **XRPC streaming procedure traits** for endpoints with large payloads, experimental manual implementations in `jacquard` 19 - - Fixed blob upload and download bugs, CID link deserialization issues. 20 - 21 - ### WARNING 13 + - **Bluesky-style rich text support** 14 + - Parses from supplied text as well as explicit builder 15 + - Sanitizes input text 16 + - Also handles \[]() Markdown-style links 17 + - Optionally pulls out candidates for link/record embedding 18 + - Optionally fetches Opengraph link data for external links 19 + - **Moderation label application** 20 + - Generic implementation of atproto moderation/labeling client-side filtering/tagging via traits 21 + - Implementations for Bluesky and other types on best-effort basis 22 + - Demonstration options for use while avoiding Bluesky namespace or AppView infrastructure 23 + - Fixed some Data value type deserialization issues 22 24 23 - A lot of the streaming code is still pretty experimental. The examples work, though.\ 25 + > [!WARNING] 26 + > A lot of the streaming code is still pretty experimental. The examples work, though.\ 24 27 The modules are also less well-documented, and don't have code examples. There are also a lot of utility functions for conveniently working with the streams and transforming them which are lacking. Use [`n0-future`](https://docs.rs/n0-future/latest/n0_future/index.html) to work with them, that is what Jacquard uses internally as much as possible. 25 28 26 29 ### Changelog
+48 -51
crates/jacquard-common/src/types/value/serde_impl.rs
··· 775 775 Data::Boolean(b) => visitor.visit_bool(*b), 776 776 Data::Integer(i) => visitor.visit_i64(*i), 777 777 Data::String(s) => { 778 - // Get the string with 'de lifetime first 778 + // Get the string with 'de lifetime - this borrows from the Data itself 779 + // and is valid for the full 'de lifetime since Data<'de> owns/borrows the string 779 780 let string_ref: &'de str = s.as_str(); 780 781 781 - // Try to borrow from types that contain CowStr 782 - match s { 783 - AtprotoStr::String(cow) => match cow { 784 - CowStr::Borrowed(b) => visitor.visit_borrowed_str(b), 785 - CowStr::Owned(_) => visitor.visit_str(cow.as_ref()), 786 - }, 787 - AtprotoStr::Did(Did(cow)) => match cow { 788 - CowStr::Borrowed(b) => visitor.visit_borrowed_str(b), 789 - CowStr::Owned(_) => visitor.visit_str(cow.as_ref()), 790 - }, 791 - AtprotoStr::Handle(Handle(cow)) => match cow { 792 - CowStr::Borrowed(b) => visitor.visit_borrowed_str(b), 793 - CowStr::Owned(_) => visitor.visit_str(cow.as_ref()), 794 - }, 795 - AtprotoStr::Nsid(Nsid(cow)) => match cow { 796 - CowStr::Borrowed(b) => visitor.visit_borrowed_str(b), 797 - CowStr::Owned(_) => visitor.visit_str(cow.as_ref()), 798 - }, 799 - AtprotoStr::Uri(Uri::Did(Did(cow))) => match cow { 800 - CowStr::Borrowed(b) => visitor.visit_borrowed_str(b), 801 - CowStr::Owned(_) => visitor.visit_str(cow.as_ref()), 802 - }, 803 - AtprotoStr::Uri(Uri::Any(cow)) => match cow { 804 - CowStr::Borrowed(b) => visitor.visit_borrowed_str(b), 805 - CowStr::Owned(_) => visitor.visit_str(cow.as_ref()), 806 - }, 807 - AtprotoStr::Cid(Cid::Str(cow)) => match cow { 808 - CowStr::Borrowed(b) => visitor.visit_borrowed_str(b), 809 - CowStr::Owned(_) => visitor.visit_str(cow.as_ref()), 810 - }, 811 - AtprotoStr::AtIdentifier(AtIdentifier::Did(Did(cow))) => match cow { 812 - CowStr::Borrowed(b) => visitor.visit_borrowed_str(b), 813 - CowStr::Owned(_) => visitor.visit_str(cow.as_ref()), 814 - }, 815 - AtprotoStr::AtIdentifier(AtIdentifier::Handle(Handle(cow))) => match cow { 816 - CowStr::Borrowed(b) => visitor.visit_borrowed_str(b), 817 - CowStr::Owned(_) => visitor.visit_str(cow.as_ref()), 818 - }, 819 - AtprotoStr::RecordKey(RecordKey(Rkey(cow))) => match cow { 820 - CowStr::Borrowed(b) => visitor.visit_borrowed_str(b), 821 - CowStr::Owned(_) => visitor.visit_str(cow.as_ref()), 822 - }, 823 - // All other types (Tid, Datetime, Language, AtUri with SmolStr): 824 - // use visit_borrowed_str with the &'de str so they can borrow if needed 825 - _ => visitor.visit_borrowed_str(string_ref), 826 - } 782 + // Use string_ref for ALL cases to ensure proper 'de lifetime borrowing 783 + visitor.visit_borrowed_str(string_ref) 827 784 } 828 785 Data::Bytes(b) => visitor.visit_bytes(b), 829 786 Data::CidLink(cid) => visitor.visit_str(cid.as_str()), ··· 836 793 } 837 794 } 838 795 796 + fn deserialize_option<V>(self, visitor: V) -> Result<V::Value, Self::Error> 797 + where 798 + V: serde::de::Visitor<'de>, 799 + { 800 + match self { 801 + Data::Null => visitor.visit_none(), 802 + _ => visitor.visit_some(self), 803 + } 804 + } 805 + 839 806 serde::forward_to_deserialize_any! { 840 807 bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string 841 - bytes byte_buf option unit unit_struct newtype_struct seq tuple 808 + bytes byte_buf unit unit_struct newtype_struct seq tuple 842 809 tuple_struct map struct enum identifier ignored_any 843 810 } 844 811 } ··· 867 834 } 868 835 } 869 836 837 + fn deserialize_option<V>(self, visitor: V) -> Result<V::Value, Self::Error> 838 + where 839 + V: serde::de::Visitor<'de>, 840 + { 841 + match self { 842 + Data::Null => visitor.visit_none(), 843 + _ => visitor.visit_some(self), 844 + } 845 + } 846 + 870 847 serde::forward_to_deserialize_any! { 871 848 bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string 872 - bytes byte_buf option unit unit_struct newtype_struct seq tuple 849 + bytes byte_buf unit unit_struct newtype_struct seq tuple 873 850 tuple_struct map struct enum identifier ignored_any 874 851 } 875 852 } ··· 902 879 } 903 880 } 904 881 882 + fn deserialize_option<V>(self, visitor: V) -> Result<V::Value, Self::Error> 883 + where 884 + V: serde::de::Visitor<'de>, 885 + { 886 + match self { 887 + RawData::Null => visitor.visit_none(), 888 + _ => visitor.visit_some(self), 889 + } 890 + } 891 + 905 892 serde::forward_to_deserialize_any! { 906 893 bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string 907 - bytes byte_buf option unit unit_struct newtype_struct seq tuple 894 + bytes byte_buf unit unit_struct newtype_struct seq tuple 908 895 tuple_struct map struct enum identifier ignored_any 909 896 } 910 897 } ··· 937 924 } 938 925 } 939 926 927 + fn deserialize_option<V>(self, visitor: V) -> Result<V::Value, Self::Error> 928 + where 929 + V: serde::de::Visitor<'de>, 930 + { 931 + match self { 932 + RawData::Null => visitor.visit_none(), 933 + _ => visitor.visit_some(self), 934 + } 935 + } 936 + 940 937 serde::forward_to_deserialize_any! { 941 938 bool i8 i16 i32 i64 i128 u8 u16 u32 u64 u128 f32 f64 char str string 942 - bytes byte_buf option unit unit_struct newtype_struct seq tuple 939 + bytes byte_buf unit unit_struct newtype_struct seq tuple 943 940 tuple_struct map struct enum identifier ignored_any 944 941 } 945 942 }
+61
crates/jacquard-common/src/types/value/tests.rs
··· 752 752 _ => panic!("expected object"), 753 753 } 754 754 } 755 + 756 + #[test] 757 + fn test_option_vec_deserialization() { 758 + use serde::Deserialize; 759 + 760 + // Regression test for Option<Vec<T>> deserialization bug 761 + // Previously failed with "invalid type: sequence, expected option" 762 + #[derive(Debug, PartialEq, Deserialize)] 763 + struct WithOptionalArray<'a> { 764 + #[serde(borrow)] 765 + text: &'a str, 766 + langs: Option<Vec<Language>>, 767 + tags: Option<Vec<CowStr<'a>>>, 768 + } 769 + 770 + // Test with langs present 771 + let mut map_with_langs = BTreeMap::new(); 772 + map_with_langs.insert( 773 + SmolStr::new_static("text"), 774 + Data::String(AtprotoStr::String("hello".into())), 775 + ); 776 + map_with_langs.insert( 777 + SmolStr::new_static("langs"), 778 + Data::Array(Array(vec![ 779 + Data::String(AtprotoStr::Language(Language::new("en").unwrap())), 780 + Data::String(AtprotoStr::Language(Language::new("fr").unwrap())), 781 + ])), 782 + ); 783 + let data_with_langs = Data::Object(Object(map_with_langs)); 784 + 785 + let result: WithOptionalArray = from_data(&data_with_langs).unwrap(); 786 + assert_eq!(result.text, "hello"); 787 + assert_eq!(result.langs.as_ref().map(|v| v.len()), Some(2)); 788 + assert_eq!(result.tags, None); 789 + 790 + // Test with langs absent (None) 791 + let mut map_without_langs = BTreeMap::new(); 792 + map_without_langs.insert( 793 + SmolStr::new_static("text"), 794 + Data::String(AtprotoStr::String("world".into())), 795 + ); 796 + let data_without_langs = Data::Object(Object(map_without_langs)); 797 + 798 + let result: WithOptionalArray = from_data(&data_without_langs).unwrap(); 799 + assert_eq!(result.text, "world"); 800 + assert_eq!(result.langs, None); 801 + assert_eq!(result.tags, None); 802 + 803 + // Test with null explicitly set 804 + let mut map_with_null = BTreeMap::new(); 805 + map_with_null.insert( 806 + SmolStr::new_static("text"), 807 + Data::String(AtprotoStr::String("null test".into())), 808 + ); 809 + map_with_null.insert(SmolStr::new_static("langs"), Data::Null); 810 + let data_with_null = Data::Object(Object(map_with_null)); 811 + 812 + let result: WithOptionalArray = from_data(&data_with_null).unwrap(); 813 + assert_eq!(result.text, "null test"); 814 + assert_eq!(result.langs, None); 815 + }
+4
crates/jacquard/Cargo.toml
··· 120 120 path = "../../examples/subscribe_jetstream.rs" 121 121 required-features = ["streaming"] 122 122 123 + [[example]] 124 + name = "moderated_timeline" 125 + path = "../../examples/moderated_timeline.rs" 126 + required-features = ["api_bluesky", "loopback"] 123 127 124 128 [dependencies] 125 129 jacquard-api = { version = "0.7", path = "../jacquard-api" }
+1 -1
crates/jacquard/src/moderation/fetch.rs
··· 13 13 use jacquard_common::types::collection::Collection; 14 14 use jacquard_common::types::string::Did; 15 15 use jacquard_common::types::uri::RecordUri; 16 - use jacquard_common::xrpc::{XrpcClient, XrpcError, XrpcResp}; 16 + use jacquard_common::xrpc::{XrpcClient, XrpcError}; 17 17 use jacquard_common::{CowStr, IntoStatic}; 18 18 use std::convert::From; 19 19
+185
examples/moderated_timeline.rs
··· 1 + use clap::Parser; 2 + use jacquard::CowStr; 3 + use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 4 + use jacquard::api::app_bsky::feed::post::Post; 5 + use jacquard::api::app_bsky::labeler::get_services::GetServicesOutput; 6 + use jacquard::client::{Agent, FileAuthStore}; 7 + use jacquard::cowstr::ToCowStr; 8 + use jacquard::from_data; 9 + use jacquard::moderation::{Moderateable, ModerationPrefs, fetch_labeler_defs}; 10 + use jacquard::oauth::atproto::AtprotoClientMetadata; 11 + use jacquard::oauth::client::OAuthClient; 12 + use jacquard::oauth::loopback::LoopbackConfig; 13 + use jacquard::xrpc::{CallOptions, XrpcClient}; 14 + use jacquard_api::app_bsky::feed::{ReplyRefParent, ReplyRefRoot}; 15 + 16 + // To save having to fetch prefs, etc., we're borrowing some from our test cases. 17 + const LABELER_SERVICES_JSON: &str = 18 + include_str!("../crates/jacquard/src/moderation/labeler_services.json"); 19 + 20 + #[derive(Parser, Debug)] 21 + #[command( 22 + author, 23 + version, 24 + about = "Fetch timeline with moderation labels applied" 25 + )] 26 + struct Args { 27 + /// Handle (e.g., alice.bsky.social), DID, or PDS URL 28 + input: CowStr<'static>, 29 + 30 + /// Path to auth store file (will be created if missing) 31 + #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")] 32 + store: String, 33 + 34 + /// Number of posts to fetch 35 + #[arg(short, long, default_value = "50")] 36 + limit: i64, 37 + } 38 + 39 + #[tokio::main] 40 + async fn main() -> miette::Result<()> { 41 + let args = Args::parse(); 42 + 43 + // Extract labeler DIDs from the static JSON (used for testing) 44 + let services: GetServicesOutput<'static> = 45 + serde_json::from_str(LABELER_SERVICES_JSON).expect("failed to parse labeler services"); 46 + 47 + let mut accepted_labelers = Vec::new(); 48 + use jacquard::api::app_bsky::labeler::get_services::GetServicesOutputViewsItem; 49 + 50 + for view in services.views { 51 + if let GetServicesOutputViewsItem::LabelerViewDetailed(detailed) = view { 52 + accepted_labelers.push(detailed.creator.did.clone()); 53 + } 54 + } 55 + 56 + println!( 57 + "Fetching live definitions for {} labelers...", 58 + accepted_labelers.len() 59 + ); 60 + 61 + // OAuth login 62 + let store = FileAuthStore::new(&args.store); 63 + let client_data = jacquard_oauth::session::ClientData { 64 + keyset: None, 65 + config: AtprotoClientMetadata::default_localhost(), 66 + }; 67 + 68 + let oauth = OAuthClient::new(store, client_data); 69 + let session = oauth 70 + .login_with_local_server( 71 + args.input.clone(), 72 + Default::default(), 73 + LoopbackConfig::default(), 74 + ) 75 + .await?; 76 + 77 + let agent: Agent<_> = Agent::from(session); 78 + 79 + // Fetch live labeler definitions from the network 80 + let defs = fetch_labeler_defs(&agent, accepted_labelers.clone()).await?; 81 + 82 + println!("Loaded definitions for {} labelers\n", defs.defs.len()); 83 + 84 + // Fetch timeline with labelers enabled via CallOptions 85 + let mut opts = CallOptions::default(); 86 + opts.atproto_accept_labelers = Some( 87 + accepted_labelers 88 + .iter() 89 + .map(|did| did.to_cowstr()) 90 + .collect(), 91 + ); 92 + let request = GetTimeline::new().limit(args.limit).build(); 93 + 94 + println!("\nFetching timeline with {} posts...\n", args.limit); 95 + 96 + let response = agent.send_with_opts(request, opts).await?; 97 + let timeline = response.into_output()?; 98 + 99 + // Apply moderation preferences (default: no adult content) 100 + let prefs = ModerationPrefs::default(); 101 + 102 + let mut filtered = 0; 103 + let mut warned = 0; 104 + let mut clean = 0; 105 + 106 + for feed_post in timeline.feed.iter() { 107 + let post = &feed_post.post; 108 + 109 + // Use Moderateable trait to get moderation decisions for all parts 110 + // (post, author, reply chain) 111 + let decisions = feed_post.moderate_all(&prefs, &defs, &accepted_labelers); 112 + 113 + // Determine overall status from all decisions 114 + if decisions.iter().any(|(_, d)| d.filter) { 115 + filtered += 1; 116 + } else if decisions 117 + .iter() 118 + .any(|(_, d)| d.blur != jacquard::moderation::Blur::None || d.alert) 119 + { 120 + warned += 1; 121 + } else { 122 + clean += 1; 123 + } 124 + 125 + let text = from_data::<Post>(&post.record) 126 + .inspect_err(|e| println!("error: {e}")) 127 + .ok() 128 + .map(|p| p.text.to_string()) 129 + .unwrap_or_else(|| "<no text>".to_string()); 130 + 131 + if let Some(reply) = &feed_post.reply { 132 + if let ReplyRefParent::PostView(parent) = &reply.parent { 133 + if let ReplyRefRoot::PostView(root) = &reply.root { 134 + if root.uri != parent.uri { 135 + let root_text = from_data::<Post>(&root.record) 136 + .ok() 137 + .map(|p| p.text.to_string()) 138 + .unwrap_or_else(|| "<no text>".to_string()); 139 + println!("@{}:\n{}", root.author.handle, root_text); 140 + } 141 + } 142 + let parent_text = from_data::<Post>(&parent.record) 143 + .ok() 144 + .map(|p| p.text.to_string()) 145 + .unwrap_or_else(|| "<no text>".to_string()); 146 + println!("@{}:\n{}", parent.author.handle, parent_text); 147 + } 148 + } 149 + println!("@{}:\n{}", post.author.handle, text); 150 + 151 + // Show details for any part with moderation causes 152 + for (tag, decision) in decisions.iter() { 153 + if !decision.causes.is_empty() { 154 + println!( 155 + " {}: {:?}", 156 + tag, 157 + decision 158 + .causes 159 + .iter() 160 + .map(|c| c.label.as_str()) 161 + .collect::<Vec<_>>() 162 + ); 163 + if decision.filter { 164 + println!(" → Would be hidden"); 165 + } else if decision.blur != jacquard::moderation::Blur::None { 166 + println!(" → Would be blurred ({:?})", decision.blur); 167 + } 168 + if decision.alert { 169 + println!(" → Alert-level warning"); 170 + } 171 + if decision.no_override { 172 + println!(" → User cannot override"); 173 + } 174 + } 175 + } 176 + } 177 + 178 + println!("\n--- Summary ---"); 179 + println!("Total posts: {}", timeline.feed.len()); 180 + println!("Clean: {}", clean); 181 + println!("Warned: {}", warned); 182 + println!("Filtered: {}", filtered); 183 + 184 + Ok(()) 185 + }