A better Rust ATProto crate

reworked workspace deps, descriptions, added example to readme

Changed files
+180 -104
crates
jacquard
jacquard-api
jacquard-common
jacquard-derive
jacquard-lexicon
+31 -2
Cargo.toml
··· 15 15 exclude = [".direnv"] 16 16 17 17 18 - description = "A simple Rust project using Nix" 19 - 18 + description = "Simple and powerful AT Protocol client library for Rust" 20 19 21 20 # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html 22 21 23 22 [workspace.dependencies] 23 + # CLI 24 24 clap = { version = "4.5", features = ["derive"] } 25 + 26 + # Serialization 27 + serde = { version = "1.0", features = ["derive"] } 28 + serde_json = "1.0" 29 + serde_with = "3.14" 30 + serde_html_form = "0.2" 31 + serde_ipld_dagcbor = "0.6" 32 + serde_repr = "0.1" 33 + 34 + # Error handling 35 + miette = "7.6" 36 + thiserror = "2.0" 37 + 38 + # Data types 39 + bytes = "1.10" 40 + smol_str = { version = "0.3", features = ["serde"] } 41 + url = "2.5" 42 + 43 + # Proc macros 44 + proc-macro2 = "1.0" 45 + quote = "1.0" 46 + syn = "2.0" 47 + heck = "0.5" 48 + itertools = "0.14" 49 + prettyplease = "0.2" 50 + 51 + # HTTP 52 + http = "1.3" 53 + reqwest = { version = "0.12", default-features = false }
+71 -26
README.md
··· 2 2 3 3 A suite of Rust crates for the AT Protocol. 4 4 5 + ## Example 6 + 7 + Dead simple api client. Logs in, prints the latest 5 posts from your timeline. 8 + 9 + ```rust 10 + use clap::Parser; 11 + use jacquard::CowStr; 12 + use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 13 + use jacquard::api::com_atproto::server::create_session::CreateSession; 14 + use jacquard::client::{AuthenticatedClient, Session, XrpcClient}; 15 + use miette::IntoDiagnostic; 16 + 17 + #[derive(Parser, Debug)] 18 + #[command(author, version, about = "Jacquard - AT Protocol client demo")] 19 + struct Args { 20 + /// Username/handle (e.g., alice.mosphere.at) 21 + #[arg(short, long)] 22 + username: CowStr<'static>, 23 + 24 + /// PDS URL (e.g., https://bsky.social) 25 + #[arg(long, default_value = "https://bsky.social")] 26 + pds: CowStr<'static>, 27 + 28 + /// App password 29 + #[arg(short, long)] 30 + password: CowStr<'static>, 31 + } 32 + 33 + #[tokio::main] 34 + async fn main() -> miette::Result<()> { 35 + let args = Args::parse(); 36 + 37 + // Create HTTP client 38 + let mut client = AuthenticatedClient::new(reqwest::Client::new(), args.pds); 39 + 40 + // Create session 41 + let session = Session::from( 42 + client 43 + .send( 44 + CreateSession::new() 45 + .identifier(args.username) 46 + .password(args.password) 47 + .build(), 48 + ) 49 + .await? 50 + .into_output()?, 51 + ); 52 + 53 + println!("logged in as {} ({})", session.handle, session.did); 54 + client.set_session(session); 55 + 56 + // Fetch timeline 57 + println!("\nfetching timeline..."); 58 + let timeline = client 59 + .send(GetTimeline::new().limit(5).build()) 60 + .await? 61 + .into_output()?; 62 + 63 + println!("\ntimeline ({} posts):", timeline.feed.len()); 64 + for (i, post) in timeline.feed.iter().enumerate() { 65 + println!("\n{}. by {}", i + 1, post.post.author.handle); 66 + println!( 67 + " {}", 68 + serde_json::to_string_pretty(&post.post.record).into_diagnostic()? 69 + ); 70 + } 71 + 72 + Ok(()) 73 + } 74 + ``` 75 + 5 76 ## Goals 6 77 7 78 - Validated, spec-compliant, easy to work with, and performant baseline types (including typed at:// uris) ··· 29 100 ``` 30 101 31 102 There's also a [`justfile`](https://just.systems/) for Makefile-esque commands to be run inside of the devShell, and you can generally `cargo ...` or `just ...` whatever just fine if you don't want to use Nix and have the prerequisites installed. 32 - 33 - 34 - 35 - ### String types 36 - Something of a note to self. Developing a pattern with the string types (may macro-ify at some point). Each needs: 37 - - new(): constructing from a string slice with the right lifetime that borrows 38 - - new_owned(): constructing from an impl AsRef<str>, taking ownership 39 - - new_static(): construction from a &'static str, using SmolStr's/CowStr's new_static() constructor to not allocate 40 - - raw(): same as new() but panics instead of erroring 41 - - unchecked(): same as new() but doesn't validate. marked unsafe. 42 - - as_str(): does what it says on the tin 43 - #### Traits: 44 - - Serialize + Deserialize (custom impl for latter, sometimes for former) 45 - - FromStr 46 - - Display 47 - - Debug, PartialEq, Eq, Hash, Clone 48 - - From<T> for String, CowStr, SmolStr, 49 - - From<String>, From<CowStr>, From<SmolStr>, or TryFrom if likely enough to fail in practice to make panics common 50 - - AsRef<str> 51 - - Deref with Target = str (usually) 52 - 53 - Use `#[repr(transparent)]` as much as possible. Main exception is at-uri type and components. 54 - Use SmolStr directly as the inner type if most or all of the instances will be under 24 bytes, save lifetime headaches. 55 - Use CowStr for longer to allow for borrowing from input. 56 - 57 - TODO: impl IntoStatic trait to take ownership of string types
+8 -8
crates/jacquard-api/Cargo.toml
··· 1 1 [package] 2 2 name = "jacquard-api" 3 + description = "Generated AT Protocol API bindings for Jacquard" 3 4 edition.workspace = true 4 5 version.workspace = true 5 6 authors.workspace = true ··· 7 8 keywords.workspace = true 8 9 categories.workspace = true 9 10 readme.workspace = true 10 - documentation.workspace = true 11 + documentation = "https://docs.rs/jacquard-api" 11 12 exclude.workspace = true 12 - description.workspace = true 13 13 14 14 [features] 15 15 default = [ "com_atproto"] ··· 20 20 21 21 [dependencies] 22 22 bon = "3" 23 - bytes = { version = "1.10.1", features = ["serde"] } 24 - jacquard-common = { version = "0.1.0", path = "../jacquard-common" } 25 - jacquard-derive = { version = "0.1.0", path = "../jacquard-derive" } 26 - miette = "7.6.0" 27 - serde = { version = "1.0.228", features = ["derive"] } 28 - thiserror = "2.0.17" 23 + bytes = { workspace = true, features = ["serde"] } 24 + jacquard-common = { path = "../jacquard-common" } 25 + jacquard-derive = { path = "../jacquard-derive" } 26 + miette.workspace = true 27 + serde.workspace = true 28 + thiserror.workspace = true
+11 -11
crates/jacquard-common/Cargo.toml
··· 1 1 [package] 2 2 name = "jacquard-common" 3 + description = "Core AT Protocol types and utilities for Jacquard" 3 4 edition.workspace = true 4 5 version.workspace = true 5 6 authors.workspace = true ··· 7 8 keywords.workspace = true 8 9 categories.workspace = true 9 10 readme.workspace = true 10 - documentation.workspace = true 11 + documentation = "https://docs.rs/jacquard-common" 11 12 exclude.workspace = true 12 - description.workspace = true 13 13 14 14 15 15 16 16 [dependencies] 17 17 base64 = "0.22.1" 18 - bytes = "1.10.1" 18 + bytes.workspace = true 19 19 chrono = "0.4.42" 20 20 cid = { version = "0.11.1", features = ["serde", "std"] } 21 21 enum_dispatch = "0.3.13" 22 22 ipld-core = { version = "0.4.2", features = ["serde"] } 23 23 langtag = { version = "0.4.0", features = ["serde"] } 24 - miette = "7.6.0" 24 + miette.workspace = true 25 25 multibase = "0.9.1" 26 26 multihash = "0.19.3" 27 27 num-traits = "0.2.19" 28 28 ouroboros = "0.18.5" 29 29 rand = "0.9.2" 30 30 regex = "1.11.3" 31 - serde = { version = "1.0.227", features = ["derive"] } 32 - serde_html_form = "0.2.8" 33 - serde_json = "1.0.145" 34 - serde_with = "3.14.1" 35 - smol_str = { version = "0.3.2", features = ["serde"] } 36 - thiserror = "2.0.16" 37 - url = "2.5.7" 31 + serde.workspace = true 32 + serde_html_form.workspace = true 33 + serde_json.workspace = true 34 + serde_with.workspace = true 35 + smol_str.workspace = true 36 + thiserror.workspace = true 37 + url.workspace = true
+13 -13
crates/jacquard-derive/Cargo.toml
··· 1 1 [package] 2 2 name = "jacquard-derive" 3 + description = "Procedural macros for Jacquard lexicon types" 3 4 edition.workspace = true 4 5 version.workspace = true 5 6 authors.workspace = true ··· 7 8 keywords.workspace = true 8 9 categories.workspace = true 9 10 readme.workspace = true 10 - documentation.workspace = true 11 + documentation = "https://docs.rs/jacquard-derive" 11 12 exclude.workspace = true 12 - description.workspace = true 13 13 14 14 [lib] 15 15 proc-macro = true 16 16 17 17 [dependencies] 18 - heck = "0.5.0" 19 - itertools = "0.14.0" 20 - prettyplease = "0.2.37" 21 - proc-macro2 = "1.0.101" 22 - quote = "1.0.41" 23 - serde = { version = "1.0.228", features = ["derive"] } 24 - serde_json = "1.0.145" 25 - serde_repr = "0.1.20" 26 - serde_with = "3.14.1" 27 - syn = "2.0.106" 18 + heck.workspace = true 19 + itertools.workspace = true 20 + prettyplease.workspace = true 21 + proc-macro2.workspace = true 22 + quote.workspace = true 23 + serde.workspace = true 24 + serde_json.workspace = true 25 + serde_repr.workspace = true 26 + serde_with.workspace = true 27 + syn.workspace = true 28 28 29 29 30 30 [dev-dependencies] 31 - jacquard-common = { version = "0.1.0", path = "../jacquard-common" } 31 + jacquard-common = { path = "../jacquard-common" }
+15 -15
crates/jacquard-lexicon/Cargo.toml
··· 1 1 [package] 2 2 name = "jacquard-lexicon" 3 + description = "Lexicon schema parsing and code generation for Jacquard" 3 4 edition.workspace = true 4 5 version.workspace = true 5 6 authors.workspace = true ··· 9 10 readme.workspace = true 10 11 documentation.workspace = true 11 12 exclude.workspace = true 12 - description.workspace = true 13 13 14 14 [[bin]] 15 15 name = "jacquard-codegen" 16 16 path = "src/bin/codegen.rs" 17 17 18 18 [dependencies] 19 - clap = { workspace = true } 20 - heck = "0.5.0" 21 - itertools = "0.14.0" 22 - jacquard-common = { version = "0.1.0", path = "../jacquard-common" } 23 - miette = { version = "7.6.0", features = ["fancy"] } 24 - prettyplease = "0.2.37" 25 - proc-macro2 = "1.0.101" 26 - quote = "1.0.41" 27 - serde = { version = "1.0.228", features = ["derive"] } 28 - serde_json = "1.0.145" 29 - serde_repr = "0.1.20" 30 - serde_with = "3.14.1" 31 - syn = "2.0.106" 32 - thiserror = "2.0.17" 19 + clap.workspace = true 20 + heck.workspace = true 21 + itertools.workspace = true 22 + jacquard-common = { path = "../jacquard-common" } 23 + miette = { workspace = true, features = ["fancy"] } 24 + prettyplease.workspace = true 25 + proc-macro2.workspace = true 26 + quote.workspace = true 27 + serde.workspace = true 28 + serde_json.workspace = true 29 + serde_repr.workspace = true 30 + serde_with.workspace = true 31 + syn.workspace = true 32 + thiserror.workspace = true
+12 -12
crates/jacquard/Cargo.toml
··· 1 1 [package] 2 2 name = "jacquard" 3 - description = "Simple and powerful AT Procotol implementation" 3 + description.workspace = true 4 4 edition.workspace = true 5 5 version.workspace = true 6 6 authors.workspace = true ··· 26 26 path = "src/main.rs" 27 27 28 28 [dependencies] 29 - bytes = "1.10" 30 - clap = { workspace = true } 31 - http = "1.3.1" 32 - jacquard-api = { version = "0.1.0", path = "../jacquard-api" } 29 + bytes.workspace = true 30 + clap.workspace = true 31 + http.workspace = true 32 + jacquard-api = { path = "../jacquard-api" } 33 33 jacquard-common = { path = "../jacquard-common" } 34 34 jacquard-derive = { path = "../jacquard-derive", optional = true } 35 - miette = "7.6.0" 36 - reqwest = { version = "0.12.23", default-features = false, features = ["charset", "http2", "json", "system-proxy", "gzip", "rustls-tls"] } 37 - serde = { version = "1.0", features = ["derive"] } 38 - serde_html_form = "0.2" 39 - serde_ipld_dagcbor = "0.6.4" 40 - serde_json = "1.0" 41 - thiserror = "2.0" 35 + miette.workspace = true 36 + reqwest = { workspace = true, features = ["charset", "http2", "json", "system-proxy", "gzip", "rustls-tls"] } 37 + serde.workspace = true 38 + serde_html_form.workspace = true 39 + serde_ipld_dagcbor.workspace = true 40 + serde_json.workspace = true 41 + thiserror.workspace = true 42 42 tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
+19 -17
crates/jacquard/src/main.rs
··· 1 1 use clap::Parser; 2 + use jacquard::CowStr; 3 + use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 4 + use jacquard::api::com_atproto::server::create_session::CreateSession; 2 5 use jacquard::client::{AuthenticatedClient, Session, XrpcClient}; 3 - use jacquard_api::app_bsky::feed::get_timeline::GetTimeline; 4 - use jacquard_api::com_atproto::server::create_session::CreateSession; 5 - use jacquard_common::CowStr; 6 6 use miette::IntoDiagnostic; 7 7 8 8 #[derive(Parser, Debug)] ··· 20 20 #[arg(short, long)] 21 21 password: CowStr<'static>, 22 22 } 23 - 24 23 #[tokio::main] 25 24 async fn main() -> miette::Result<()> { 26 25 let args = Args::parse(); 27 26 28 27 // Create HTTP client 29 - let http = reqwest::Client::new(); 30 - let mut client = AuthenticatedClient::new(http, args.pds); 28 + let mut client = AuthenticatedClient::new(reqwest::Client::new(), args.pds); 31 29 32 30 // Create session 33 - println!("logging in as {}...", args.username); 34 - let create_session = CreateSession::new() 35 - .identifier(args.username) 36 - .password(args.password) 37 - .build(); 38 - 39 - let session_output = client.send(create_session).await?.into_output()?; 40 - let session = Session::from(session_output); 31 + let session = Session::from( 32 + client 33 + .send( 34 + CreateSession::new() 35 + .identifier(args.username) 36 + .password(args.password) 37 + .build(), 38 + ) 39 + .await? 40 + .into_output()?, 41 + ); 41 42 42 43 println!("logged in as {} ({})", session.handle, session.did); 43 44 client.set_session(session); 44 45 45 46 // Fetch timeline 46 47 println!("\nfetching timeline..."); 47 - let timeline_req = GetTimeline::new().limit(5).build(); 48 - 49 - let timeline = client.send(timeline_req).await?.into_output()?; 48 + let timeline = client 49 + .send(GetTimeline::new().limit(5).build()) 50 + .await? 51 + .into_output()?; 50 52 51 53 println!("\ntimeline ({} posts):", timeline.feed.len()); 52 54 for (i, post) in timeline.feed.iter().enumerate() {