A better Rust ATProto crate
103
fork

Configure Feed

Select the types of activity you want to include in your feed.

changelog, data deserialization bugfix, readme update, mod example

Orual adab0d47 ef09feb7

+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 + }