A better Rust ATProto crate
0

Configure Feed

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

preliminary docs fixes, some improvements to the atproto!() macro

author nonbinary.computer date (Jun 7, 2026, 8:21 PM -0400) commit ef760453 parent 70567f3e change-id xqlukypv
+264 -248
+23 -28
README.md
··· 6 6 7 7 [Jacquard is simpler](https://alpha.weaver.sh/nonbinary.computer/jacquard/jacquard_magic) because it is designed in a way which makes things simple that almost every other atproto library seems to make difficult. 8 8 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. 9 + Jacquard generated types are generic over their string backing, but ordinary client code can usually ignore that detail. Use the generated request builders, pass normal strings, and call `.send(...).into_output()?` to get owned output that is easy to store, move independently of the response buffer, and pass through frameworks or APIs that require `DeserializeOwned`. If you need tighter control later, Jacquard still supports borrowing and zero-copy parsing with backing types such as `&str` and `CowStr<'_>`. 10 10 11 11 12 12 ## Features ··· 26 26 27 27 ## Example 28 28 29 - Dead simple API client. Logs in with OAuth and prints the latest 5 posts from your timeline. 29 + Dead simple API client. Resumes a stored OAuth session or opens a browser login, then prints the latest 5 posts from your timeline. This is the default path for local scripts and CLIs where browser login is acceptable; app-password credential sessions are mainly for unattended workflows that must re-authenticate non-interactively. 30 30 31 31 ```rust 32 - // Note: this requires the `loopback` feature enabled (it is currently by default) 33 - use clap::Parser; 34 - use jacquard::CowStr; 32 + // Note: this requires the `loopback` feature enabled (it is currently by default). 35 33 use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 36 34 use jacquard::client::{Agent, FileAuthStore}; 35 + use jacquard::common::session::SessionHint; 37 36 use jacquard::oauth::client::OAuthClient; 38 37 use jacquard::oauth::loopback::LoopbackConfig; 39 - use jacquard::types::xrpc::XrpcClient; 38 + use jacquard::oauth::types::AuthorizeOptions; 39 + use jacquard::xrpc::XrpcClient; 40 40 use miette::IntoDiagnostic; 41 41 42 - #[derive(Parser, Debug)] 43 - #[command(author, version, about = "Jacquard - OAuth (DPoP) loopback demo")] 44 - struct Args { 45 - /// Handle (e.g., alice.bsky.social), DID, or PDS URL 46 - input: CowStr<'static>, 47 - 48 - /// Path to auth store file (will be created if missing) 49 - #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")] 50 - store: String, 51 - } 42 + const STORE_PATH: &str = "/tmp/jacquard-oauth-session.json"; 52 43 53 44 #[tokio::main] 54 45 async fn main() -> miette::Result<()> { 55 - let args = Args::parse(); 46 + let login_hint = std::env::args().nth(1); 47 + let oauth = OAuthClient::with_default_config(FileAuthStore::new(STORE_PATH)); 48 + let hint = SessionHint::from_optional_input(login_hint.as_deref()); 56 49 57 - // Build an OAuth client with file-backed auth store and default localhost config 58 - let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store)); 59 - // Authenticate with a PDS, using a loopback server to handle the callback flow 60 - let session = oauth 61 - .login_with_local_server( 62 - args.input.clone(), 63 - Default::default(), 50 + let Some(session) = oauth 51 + .resume_or_login_with_local_server( 52 + &hint, 53 + AuthorizeOptions::default(), 64 54 LoopbackConfig::default(), 65 55 ) 66 - .await?; 67 - // Wrap in Agent and fetch the timeline 56 + .await? 57 + else { 58 + miette::bail!( 59 + "no stored OAuth session found in {STORE_PATH}; pass a handle, DID, or PDS URL to log in" 60 + ); 61 + }; 62 + 68 63 let agent: Agent<_> = Agent::from(session); 69 64 let timeline = agent 70 - .send(&GetTimeline::new().limit(5).build()) 65 + .send(GetTimeline::new().limit(5).build()) 71 66 .await? 72 67 .into_output()?; 68 + 73 69 for (i, post) in timeline.feed.iter().enumerate() { 74 70 println!("\n{}. by {}", i + 1, post.post.author.handle); 75 71 println!( ··· 80 76 81 77 Ok(()) 82 78 } 83 - 84 79 ``` 85 80 86 81 If you have `just` installed, you can run the [examples](https://tangled.org/nonbinary.computer/jacquard/tree/main/examples) using `just example {example-name} {ARGS}` or `just examples` to see what's available.
+50 -99
crates/jacquard-common/src/lib.rs
··· 67 67 //! const NSID: &'static str; 68 68 //! /// Output encoding (MIME type) 69 69 //! const ENCODING: &'static str; 70 - //! type Output<'de>: Deserialize<'de> + IntoStatic; 71 - //! type Err<'de>: Error + Deserialize<'de> + IntoStatic; 70 + //! type Output<S: BosStr>; 71 + //! type Err: Error + Serialize + DeserializeOwned; 72 72 //! } 73 73 //! ``` 74 74 //! Here are the implementations for [`GetTimeline`](https://tangled.org/@nonbinary.computer/jacquard/blob/main/crates/jacquard-api/src/app_bsky/feed/get_timeline.rs). You'll also note that `send()` doesn't return the fully decoded response on success. It returns a Response struct which has a generic parameter that must implement the XrpcResp trait above. Here's its definition. It's essentially just a cheaply cloneable byte buffer and a type marker. ··· 81 81 //! } 82 82 //! 83 83 //! impl<R: XrpcResp> Response<R> { 84 - //! pub fn parse<'s>( 85 - //! &'s self 86 - //! ) -> Result<<Resp as XrpcResp>::Output<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> { 87 - //! // Borrowed parsing into Output or Err 84 + //! pub fn parse<'s, S>(&'s self) -> Result<R::Output<S>, XrpcError<R::Err>> 85 + //! where 86 + //! S: BosStr + Deserialize<'s>, 87 + //! R::Output<S>: Deserialize<'s>, 88 + //! { 89 + //! // Parse with the caller's chosen string backing. 88 90 //! } 89 - //! pub fn into_output( 90 - //! self 91 - //! ) -> Result<<Resp as XrpcResp>::Output<'static>, XrpcError<<Resp as XrpcResp>::Err<'static>>> 92 - //! where ... 93 - //! { /* Owned parsing into Output or Err */ } 91 + //! pub fn into_output(self) -> Result<R::Output<SmolStr>, XrpcError<R::Err>> 92 + //! where 93 + //! R::Output<SmolStr>: DeserializeOwned, 94 + //! { 95 + //! // Parse into owned/default-backed output. 96 + //! } 94 97 //! } 95 98 //! ``` 96 - //! You decode the response (or the endpoint-specific error) out of this, borrowing from the buffer or taking ownership so you can drop the buffer. There are two reasons for this. One is separation of concerns. By two-staging the parsing, it's easier to distinguish network and authentication problems from application-level errors. The second is lifetimes and borrowed deserialization. 99 + //! You decode the response (or the endpoint-specific error) out of this when you are ready. There 100 + //! are two reasons for this. One is separation of concerns: by two-staging the parsing, it is easier 101 + //! to distinguish network and authentication problems from application-level errors. The second is 102 + //! string backing: callers can choose ordinary owned output or explicit borrowed parsing. 97 103 //! 98 - //! ## Working with Lifetimes and Zero-Copy Deserialization 104 + //! ## String backing, borrowing, and response parsing 99 105 //! 100 - //! Jacquard is designed around zero-copy/borrowed deserialization: types like [`Post<'a>`](https://tangled.org/@nonbinary.computer/jacquard/blob/main/crates/jacquard-api/src/app_bsky/feed/post.rs) can borrow strings and other data directly from the response buffer instead of allocating owned copies. This is great for performance, but it creates some interesting challenges, especially in async contexts. So how do you specify the lifetime of the borrow? 106 + //! Most generated output types are parameterized over a string backing type: `Output<S: BosStr>`. 107 + //! The usual path is owned output: 101 108 //! 102 - //! The naive approach would be to put a lifetime parameter on the trait itself: 109 + //! ```ignore 110 + //! let output = response.into_output()?; 111 + //! ``` 103 112 //! 104 - //!```ignore 105 - //!// This looks reasonable but creates problems in generic/async contexts 106 - //!trait NaiveXrpcRequest<'de> { 107 - //! type Output: Deserialize<'de>; 108 - //! // ... 109 - //!} 110 - //!``` 113 + //! `into_output()` parses into `SmolStr`-backed output. This is the convenient default when values 114 + //! need to be stored, moved independently of the response buffer, or passed through frameworks and 115 + //! APIs that require `DeserializeOwned`. 111 116 //! 112 - //! This looks reasonable until you try to use it in a generic context. If you have a function that works with *any* lifetime, you need a Higher-ranked trait bound: 117 + //! When you specifically want to borrow from the response buffer, choose a borrowed or 118 + //! borrow-or-own backing at parse time: 113 119 //! 114 - //!```ignore 115 - //!fn parse<R>(response: &[u8]) ... // return type 116 - //!where 117 - //! R: for<'any> XrpcRequest<'any> 118 - //!{ /* deserialize from response... */ } 119 - //!``` 120 + //! ```ignore 121 + //! let output = response.parse::<CowStr<'_>>()?; 122 + //! let output = response.parse::<&str>()?; 123 + //! ``` 120 124 //! 121 - //! The `for<'any>` bound says "this type must implement `XrpcRequest` for *every possible lifetime*", which, for `Deserialize`, is effectively the same as requiring `DeserializeOwned`. You've probably just thrown away your zero-copy optimization, and furthermore that trait bound just straight-up won't work on most of the types in Jacquard. The vast majority of them have either a custom Deserialize implementation which will borrow if it can, a `#[serde(borrow)]` attribute on one or more fields, or an equivalent lifetime bound attribute, associated with the Deserialize derive macro. You will get "Deserialize implementation not general enough" if you try. And no, you cannot have an additional deserialize implementation for the `'static` lifetime due to how serde works. 125 + //! Borrowed output works fine across async code as long as the value remains tied to the 126 + //! buffer-owning `Response`. The `.send()` method itself can stay lifetime-free because it returns 127 + //! that buffer-owning response, and the caller decides whether to parse into owned data or borrow 128 + //! from the buffer. 122 129 //! 123 - //! If you instead try something like the below function signature and specify a specific lifetime, it will compile in isolation, but when you go to use it, the Rust compiler will not generally be able to figure out the lifetimes at the call site, and will complain about things being dropped while still borrowed, even if you convert the response to an owned/ `'static` lifetime version of the type. 130 + //! `XrpcResp` stays lifetime-free too. Its success output is a GAT over the backing string type, 131 + //! and its error type is a plain owned type: 124 132 //! 125 - //!```ignore 126 - //!fn parse<'s, R: XrpcRequest<'s>>(response: &'s [u8]) ... // return type with the same lifetime 127 - //!{ /* deserialize from response... */ } 128 - //!``` 129 - //! 130 - //! It gets worse with async. If you want to return borrowed data from an async method, where does the lifetime come from? The response buffer needs to outlive the borrow, but the buffer is consumed or potentially has to have an unbounded lifetime. You end up with confusing and frustrating errors because the compiler can't prove the buffer will stay alive or that you have taken ownership of the parts of it you care about. You *could* do some lifetime laundering with `unsafe`, but that road leads to potential soundness issues, and besides, you don't actually *need* to tell `rustc` to "trust me, bro", you can, with some cleverness, explain this to the compiler in a way that it can reason about perfectly well. 131 - //! 132 - //! ### Explaining where the buffer goes to `rustc` 133 - //! 134 - //! The fix is to use Generic Associated Types (GATs) on the trait's associated types, while keeping the trait itself lifetime-free: 135 - //! 136 - //!```ignore 137 - //!pub trait XrpcResp { 138 - //! const NSID: &'static str; 139 - //! /// Output encoding (MIME type) 140 - //! const ENCODING: &'static str; 141 - //! type Output<'de>: Deserialize<'de> + IntoStatic; 142 - //! type Err<'de>: Error + Deserialize<'de> + IntoStatic; 143 - //!} 144 - //!``` 145 - //! 146 - //!Now you can write trait bounds without HRTBs, and with lifetime bounds that are actually possible for Jacquard's borrowed deserializing types to meet: 147 - //! 148 - //!```ignore 149 - //!fn parse<'s, R: XrpcResp>(response: &'s [u8]) /* return type with same lifetime */ { 150 - //! // Compiler can pick a concrete lifetime for R::Output<'_> or have it specified easily 151 - //!} 152 - //!``` 153 - //! 154 - //!Methods that need lifetimes use method-level generic parameters: 155 - //! 156 - //!```ignore 157 - //!// This is part of a trait from jacquard itself, used to genericize updates to things like the Bluesky 158 - //!// preferences union, so that if you implement a similar lexicon type in your app, you don't have 159 - //!// to special-case it. Instead you can do a relatively simple trait implementation and then call 160 - //!// .update_vec() with a modifier function or .update_vec_item() with a single item you want to set. 161 - // 162 - //!pub trait VecUpdate { 163 - //! type GetRequest: XrpcRequest; 164 - //! type PutRequest: XrpcRequest; 165 - //! //... more stuff 166 - // 167 - //! //Method-level lifetime, GAT on response type 168 - //! fn extract_vec<'s>( 169 - //! output: <<Self::GetRequest as XrpcRequest>::Response as XrpcResp>::Output<'s> 170 - //! ) -> Vec<Self::Item>; 171 - //! //... more stuff 172 - //!} 173 - //!``` 133 + //! ```ignore 134 + //! pub trait XrpcResp { 135 + //! const NSID: &'static str; 136 + //! const ENCODING: &'static str; 137 + //! type Output<S: BosStr>; 138 + //! type Err: Error + Serialize + DeserializeOwned; 139 + //! } 140 + //! ``` 174 141 //! 175 - //!The compiler can monomorphize for concrete lifetimes instead of trying to prove bounds hold for *all* lifetimes at once, or struggle to figure out when you're done with a buffer. `XrpcResp` being separate and lifetime-free lets async methods like `.send()` return a `Response` that owns the response buffer, and then the *caller* decides the lifetime strategy: 176 - //! 177 - //!```ignore 178 - //!// Zero-copy: borrow from the owned buffer 179 - //!let output: R::Output<'_> = response.parse()?; 180 - // 181 - //!// Owned: convert to 'static via IntoStatic 182 - //!let output: R::Output<'static> = response.into_output()?; 183 - //!``` 184 - //! 185 - //! The async method doesn't need to know or care about lifetimes for the most part - it just returns the `Response`. The caller gets full control over whether to use borrowed or owned data. It can even decide after the fact that it doesn't want to parse out the API response type that it asked for. Instead it can call `.parse_data()` or `.parse_raw()` on the response to get loosely typed, validated data or minimally typed maximally accepting data values out. 186 - //! 187 - //! When you see types like `Response<R: XrpcResp>` or methods with lifetime parameters, 188 - //! this is the pattern at work. It looks a bit funky, but it's solving a specific problem 189 - //! in a way that doesn't require unsafe code or much actual work from you, if you're using it. 190 - //! It's also not too bad to write, once you're aware of the pattern and why it works. If you run 191 - //! into a lifetime/borrowing inference issue in jacquard, please contact the crate author. She'd 192 - //! be happy to debug, and if it's using a method from one of the jacquard crates and seems like 193 - //! it *should* just work, that is a bug in jacquard, and you should [file an issue](https://tangled.org/nonbinary.computer/jacquard/). 142 + //! Keeping endpoint errors owned avoids lifetime gymnastics on the unhappy path. If you do not want 143 + //! to parse the endpoint-specific success type, `Response` also supports `.parse_data()` and 144 + //! `.parse_raw()` for loosely typed atproto data. 194 145 195 146 #![no_std] 196 147 #![warn(missing_docs)]
+121 -16
crates/jacquard-common/src/macros.rs
··· 1 1 //! `atproto!` macro. 2 - /// Construct a atproto `Data<'_>` value from a literal. 2 + 3 + use crate::{DefaultStr, FromStaticStr, types::value::Data}; 4 + 5 + /// Hidden conversion hook used by the [`atproto!`] macro. 6 + #[doc(hidden)] 7 + pub trait AtprotoMacroLiteral { 8 + /// Convert this literal into default-backed AT Protocol data. 9 + fn into_atproto_data(self) -> Data; 10 + } 11 + 12 + impl AtprotoMacroLiteral for &'static str { 13 + fn into_atproto_data(self) -> Data { 14 + Data::String(crate::types::string::AtprotoStr::new( 15 + DefaultStr::from_static(self), 16 + )) 17 + } 18 + } 19 + 20 + macro_rules! impl_atproto_macro_integer_literal { 21 + ($($ty:ty),* $(,)?) => { 22 + $( 23 + impl AtprotoMacroLiteral for $ty { 24 + fn into_atproto_data(self) -> Data { 25 + Data::Integer(i64::from(self)) 26 + } 27 + } 28 + )* 29 + }; 30 + } 31 + 32 + macro_rules! impl_atproto_macro_checked_integer_literal { 33 + ($($ty:ty),* $(,)?) => { 34 + $( 35 + impl AtprotoMacroLiteral for $ty { 36 + fn into_atproto_data(self) -> Data { 37 + Data::Integer(self.try_into().expect("integer literal exceeds the AT Protocol i64 range")) 38 + } 39 + } 40 + )* 41 + }; 42 + } 43 + 44 + impl_atproto_macro_integer_literal!(i8, i16, i32, u8, u16, u32); 45 + impl_atproto_macro_checked_integer_literal!(i64, i128, isize, u64, u128, usize); 46 + 47 + /// Construct a default-backed atproto [`Data`] value from a literal. 48 + /// 49 + /// [`Data`]: crate::types::value::Data 3 50 /// 4 51 /// ``` 5 52 /// # use jacquard_common::atproto; ··· 18 65 /// 19 66 /// Variables or expressions can be interpolated into the ATProto literal. Any type 20 67 /// interpolated into an array element or object value must implement Serde's 21 - /// `Serialize` trait, while any type interpolated into a object key must 22 - /// implement `Into<String>`. If the `Serialize` implementation of the 23 - /// interpolated type decides to fail, or if the interpolated type contains a 24 - /// map with non-string keys, the `atproto!` macro will panic. 68 + /// `Serialize` trait, while any type interpolated into an object key must 69 + /// convert into the default string backing. If the `Serialize` implementation 70 + /// of the interpolated type decides to fail, or if the interpolated type 71 + /// contains a map with non-string keys, the `atproto!` macro will panic. 25 72 /// 26 73 /// ``` 27 74 /// # use jacquard_common::atproto; ··· 138 185 139 186 // Insert the current entry followed by trailing comma. 140 187 (@object $object:ident [$($key:tt)+] ($value:expr) , $($rest:tt)*) => { 141 - let _ = $object.insert(($($key)+).into(), $value); 188 + let _ = $object.insert(atproto_internal_key!($($key)+), $value); 142 189 atproto_internal!(@object $object () ($($rest)*) ($($rest)*)); 143 190 }; 144 191 ··· 149 196 150 197 // Insert the last entry without trailing comma. 151 198 (@object $object:ident [$($key:tt)+] ($value:expr)) => { 152 - let _ = $object.insert(($($key)+).into(), $value); 199 + let _ = $object.insert(atproto_internal_key!($($key)+), $value); 153 200 }; 154 201 155 202 // Next value is `null`. ··· 230 277 ////////////////////////////////////////////////////////////////////////// 231 278 232 279 (null) => { 233 - $crate::types::value::Data::Null 280 + $crate::types::value::Data::<$crate::DefaultStr>::Null 234 281 }; 235 282 236 283 (true) => { 237 - $crate::types::value::Data::Boolean(true) 284 + $crate::types::value::Data::<$crate::DefaultStr>::Boolean(true) 238 285 }; 239 286 240 287 (false) => { 241 - $crate::types::value::Data::Boolean(false) 288 + $crate::types::value::Data::<$crate::DefaultStr>::Boolean(false) 242 289 }; 243 290 244 291 ([]) => { 245 - $crate::types::value::Data::Array($crate::types::value::Array(atproto_internal_vec![])) 292 + $crate::types::value::Data::<$crate::DefaultStr>::Array($crate::types::value::Array(atproto_internal_vec![])) 246 293 }; 247 294 248 295 ([ $($tt:tt)+ ]) => { 249 - $crate::types::value::Data::Array($crate::types::value::Array(atproto_internal!(@array [] $($tt)+))) 296 + $crate::types::value::Data::<$crate::DefaultStr>::Array($crate::types::value::Array(atproto_internal!(@array [] $($tt)+))) 250 297 }; 251 298 252 299 ({}) => { 253 - $crate::types::value::Data::Object($crate::types::value::Object(::std::collections::BTreeMap::new())) 300 + $crate::types::value::Data::<$crate::DefaultStr>::Object($crate::types::value::Object(::std::collections::BTreeMap::new())) 254 301 }; 255 302 256 303 ({ $($tt:tt)+ }) => { 257 - $crate::types::value::Data::Object($crate::types::value::Object({ 304 + $crate::types::value::Data::<$crate::DefaultStr>::Object($crate::types::value::Object({ 258 305 let mut object = ::std::collections::BTreeMap::new(); 259 306 atproto_internal!(@object object () ($($tt)+) ($($tt)+)); 260 307 object 261 308 })) 262 309 }; 263 310 264 - // Any Serialize type: numbers, strings, struct literals, variables etc. 311 + // Literal values go through a helper so string literals can use static 312 + // storage while integer literals remain integers. 313 + ($literal:literal) => { 314 + $crate::macros::AtprotoMacroLiteral::into_atproto_data($literal) 315 + }; 316 + 317 + // Any Serialize type: variables, struct literals, dynamic strings etc. 265 318 // Must be below every other rule. 266 319 ($other:expr) => { 267 320 { 268 - $crate::types::value::Data::from($other) 321 + $crate::types::value::Data::<$crate::DefaultStr>::from($other) 269 322 } 270 323 }; 271 324 } ··· 283 336 284 337 #[macro_export] 285 338 #[doc(hidden)] 339 + macro_rules! atproto_internal_key { 340 + ($key:literal) => { 341 + <$crate::DefaultStr as $crate::FromStaticStr>::from_static($key) 342 + }; 343 + 344 + ($key:expr) => { 345 + ($key).into() 346 + }; 347 + } 348 + 349 + #[macro_export] 350 + #[doc(hidden)] 286 351 macro_rules! atproto_unexpected { 287 352 () => {}; 288 353 } 354 + 355 + #[cfg(test)] 356 + mod tests { 357 + use crate::{DefaultStr, types::value::Data}; 358 + 359 + const LONG_KEY: &str = "a-static-key-that-is-longer-than-inline-capacity"; 360 + const LONG_VALUE: &str = "a static string value that is longer than inline capacity"; 361 + 362 + #[test] 363 + fn string_literals_use_static_default_backing() { 364 + let value = atproto!({ 365 + "a-static-key-that-is-longer-than-inline-capacity": 366 + "a static string value that is longer than inline capacity" 367 + }); 368 + 369 + let Data::Object(object) = value else { 370 + panic!("expected object"); 371 + }; 372 + let (key, value) = object.0.iter().next().expect("object has one field"); 373 + 374 + assert_eq!(key.as_str(), LONG_KEY); 375 + assert!(!key.is_heap_allocated()); 376 + 377 + let Data::String(string) = value else { 378 + panic!("expected string value"); 379 + }; 380 + assert_eq!(string.as_str(), LONG_VALUE); 381 + if let crate::types::string::AtprotoStr::String(backing) = string { 382 + assert!(!backing.is_heap_allocated()); 383 + } else { 384 + panic!("test value should not be inferred as a richer atproto string type"); 385 + } 386 + } 387 + 388 + #[test] 389 + fn macro_result_defaults_to_default_backing_without_context() { 390 + let value = atproto!(["hello", 200, true, null]); 391 + let _: Data<DefaultStr> = value; 392 + } 393 + }
+3 -3
crates/jacquard-common/src/types/value.rs
··· 708 708 /// 709 709 /// # Example 710 710 /// ``` 711 - /// # use jacquard_common::types::value::{Data, from_data}; 711 + /// # use jacquard_common::{atproto, Data}; 712 + /// # use jacquard_common::types::value::from_data; 712 713 /// # use serde::Deserialize; 713 714 /// # 714 715 /// #[derive(Deserialize)] ··· 720 721 /// } 721 722 /// 722 723 /// # fn example() -> Result<(), Box<dyn std::error::Error>> { 723 - /// # let json = serde_json::json!({"text": "hello", "author": "alice"}); 724 - /// # let data = Data::from_json(&json)?; 724 + /// # let data: Data = atproto!({"text": "hello", "author": "alice"}); 725 725 /// let post: Post = from_data(&data)?; 726 726 /// # Ok(()) 727 727 /// # }
+4 -4
crates/jacquard-common/src/xrpc.rs
··· 469 469 /// # #[tokio::main] 470 470 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> { 471 471 /// use jacquard_common::xrpc::XrpcExt; 472 - /// use jacquard_common::{AuthorizationToken, CowStr}; 472 + /// use jacquard_common::AuthorizationToken; 473 473 /// use jacquard_common::deps::fluent_uri::Uri; 474 474 /// 475 475 /// let http = reqwest::Client::new(); 476 - /// let base = Uri::parse("https://public.api.bsky.app").unwrap().to_owned(); 476 + /// let base = Uri::parse("https://public.api.bsky.app").unwrap(); 477 477 /// let call = http 478 478 /// .xrpc(base) 479 - /// .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT"))) 480 - /// .accept_labelers(vec![CowStr::from("did:plc:labelerid")]) 479 + /// .auth(AuthorizationToken::Bearer("ACCESS_JWT".into())) 480 + /// .accept_labelers(vec!["did:plc:labelerid".into()]) 481 481 /// .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example")); 482 482 /// // let resp = call.send(&request).await?; 483 483 /// # Ok(())
+16 -15
crates/jacquard/src/client.rs
··· 144 144 /// # #[tokio::main] 145 145 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> { 146 146 /// let client = BasicClient::unauthenticated(); 147 - /// let uri = AtUri::new_static("at://did:plc:xyz/app.bsky.feed.post/3l5abc").unwrap(); 147 + /// let uri: AtUri = AtUri::new_static("at://did:plc:xyz/app.bsky.feed.post/3l5abc").unwrap(); 148 148 /// let response = client.get_record::<Post, _>(&uri).await?; 149 149 /// # Ok(()) 150 150 /// # } ··· 655 655 /// 656 656 /// // Read it back 657 657 /// let response = agent.get_record::<Post, _>(&output.uri).await?; 658 - /// let record = response.parse()?; 658 + /// let record = response.into_output()?; 659 659 /// println!("Post: {}", record.value.text); 660 660 /// # Ok(()) 661 661 /// # } ··· 744 744 /// Get a record from the repository using an at:// URI. 745 745 /// 746 746 /// Returns a typed `Response` that deserializes directly to the record type. 747 - /// Use `.parse()` to borrow from the response buffer, or `.into_output()` for owned data. 747 + /// Use `.into_output()` for owned data, or `.parse::<S>()` to choose another string backing. 748 748 /// 749 749 /// # Example 750 750 /// ··· 752 752 /// # use jacquard::client::BasicClient; 753 753 /// # use jacquard_api::app_bsky::feed::post::Post; 754 754 /// # use jacquard_common::types::string::AtUri; 755 - /// # use jacquard_common::IntoStatic; 755 + /// # use jacquard_common::CowStr; 756 756 /// use jacquard::client::AgentSessionExt; 757 757 /// # #[tokio::main] 758 758 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> { 759 759 /// # let agent: BasicClient = todo!(); 760 - /// let uri = AtUri::new_static("at://did:plc:xyz/app.bsky.feed.post/3l5bqm7lepk2c").unwrap(); 760 + /// let uri: AtUri = AtUri::new_static("at://did:plc:xyz/app.bsky.feed.post/3l5bqm7lepk2c").unwrap(); 761 761 /// let response = agent.get_record::<Post, _>(&uri).await?; 762 - /// let output = response.parse()?; // PostGetRecordOutput<'_> borrowing from buffer 762 + /// let output = response.into_output()?; 763 763 /// println!("Post text: {}", output.value.text); 764 764 /// 765 - /// // Or get owned data 766 - /// let output_owned = response.into_output()?; 765 + /// // Or choose borrowed parsing explicitly. 766 + /// let response = agent.get_record::<Post, _>(&uri).await?; 767 + /// let borrowed = response.parse::<CowStr<'_>>()?; 768 + /// let borrowed_strs = response.parse::<&str>()?; 767 769 /// # Ok(()) 768 770 /// # } 769 771 /// ``` ··· 945 947 /// ```no_run 946 948 /// # use jacquard::client::BasicClient; 947 949 /// # use jacquard_api::app_bsky::actor::profile::Profile; 948 - /// # use jacquard_common::CowStr; 949 950 /// # use jacquard_common::types::string::AtUri; 950 951 /// use jacquard::client::AgentSessionExt; 951 952 /// # #[tokio::main] 952 953 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> { 953 954 /// # let agent: BasicClient = todo!(); 954 - /// let uri = AtUri::new_static("at://did:plc:xyz/app.bsky.actor.profile/self").unwrap(); 955 + /// let uri: AtUri = AtUri::new_static("at://did:plc:xyz/app.bsky.actor.profile/self").unwrap(); 955 956 /// // Update profile record in-place 956 957 /// agent.update_record::<Profile, _>(&uri, |profile| { 957 - /// profile.display_name = Some(CowStr::from("New Name")); 958 - /// profile.description = Some(CowStr::from("Updated bio")); 958 + /// profile.display_name = Some("New Name".into()); 959 + /// profile.description = Some("Updated bio".into()); 959 960 /// }).await?; 960 961 /// # Ok(()) 961 962 /// # } ··· 1124 1125 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> { 1125 1126 /// # let agent: BasicClient = todo!(); 1126 1127 /// let data = std::fs::read("image.png")?; 1127 - /// let mime_type = MimeType::new_static("image/png"); 1128 + /// let mime_type = MimeType::new("image/png"); 1128 1129 /// let blob_ref = agent.upload_blob(data, mime_type).await?; 1129 1130 /// # Ok(()) 1130 1131 /// # } ··· 1274 1275 async move { 1275 1276 CredentialSession::<S, T, W>::session_info(self) 1276 1277 .await 1277 - // Convert the SmolStr session id to CowStr<'static>. 1278 + // The session id is already owned as SmolStr. 1278 1279 .map(|key| (key.did, Some(key.session_id))) 1279 1280 } 1280 1281 } ··· 1305 1306 fn session_info(&self) -> impl Future<Output = Option<(Did, Option<SmolStr>)>> { 1306 1307 async { 1307 1308 let (did, sid) = OAuthSession::<T, S, W>::session_info(self).await; 1308 - // did is already Did<SmolStr>; convert SmolStr sid to CowStr<'static>. 1309 + // Both the DID and session id are already owned. 1309 1310 Some((did, Some(sid))) 1310 1311 } 1311 1312 }
+47 -83
crates/jacquard/src/lib.rs
··· 6 6 //! [Jacquard is simpler](https://whtwnd.com/nonbinary.computer/3m33efvsylz2s) because it is 7 7 //! designed in a way which makes things simple that almost every other atproto library seems to make difficult. 8 8 //! 9 - //! It is also designed around zero-copy/borrowed deserialization: types like [`Post<'_>`](https://docs.rs/jacquard-api/latest/jacquard_api/app_bsky/feed/post/struct.Post.html) 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 - //! 11 9 //! 12 10 //! ## Features 13 11 //! 14 - //! - Validated, spec-compliant, easy to work with, and performant baseline types 15 - //! - Designed such that you can just work with generated API bindings easily 16 - //! - Straightforward OAuth 17 - //! - Server-side convenience features 18 - //! - Lexicon Data value type for working with unknown atproto data (dag-cbor or json) 19 - //! - An order of magnitude less boilerplate than some existing crates 12 + //! - Validated, spec-compliant, easy to work with, and performant baseline types. 13 + //! - Designed such that you can just work with generated API bindings easily. 14 + //! - Straightforward OAuth. 15 + //! - Server-side convenience features. 16 + //! - Lexicon Data value type for working with unknown atproto data (dag-cbor or json). 17 + //! - An order of magnitude less boilerplate than some existing crates. 20 18 //! - Batteries-included, but easily replaceable batteries. 21 - //! - Easy to extend with custom lexicons using code generation or handwritten api types 22 - //! - Stateless options (or options where you handle the state) for rolling your own 23 - //! - All the building blocks of the convenient abstractions are available 24 - //! - Use as much or as little from the crates as you need 25 - //! 26 - //! 19 + //! - Easy to extend with custom lexicons using code generation or handwritten api types. 20 + //! - Stateless options (or options where you handle the state) for rolling your own. 21 + //! - All the building blocks of the convenient abstractions are available. 22 + //! - Use as much or as little from the crates as you need. 27 23 //! 28 24 //! ## Example 29 25 //! 30 - //! Dead simple API client: login with OAuth, then fetch the latest 5 posts. 26 + //! Dead simple API client: resume a stored OAuth session or open a browser login, then fetch the 27 + //! latest 5 posts. OAuth loopback is the default path for local scripts and CLIs where browser login 28 + //! is acceptable; app-password credential sessions are mainly for unattended workflows that must 29 + //! re-authenticate non-interactively. 31 30 //! 32 31 //! ```no_run 33 - //! # use clap::Parser; 34 - //! # use jacquard::CowStr; 35 32 //! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; 36 33 //! use jacquard::client::{Agent, FileAuthStore}; 34 + //! use jacquard::common::session::SessionHint; 37 35 //! use jacquard::oauth::client::OAuthClient; 38 36 //! use jacquard::xrpc::XrpcClient; 39 37 //! use jacquard::oauth::types::AuthorizeOptions; ··· 41 39 //! use jacquard::oauth::loopback::LoopbackConfig; 42 40 //! # use miette::IntoDiagnostic; 43 41 //! 44 - //! # #[derive(Parser, Debug)] 45 - //! # #[command(author, version, about = "Jacquard - OAuth (DPoP) loopback demo")] 46 - //! # struct Args { 47 - //! # /// Handle (e.g., alice.bsky.social), DID, or PDS URL 48 - //! # input: CowStr<'static>, 49 - //! # 50 - //! # /// Path to auth store file (will be created if missing) 51 - //! # #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")] 52 - //! # store: String, 53 - //! # } 54 - //! # 42 + //! const STORE_PATH: &str = "/tmp/jacquard-oauth-session.json"; 43 + //! 55 44 //! #[tokio::main] 56 45 //! async fn main() -> miette::Result<()> { 57 - //! let args = Args::parse(); 46 + //! let login_hint = std::env::args().nth(1); 47 + //! let oauth = OAuthClient::with_default_config(FileAuthStore::new(STORE_PATH)); 48 + //! let hint = SessionHint::from_optional_input(login_hint.as_deref()); 58 49 //! 59 - //! // Build an OAuth client with file-backed auth store and default localhost config 60 - //! let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store)); 61 - //! // Authenticate with a PDS, using a loopback server to handle the callback flow 62 50 //! # #[cfg(feature = "loopback")] 63 - //! let session = oauth 64 - //! .login_with_local_server( 65 - //! args.input.clone(), 51 + //! let Some(session) = oauth 52 + //! .resume_or_login_with_local_server( 53 + //! &hint, 66 54 //! AuthorizeOptions::default(), 67 55 //! LoopbackConfig::default(), 68 56 //! ) 69 - //! .await?; 57 + //! .await? 58 + //! else { 59 + //! miette::bail!( 60 + //! "no stored OAuth session found in {STORE_PATH}; pass a handle, DID, or PDS URL to log in" 61 + //! ); 62 + //! }; 70 63 //! # #[cfg(not(feature = "loopback"))] 71 64 //! # compile_error!("loopback feature must be enabled to run this example"); 72 - //! // Wrap in Agent and fetch the timeline 65 + //! 73 66 //! let agent: Agent<_> = Agent::from(session); 74 67 //! let timeline = agent 75 68 //! .send(GetTimeline::new().limit(5).build()) 76 69 //! .await? 77 70 //! .into_output()?; 71 + //! 78 72 //! for (i, post) in timeline.feed.iter().enumerate() { 79 73 //! println!("\n{}. by {}", i + 1, post.post.author.handle); 80 74 //! println!( ··· 103 97 //! - [`jacquard-derive`](https://docs.rs/jacquard-derive/latest/jacquard_derive/index.html) - Macros (`#[lexicon]`, `#[open_union]`, `#[derive(IntoStatic)]`, `#[derive(LexiconSchema)]`, `#[derive(XrpcRequest)]`) 104 98 //! 105 99 //! 106 - //! ### A note on lifetimes 100 + //! ### String backing types 107 101 //! 108 - //! You'll notice a bunch of lifetimes all over Jacquard types, examples, and so on. If you're newer 109 - //! to Rust or have simply avoided them, they're part of how Rust knows how long to keep something 110 - //! around before cleaning it up. They're not unique to Rust (C and C++ have the same concept 111 - //! internally) but Rust is perhaps the one language that makes them explicit, because they're part 112 - //! of how it validates that things are memory-safe, and being able to give information to the compiler 113 - //! about how long it can expect something to stick around lets the compiler reason out much more 114 - //! sophisticated things. [The Rust book](https://doc.rust-lang.org/book/ch10-03-lifetime-syntax.html) has a section on them if you want a refresher. 115 - //! 116 - //! > On Jacquard types like [`CowStr`], a `'static` lifetime parameter is used to refer to the owned 117 - //! version of a type, in the same way `String` is the owned version of `&str`. 102 + //! Most generated Jacquard types are parameterized over a string backing type: `Type<S = DefaultStr>`. 103 + //! The default backing is owned and efficient. It is especially convenient when values need to be 104 + //! stored, moved independently of a response buffer, or passed through frameworks and APIs with 105 + //! `DeserializeOwned` bounds. In most examples you will not write the `S` parameter at all because 106 + //! builders, constructors, and `.into_output()` infer or choose the owned default for you. 118 107 //! 119 - //! This is somewhat in tension with the 'make things simpler' goal of the crate, but it is honestly 120 - //! pretty straightforward once you know the deal, and Jacquard provides a number of escape hatches 121 - //! and easy ways to work. 108 + //! When you are writing generic helpers or optimizing parsing, you can choose another backing such 109 + //! as `String`, `&str`, or [`CowStr<'_>`] with the [`BosStr`] trait. For API responses, use 110 + //! `.into_output()` as the normal path for owned/default-backed output. Use 111 + //! `.parse::<CowStr<'_>>()` or `.parse::<&str>()` when you specifically want to borrow from the 112 + //! response buffer. 122 113 //! 123 - //! Because explicit lifetimes are somewhat unique to Rust and are not something you may be used to 124 - //! thinking about, they can seem a bit scary to work with. Normally the compiler is pretty good at 125 - //! them, but Jacquard is [built around borrowed deserialization](https://docs.rs/jacquard-common/latest/jacquard_common/#working-with-lifetimes-and-zero-copy-deserialization) and types. This is for reasons of 126 - //! speed and efficiency, because borrowing from your source buffer saves copying the data around. 127 - //! 128 - //! However, it does mean that any Jacquard type that can borrow (not all of them do) is annotated 129 - //! with a lifetime, to confirm that all the borrowed bits are ["covariant"](https://doc.rust-lang.org/nomicon/subtyping.html), i.e. that they all live 130 - //! at least the same amount of time, and that lifetime matches or exceeds the lifetime of the data 131 - //! structure. This also imposes certain restrictions on deserialization. Namely the [`DeserializeOwned`](https://serde.rs/lifetimes.html) 132 - //! bound does not apply to almost any types in Jacquard. There is a [`deserialize_owned`] function 133 - //! which you can use in a serde `deserialize_with` attribute to help, but the general pattern is 134 - //! to do borrowed deserialization and then call [`.into_static()`] if you need ownership. 135 - //! 136 - //! ### Easy mode 137 - //! 138 - //! Easy mode for jacquard is to mostly just use `'static` for your lifetime params and derive/use 139 - //! [`.into_static()`] as needed. When writing, first see if you can get away with `Thing<'_>` 140 - //! and let the compiler infer. second-easiest after that is `Thing<'static>`, third-easiest is giving 141 - //! everything one lifetime, e.g. `fn foo<'a>(&'a self, thing: Thing<'a>) -> /* thing with lifetime 'a */`. 142 - //! 143 - //! When parsing the output of atproto API calls, you can call `.into_output()` on the `Response<R>` 144 - //! struct to get an owned version with a `'static` lifetime. When deserializing, do not use 145 - //! `from_writer()` type deserialization functions, or features like Axum's `Json` extractor, as they 146 - //! have DeserializeOwned bounds and cannot borrow from their buffer. Either use Jacquard's features 147 - //! to get an owned version or follow the same [patterns](https://whtwnd.com/nonbinary.computer/3m33efvsylz2s) it uses in your own code. 114 + //! [`BosStr`]: crate::BosStr 148 115 //! 149 116 //! ## Client options 150 117 //! ··· 162 129 //! #[tokio::main] 163 130 //! async fn main() -> miette::Result<()> { 164 131 //! let http = reqwest::Client::new(); 165 - //! let base = Uri::parse("https://public.api.bsky.app").into_diagnostic()?.to_owned(); 132 + //! let base = Uri::parse("https://public.api.bsky.app").into_diagnostic()?; 166 133 //! let resp = http 167 134 //! .xrpc(base) 168 135 //! .send( ··· 190 157 //! # use jacquard::xrpc::XrpcExt; 191 158 //! # use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; 192 159 //! # use jacquard::types::ident::AtIdentifier; 193 - //! # use jacquard::CowStr; 194 160 //! # use jacquard::deps::fluent_uri::Uri; 195 161 //! # use miette::IntoDiagnostic; 196 162 //! # 197 163 //! #[tokio::main] 198 164 //! async fn main() -> miette::Result<()> { 199 165 //! let http = reqwest::Client::new(); 200 - //! let base = Uri::parse("https://public.api.bsky.app").into_diagnostic()?.to_owned(); 166 + //! let base = Uri::parse("https://public.api.bsky.app").into_diagnostic()?; 201 167 //! let resp = http 202 168 //! .xrpc(base) 203 - //! .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT"))) 204 - //! .accept_labelers(vec![CowStr::from("did:plc:labelerid")]) 169 + //! .auth(AuthorizationToken::Bearer("ACCESS_JWT".into())) 170 + //! .accept_labelers(vec!["did:plc:labelerid".into()]) 205 171 //! .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example")) 206 172 //! .send( 207 173 //! &GetAuthorFeed::new() ··· 216 182 //! } 217 183 //! ``` 218 184 //! 219 - //! [`deserialize_owned`]: crate::deserialize_owned 220 185 //! [`AgentSessionExt`]: crate::client::AgentSessionExt 221 - //! [`.into_static()`]: IntoStatic 222 186 223 187 #![warn(missing_docs)] 224 188