A better Rust ATProto crate

ready to cut 0.4.0 release

+194 -9
+85
CHANGELOG.md
··· 1 + # Changelog 2 + 3 + ## [0.4.0] - 2025-10-11 4 + 5 + ### Breaking Changes 6 + 7 + **Zero-copy deserialization** (`jacquard-common`, `jacquard-api`) 8 + - `XrpcRequest` now takes a `'de` lifetime parameter and requires `Deserialize<'de>` 9 + - For raw data, `Response::parse_data()` gives validated loosely-typed atproto data, while `Response::parse_raw()` gives the raw values, with minimal validation. 10 + 11 + **XRPC module moved** (`jacquard-common`) 12 + - `xrpc.rs` is now top-level instead of under `types` 13 + - Import from `jacquard_common::xrpc::*` not `jacquard_common::types::xrpc::*` 14 + 15 + **Response API changes** (`jacquard-common`) 16 + - `XrpcRequest::Output` and `XrpcRequest::Err` are associated types with lifetimes 17 + - Split response and request traits: `XrpcRequest<'de>` for client, `XrpcEndpoint` for server 18 + - Added `XrpcResp` marker trait 19 + 20 + **Various traits** (`jacquard`, `jacquard-common`, `jacquard-lexicon`, `jacquard-oauth`) 21 + - Removed #[async_trait] attribute macro usage in favour of `impl Future` return types with manual bounds. 22 + - Boxing imposed by asyc_trait negatively affected borrowing modes in async methods. 23 + - Currently no semver guarantees on API trait bounds, if they need to tighten, they will. 24 + 25 + ### Added 26 + 27 + **New crate: `jacquard-axum`** 28 + - Server-side XRPC handlers for Axum 29 + - `ExtractXrpc<R>` deserializes incoming requests (query params for Query, body for Procedure) 30 + - Automatic error responses 31 + 32 + **Lexicon codegen fixes** (`jacquard-lexicon`) 33 + - Union variant collision detection: when multiple namespaces have similar type names, foreign ones get prefixed (e.g., `Images` vs `BskyImages`) 34 + - Token types generate unit structs with `Display` instead of being skipped 35 + - Namespace dependency tracking during union generation 36 + - `generate_cargo_features()` outputs Cargo.toml features with correct deps 37 + - `sanitize_name()` ensures valid Rust identifiers 38 + 39 + **Lexicons** (`jacquard-api`) 40 + 41 + Added 646 lexicon schemas. Highlights: 42 + 43 + Core ATProto: 44 + - `com.atproto.*` 45 + - `com.bad-example.*` for identity resolution 46 + 47 + Bluesky: 48 + - `app.bsky.*` bluesky app 49 + - `chat.bsky.*` chat client 50 + - `tools.ozone.*` moderation 51 + 52 + Third-party: 53 + - `sh.tangled.*` - git forge 54 + - `sh.weaver.*` - orual's WIP markdown blog platform 55 + - `pub.leaflet.*` - longform publishing 56 + - `net.anisota.*` - gamified and calming take on bluesky 57 + - `network.slices.*` - serverless atproto hosting 58 + - `tools.smokesignal.*` - automation 59 + - `com.whtwnd.*` - markdown blogging 60 + - `place.stream.*` - livestreaming 61 + - `blue.2048.*` - 2048 game 62 + - `community.lexicon.*` - community extensions (bookmarks, calendar, location, payments) 63 + - `my.skylights.*` - media tracking 64 + - `social.psky.*` - social extensions 65 + - `blue.linkat.*` - link boards 66 + 67 + Plus 30+ more experimental/community namespaces. 68 + 69 + **Value types** (`jacquard-common`) 70 + - `RawData` to `Data` conversion with type inference 71 + - `from_data`, `from_raw_data`, `to_data`, and `to_raw_data` to serialize to and deserialize from the loosely typed value data formats. Particularly useful for second-stage deserialization of type "unknown" fields in lexicons, such as `PostView.record`. 72 + 73 + ### Changed 74 + 75 + - `generate_union()` takes current NSID for dependency tracking 76 + - Generated code uses `sanitize_name()` for identifiers more consistently 77 + - Added derive macro for IntoStatic trait implementation 78 + 79 + ### Fixed 80 + 81 + - Methods to extract the output from an XRPC response now behave well with respect to lifetimes and borrowing. 82 + - Now possible to use jacquard types in places like axum extractors due to lifetime improvements 83 + - Union variants don't collide when multiple namespaces define similar types and another namespace includes them 84 + 85 + ---
+2 -2
Cargo.lock
··· 1874 1874 1875 1875 [[package]] 1876 1876 name = "jacquard-identity" 1877 - version = "0.3.1" 1877 + version = "0.4.0" 1878 1878 dependencies = [ 1879 1879 "async-trait", 1880 1880 "bon", ··· 1926 1926 1927 1927 [[package]] 1928 1928 name = "jacquard-oauth" 1929 - version = "0.3.1" 1929 + version = "0.4.0" 1930 1930 dependencies = [ 1931 1931 "async-trait", 1932 1932 "base64 0.22.1",
-1
crates/jacquard-api/Cargo.toml
··· 34 34 35 35 # --- generated --- 36 36 # Generated namespace features 37 - # Each namespace feature automatically enables its dependencies 38 37 app_blebbit = [] 39 38 app_bsky = ["com_atproto"] 40 39 app_ocho = []
+101 -1
crates/jacquard-common/src/xrpc.rs
··· 21 21 use std::{error::Error, marker::PhantomData}; 22 22 use url::Url; 23 23 24 - use crate::error::TransportError; 25 24 use crate::http_client::HttpClient; 26 25 use crate::types::value::Data; 27 26 use crate::{AuthorizationToken, error::AuthError}; 28 27 use crate::{CowStr, error::XrpcResult}; 29 28 use crate::{IntoStatic, error::DecodeError}; 29 + use crate::{error::TransportError, types::value::RawData}; 30 30 31 31 /// Error type for encoding XRPC requests 32 32 #[derive(Debug, thiserror::Error, miette::Diagnostic)] ··· 541 541 pub fn parse<'s>( 542 542 &'s self, 543 543 ) -> Result<<Resp as XrpcResp>::Output<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> { 544 + // 200: parse as output 545 + if self.status.is_success() { 546 + match serde_json::from_slice::<_>(&self.buffer) { 547 + Ok(output) => Ok(output), 548 + Err(e) => Err(XrpcError::Decode(e)), 549 + } 550 + // 400: try typed XRPC error, fallback to generic error 551 + } else if self.status.as_u16() == 400 { 552 + match serde_json::from_slice::<_>(&self.buffer) { 553 + Ok(error) => Err(XrpcError::Xrpc(error)), 554 + Err(_) => { 555 + // Fallback to generic error (InvalidRequest, ExpiredToken, etc.) 556 + match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 557 + Ok(mut generic) => { 558 + generic.nsid = Resp::NSID; 559 + generic.method = ""; // method info only available on request 560 + generic.http_status = self.status; 561 + // Map auth-related errors to AuthError 562 + match generic.error.as_str() { 563 + "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 564 + "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 565 + _ => Err(XrpcError::Generic(generic)), 566 + } 567 + } 568 + Err(e) => Err(XrpcError::Decode(e)), 569 + } 570 + } 571 + } 572 + // 401: always auth error 573 + } else { 574 + match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 575 + Ok(mut generic) => { 576 + generic.nsid = Resp::NSID; 577 + generic.method = ""; // method info only available on request 578 + generic.http_status = self.status; 579 + match generic.error.as_str() { 580 + "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 581 + "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 582 + _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)), 583 + } 584 + } 585 + Err(e) => Err(XrpcError::Decode(e)), 586 + } 587 + } 588 + } 589 + 590 + /// Parse this as validated, loosely typed atproto data. 591 + /// 592 + /// NOTE: If the response is an error, it will still parse as the matching error type for the request. 593 + pub fn parse_data<'s>(&'s self) -> Result<Data<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> { 594 + // 200: parse as output 595 + if self.status.is_success() { 596 + match serde_json::from_slice::<_>(&self.buffer) { 597 + Ok(output) => Ok(output), 598 + Err(e) => Err(XrpcError::Decode(e)), 599 + } 600 + // 400: try typed XRPC error, fallback to generic error 601 + } else if self.status.as_u16() == 400 { 602 + match serde_json::from_slice::<_>(&self.buffer) { 603 + Ok(error) => Err(XrpcError::Xrpc(error)), 604 + Err(_) => { 605 + // Fallback to generic error (InvalidRequest, ExpiredToken, etc.) 606 + match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 607 + Ok(mut generic) => { 608 + generic.nsid = Resp::NSID; 609 + generic.method = ""; // method info only available on request 610 + generic.http_status = self.status; 611 + // Map auth-related errors to AuthError 612 + match generic.error.as_str() { 613 + "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 614 + "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 615 + _ => Err(XrpcError::Generic(generic)), 616 + } 617 + } 618 + Err(e) => Err(XrpcError::Decode(e)), 619 + } 620 + } 621 + } 622 + // 401: always auth error 623 + } else { 624 + match serde_json::from_slice::<GenericXrpcError>(&self.buffer) { 625 + Ok(mut generic) => { 626 + generic.nsid = Resp::NSID; 627 + generic.method = ""; // method info only available on request 628 + generic.http_status = self.status; 629 + match generic.error.as_str() { 630 + "ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)), 631 + "InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)), 632 + _ => Err(XrpcError::Auth(AuthError::NotAuthenticated)), 633 + } 634 + } 635 + Err(e) => Err(XrpcError::Decode(e)), 636 + } 637 + } 638 + } 639 + 640 + /// Parse this as raw atproto data with minimal validation. 641 + /// 642 + /// NOTE: If the response is an error, it will still parse as the matching error type for the request. 643 + pub fn parse_raw<'s>(&'s self) -> Result<RawData<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> { 544 644 // 200: parse as output 545 645 if self.status.is_success() { 546 646 match serde_json::from_slice::<_>(&self.buffer) {
+1 -1
crates/jacquard-identity/Cargo.toml
··· 1 1 [package] 2 2 name = "jacquard-identity" 3 3 edition.workspace = true 4 - version = "0.3.1" 4 + version = "0.4.0" 5 5 authors.workspace = true 6 6 repository.workspace = true 7 7 keywords.workspace = true
+4 -3
crates/jacquard-lexicon/src/codegen.rs
··· 2606 2606 if let Some(lib_rs) = lib_rs_path { 2607 2607 if let Ok(content) = std::fs::read_to_string(lib_rs) { 2608 2608 for line in content.lines() { 2609 - if let Some(feature) = line.trim() 2609 + if let Some(feature) = line 2610 + .trim() 2610 2611 .strip_prefix("#[cfg(feature = \"") 2611 2612 .and_then(|s| s.strip_suffix("\")]")) 2612 2613 { ··· 2618 2619 2619 2620 let mut output = String::new(); 2620 2621 writeln!(&mut output, "# Generated namespace features").unwrap(); 2621 - writeln!(&mut output, "# Each namespace feature automatically enables its dependencies").unwrap(); 2622 2622 2623 2623 // Convert namespace to feature name (matching module path sanitization) 2624 2624 let to_feature_name = |ns: &str| { ··· 2648 2648 feature_names.sort(); 2649 2649 2650 2650 // Map namespace to feature name for dependency lookup 2651 - let mut ns_to_feature: std::collections::HashMap<&str, String> = std::collections::HashMap::new(); 2651 + let mut ns_to_feature: std::collections::HashMap<&str, String> = 2652 + std::collections::HashMap::new(); 2652 2653 for ns in &all_namespaces { 2653 2654 ns_to_feature.insert(ns.as_str(), to_feature_name(ns)); 2654 2655 }
+1 -1
crates/jacquard-oauth/Cargo.toml
··· 1 1 [package] 2 2 name = "jacquard-oauth" 3 - version = "0.3.1" 3 + version = "0.4.0" 4 4 edition.workspace = true 5 5 description = "AT Protocol OAuth 2.1 core types and helpers for Jacquard" 6 6 authors.workspace = true