From ef760453ce635ad3c9bae296e2bc264cf98a37a8 Mon Sep 17 00:00:00 2001 From: Orual Date: Sun, 7 Jun 2026 17:42:04 -0400 Subject: [PATCH] preliminary docs fixes, some improvements to the atproto!() macro --- README.md | 53 ++++---- crates/jacquard-common/src/lib.rs | 149 ++++++++-------------- crates/jacquard-common/src/macros.rs | 137 +++++++++++++++++--- crates/jacquard-common/src/types/value.rs | 6 +- crates/jacquard-common/src/xrpc.rs | 8 +- crates/jacquard/src/client.rs | 31 ++--- crates/jacquard/src/lib.rs | 130 +++++++------------ 7 files changed, 265 insertions(+), 249 deletions(-) diff --git a/README.md b/README.md index a624d0021..3bafbe05e 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ A suite of Rust crates intended to make it much easier to get started with atpro [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. -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. +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<'_>`. ## Features @@ -26,50 +26,46 @@ It is also designed around zero-copy/borrowed deserialization: types like [`Post ## Example -Dead simple API client. Logs in with OAuth and prints the latest 5 posts from your timeline. +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. ```rust -// Note: this requires the `loopback` feature enabled (it is currently by default) -use clap::Parser; -use jacquard::CowStr; +// Note: this requires the `loopback` feature enabled (it is currently by default). use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; use jacquard::client::{Agent, FileAuthStore}; +use jacquard::common::session::SessionHint; use jacquard::oauth::client::OAuthClient; use jacquard::oauth::loopback::LoopbackConfig; -use jacquard::types::xrpc::XrpcClient; +use jacquard::oauth::types::AuthorizeOptions; +use jacquard::xrpc::XrpcClient; use miette::IntoDiagnostic; -#[derive(Parser, Debug)] -#[command(author, version, about = "Jacquard - OAuth (DPoP) loopback demo")] -struct Args { - /// Handle (e.g., alice.bsky.social), DID, or PDS URL - input: CowStr<'static>, - - /// Path to auth store file (will be created if missing) - #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")] - store: String, -} +const STORE_PATH: &str = "/tmp/jacquard-oauth-session.json"; #[tokio::main] async fn main() -> miette::Result<()> { - let args = Args::parse(); - - // Build an OAuth client with file-backed auth store and default localhost config - let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store)); - // Authenticate with a PDS, using a loopback server to handle the callback flow - let session = oauth - .login_with_local_server( - args.input.clone(), - Default::default(), + let login_hint = std::env::args().nth(1); + let oauth = OAuthClient::with_default_config(FileAuthStore::new(STORE_PATH)); + let hint = SessionHint::from_optional_input(login_hint.as_deref()); + + let Some(session) = oauth + .resume_or_login_with_local_server( + &hint, + AuthorizeOptions::default(), LoopbackConfig::default(), ) - .await?; - // Wrap in Agent and fetch the timeline + .await? + else { + miette::bail!( + "no stored OAuth session found in {STORE_PATH}; pass a handle, DID, or PDS URL to log in" + ); + }; + let agent: Agent<_> = Agent::from(session); let timeline = agent - .send(&GetTimeline::new().limit(5).build()) + .send(GetTimeline::new().limit(5).build()) .await? .into_output()?; + for (i, post) in timeline.feed.iter().enumerate() { println!("\n{}. by {}", i + 1, post.post.author.handle); println!( @@ -80,7 +76,6 @@ async fn main() -> miette::Result<()> { Ok(()) } - ``` 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. diff --git a/crates/jacquard-common/src/lib.rs b/crates/jacquard-common/src/lib.rs index 5afbe7768..6cc5e7de1 100644 --- a/crates/jacquard-common/src/lib.rs +++ b/crates/jacquard-common/src/lib.rs @@ -67,8 +67,8 @@ //! const NSID: &'static str; //! /// Output encoding (MIME type) //! const ENCODING: &'static str; -//! type Output<'de>: Deserialize<'de> + IntoStatic; -//! type Err<'de>: Error + Deserialize<'de> + IntoStatic; +//! type Output; +//! type Err: Error + Serialize + DeserializeOwned; //! } //! ``` //! 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,116 +81,67 @@ //! } //! //! impl Response { -//! pub fn parse<'s>( -//! &'s self -//! ) -> Result<::Output<'s>, XrpcError<::Err<'s>>> { -//! // Borrowed parsing into Output or Err +//! pub fn parse<'s, S>(&'s self) -> Result, XrpcError> +//! where +//! S: BosStr + Deserialize<'s>, +//! R::Output: Deserialize<'s>, +//! { +//! // Parse with the caller's chosen string backing. +//! } +//! pub fn into_output(self) -> Result, XrpcError> +//! where +//! R::Output: DeserializeOwned, +//! { +//! // Parse into owned/default-backed output. //! } -//! pub fn into_output( -//! self -//! ) -> Result<::Output<'static>, XrpcError<::Err<'static>>> -//! where ... -//! { /* Owned parsing into Output or Err */ } //! } //! ``` -//! 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. -//! -//! ## Working with Lifetimes and Zero-Copy Deserialization -//! -//! 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? -//! -//! The naive approach would be to put a lifetime parameter on the trait itself: -//! -//!```ignore -//!// This looks reasonable but creates problems in generic/async contexts -//!trait NaiveXrpcRequest<'de> { -//! type Output: Deserialize<'de>; -//! // ... -//!} -//!``` -//! -//! 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: -//! -//!```ignore -//!fn parse(response: &[u8]) ... // return type -//!where -//! R: for<'any> XrpcRequest<'any> -//!{ /* deserialize from response... */ } -//!``` -//! -//! 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. -//! -//! 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. +//! You decode the response (or the endpoint-specific error) out of this when you are ready. There +//! are two reasons for this. One is separation of concerns: by two-staging the parsing, it is easier +//! to distinguish network and authentication problems from application-level errors. The second is +//! string backing: callers can choose ordinary owned output or explicit borrowed parsing. //! -//!```ignore -//!fn parse<'s, R: XrpcRequest<'s>>(response: &'s [u8]) ... // return type with the same lifetime -//!{ /* deserialize from response... */ } -//!``` +//! ## String backing, borrowing, and response parsing //! -//! 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. +//! Most generated output types are parameterized over a string backing type: `Output`. +//! The usual path is owned output: //! -//! ### Explaining where the buffer goes to `rustc` -//! -//! The fix is to use Generic Associated Types (GATs) on the trait's associated types, while keeping the trait itself lifetime-free: -//! -//!```ignore -//!pub trait XrpcResp { -//! const NSID: &'static str; -//! /// Output encoding (MIME type) -//! const ENCODING: &'static str; -//! type Output<'de>: Deserialize<'de> + IntoStatic; -//! type Err<'de>: Error + Deserialize<'de> + IntoStatic; -//!} -//!``` -//! -//!Now you can write trait bounds without HRTBs, and with lifetime bounds that are actually possible for Jacquard's borrowed deserializing types to meet: +//! ```ignore +//! let output = response.into_output()?; +//! ``` //! -//!```ignore -//!fn parse<'s, R: XrpcResp>(response: &'s [u8]) /* return type with same lifetime */ { -//! // Compiler can pick a concrete lifetime for R::Output<'_> or have it specified easily -//!} -//!``` +//! `into_output()` parses into `SmolStr`-backed output. This is the convenient default when values +//! need to be stored, moved independently of the response buffer, or passed through frameworks and +//! APIs that require `DeserializeOwned`. //! -//!Methods that need lifetimes use method-level generic parameters: +//! When you specifically want to borrow from the response buffer, choose a borrowed or +//! borrow-or-own backing at parse time: //! -//!```ignore -//!// This is part of a trait from jacquard itself, used to genericize updates to things like the Bluesky -//!// preferences union, so that if you implement a similar lexicon type in your app, you don't have -//!// to special-case it. Instead you can do a relatively simple trait implementation and then call -//!// .update_vec() with a modifier function or .update_vec_item() with a single item you want to set. -// -//!pub trait VecUpdate { -//! type GetRequest: XrpcRequest; -//! type PutRequest: XrpcRequest; -//! //... more stuff -// -//! //Method-level lifetime, GAT on response type -//! fn extract_vec<'s>( -//! output: <::Response as XrpcResp>::Output<'s> -//! ) -> Vec; -//! //... more stuff -//!} -//!``` +//! ```ignore +//! let output = response.parse::>()?; +//! let output = response.parse::<&str>()?; +//! ``` //! -//!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: +//! Borrowed output works fine across async code as long as the value remains tied to the +//! buffer-owning `Response`. The `.send()` method itself can stay lifetime-free because it returns +//! that buffer-owning response, and the caller decides whether to parse into owned data or borrow +//! from the buffer. //! -//!```ignore -//!// Zero-copy: borrow from the owned buffer -//!let output: R::Output<'_> = response.parse()?; -// -//!// Owned: convert to 'static via IntoStatic -//!let output: R::Output<'static> = response.into_output()?; -//!``` +//! `XrpcResp` stays lifetime-free too. Its success output is a GAT over the backing string type, +//! and its error type is a plain owned type: //! -//! 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. +//! ```ignore +//! pub trait XrpcResp { +//! const NSID: &'static str; +//! const ENCODING: &'static str; +//! type Output; +//! type Err: Error + Serialize + DeserializeOwned; +//! } +//! ``` //! -//! When you see types like `Response` or methods with lifetime parameters, -//! this is the pattern at work. It looks a bit funky, but it's solving a specific problem -//! in a way that doesn't require unsafe code or much actual work from you, if you're using it. -//! It's also not too bad to write, once you're aware of the pattern and why it works. If you run -//! into a lifetime/borrowing inference issue in jacquard, please contact the crate author. She'd -//! be happy to debug, and if it's using a method from one of the jacquard crates and seems like -//! it *should* just work, that is a bug in jacquard, and you should [file an issue](https://tangled.org/nonbinary.computer/jacquard/). +//! Keeping endpoint errors owned avoids lifetime gymnastics on the unhappy path. If you do not want +//! to parse the endpoint-specific success type, `Response` also supports `.parse_data()` and +//! `.parse_raw()` for loosely typed atproto data. #![no_std] #![warn(missing_docs)] diff --git a/crates/jacquard-common/src/macros.rs b/crates/jacquard-common/src/macros.rs index 44d0e8b75..201a23888 100644 --- a/crates/jacquard-common/src/macros.rs +++ b/crates/jacquard-common/src/macros.rs @@ -1,5 +1,52 @@ //! `atproto!` macro. -/// Construct a atproto `Data<'_>` value from a literal. + +use crate::{DefaultStr, FromStaticStr, types::value::Data}; + +/// Hidden conversion hook used by the [`atproto!`] macro. +#[doc(hidden)] +pub trait AtprotoMacroLiteral { + /// Convert this literal into default-backed AT Protocol data. + fn into_atproto_data(self) -> Data; +} + +impl AtprotoMacroLiteral for &'static str { + fn into_atproto_data(self) -> Data { + Data::String(crate::types::string::AtprotoStr::new( + DefaultStr::from_static(self), + )) + } +} + +macro_rules! impl_atproto_macro_integer_literal { + ($($ty:ty),* $(,)?) => { + $( + impl AtprotoMacroLiteral for $ty { + fn into_atproto_data(self) -> Data { + Data::Integer(i64::from(self)) + } + } + )* + }; +} + +macro_rules! impl_atproto_macro_checked_integer_literal { + ($($ty:ty),* $(,)?) => { + $( + impl AtprotoMacroLiteral for $ty { + fn into_atproto_data(self) -> Data { + Data::Integer(self.try_into().expect("integer literal exceeds the AT Protocol i64 range")) + } + } + )* + }; +} + +impl_atproto_macro_integer_literal!(i8, i16, i32, u8, u16, u32); +impl_atproto_macro_checked_integer_literal!(i64, i128, isize, u64, u128, usize); + +/// Construct a default-backed atproto [`Data`] value from a literal. +/// +/// [`Data`]: crate::types::value::Data /// /// ``` /// # use jacquard_common::atproto; @@ -18,10 +65,10 @@ /// /// Variables or expressions can be interpolated into the ATProto literal. Any type /// interpolated into an array element or object value must implement Serde's -/// `Serialize` trait, while any type interpolated into a object key must -/// implement `Into`. If the `Serialize` implementation of the -/// interpolated type decides to fail, or if the interpolated type contains a -/// map with non-string keys, the `atproto!` macro will panic. +/// `Serialize` trait, while any type interpolated into an object key must +/// convert into the default string backing. If the `Serialize` implementation +/// of the interpolated type decides to fail, or if the interpolated type +/// contains a map with non-string keys, the `atproto!` macro will panic. /// /// ``` /// # use jacquard_common::atproto; @@ -138,7 +185,7 @@ macro_rules! atproto_internal { // Insert the current entry followed by trailing comma. (@object $object:ident [$($key:tt)+] ($value:expr) , $($rest:tt)*) => { - let _ = $object.insert(($($key)+).into(), $value); + let _ = $object.insert(atproto_internal_key!($($key)+), $value); atproto_internal!(@object $object () ($($rest)*) ($($rest)*)); }; @@ -149,7 +196,7 @@ macro_rules! atproto_internal { // Insert the last entry without trailing comma. (@object $object:ident [$($key:tt)+] ($value:expr)) => { - let _ = $object.insert(($($key)+).into(), $value); + let _ = $object.insert(atproto_internal_key!($($key)+), $value); }; // Next value is `null`. @@ -230,42 +277,48 @@ macro_rules! atproto_internal { ////////////////////////////////////////////////////////////////////////// (null) => { - $crate::types::value::Data::Null + $crate::types::value::Data::<$crate::DefaultStr>::Null }; (true) => { - $crate::types::value::Data::Boolean(true) + $crate::types::value::Data::<$crate::DefaultStr>::Boolean(true) }; (false) => { - $crate::types::value::Data::Boolean(false) + $crate::types::value::Data::<$crate::DefaultStr>::Boolean(false) }; ([]) => { - $crate::types::value::Data::Array($crate::types::value::Array(atproto_internal_vec![])) + $crate::types::value::Data::<$crate::DefaultStr>::Array($crate::types::value::Array(atproto_internal_vec![])) }; ([ $($tt:tt)+ ]) => { - $crate::types::value::Data::Array($crate::types::value::Array(atproto_internal!(@array [] $($tt)+))) + $crate::types::value::Data::<$crate::DefaultStr>::Array($crate::types::value::Array(atproto_internal!(@array [] $($tt)+))) }; ({}) => { - $crate::types::value::Data::Object($crate::types::value::Object(::std::collections::BTreeMap::new())) + $crate::types::value::Data::<$crate::DefaultStr>::Object($crate::types::value::Object(::std::collections::BTreeMap::new())) }; ({ $($tt:tt)+ }) => { - $crate::types::value::Data::Object($crate::types::value::Object({ + $crate::types::value::Data::<$crate::DefaultStr>::Object($crate::types::value::Object({ let mut object = ::std::collections::BTreeMap::new(); atproto_internal!(@object object () ($($tt)+) ($($tt)+)); object })) }; - // Any Serialize type: numbers, strings, struct literals, variables etc. + // Literal values go through a helper so string literals can use static + // storage while integer literals remain integers. + ($literal:literal) => { + $crate::macros::AtprotoMacroLiteral::into_atproto_data($literal) + }; + + // Any Serialize type: variables, struct literals, dynamic strings etc. // Must be below every other rule. ($other:expr) => { { - $crate::types::value::Data::from($other) + $crate::types::value::Data::<$crate::DefaultStr>::from($other) } }; } @@ -281,8 +334,60 @@ macro_rules! atproto_internal_vec { }; } +#[macro_export] +#[doc(hidden)] +macro_rules! atproto_internal_key { + ($key:literal) => { + <$crate::DefaultStr as $crate::FromStaticStr>::from_static($key) + }; + + ($key:expr) => { + ($key).into() + }; +} + #[macro_export] #[doc(hidden)] macro_rules! atproto_unexpected { () => {}; } + +#[cfg(test)] +mod tests { + use crate::{DefaultStr, types::value::Data}; + + const LONG_KEY: &str = "a-static-key-that-is-longer-than-inline-capacity"; + const LONG_VALUE: &str = "a static string value that is longer than inline capacity"; + + #[test] + fn string_literals_use_static_default_backing() { + let value = atproto!({ + "a-static-key-that-is-longer-than-inline-capacity": + "a static string value that is longer than inline capacity" + }); + + let Data::Object(object) = value else { + panic!("expected object"); + }; + let (key, value) = object.0.iter().next().expect("object has one field"); + + assert_eq!(key.as_str(), LONG_KEY); + assert!(!key.is_heap_allocated()); + + let Data::String(string) = value else { + panic!("expected string value"); + }; + assert_eq!(string.as_str(), LONG_VALUE); + if let crate::types::string::AtprotoStr::String(backing) = string { + assert!(!backing.is_heap_allocated()); + } else { + panic!("test value should not be inferred as a richer atproto string type"); + } + } + + #[test] + fn macro_result_defaults_to_default_backing_without_context() { + let value = atproto!(["hello", 200, true, null]); + let _: Data = value; + } +} diff --git a/crates/jacquard-common/src/types/value.rs b/crates/jacquard-common/src/types/value.rs index 21352d23b..f7de3161a 100644 --- a/crates/jacquard-common/src/types/value.rs +++ b/crates/jacquard-common/src/types/value.rs @@ -708,7 +708,8 @@ impl IntoStatic for RawData<'_> { /// /// # Example /// ``` -/// # use jacquard_common::types::value::{Data, from_data}; +/// # use jacquard_common::{atproto, Data}; +/// # use jacquard_common::types::value::from_data; /// # use serde::Deserialize; /// # /// #[derive(Deserialize)] @@ -720,8 +721,7 @@ impl IntoStatic for RawData<'_> { /// } /// /// # fn example() -> Result<(), Box> { -/// # let json = serde_json::json!({"text": "hello", "author": "alice"}); -/// # let data = Data::from_json(&json)?; +/// # let data: Data = atproto!({"text": "hello", "author": "alice"}); /// let post: Post = from_data(&data)?; /// # Ok(()) /// # } diff --git a/crates/jacquard-common/src/xrpc.rs b/crates/jacquard-common/src/xrpc.rs index 1388ac24a..675ad9ceb 100644 --- a/crates/jacquard-common/src/xrpc.rs +++ b/crates/jacquard-common/src/xrpc.rs @@ -469,15 +469,15 @@ pub trait XrpcStreamingClient: XrpcClient + HttpClientExt { /// # #[tokio::main] /// # async fn main() -> Result<(), Box> { /// use jacquard_common::xrpc::XrpcExt; -/// use jacquard_common::{AuthorizationToken, CowStr}; +/// use jacquard_common::AuthorizationToken; /// use jacquard_common::deps::fluent_uri::Uri; /// /// let http = reqwest::Client::new(); -/// let base = Uri::parse("https://public.api.bsky.app").unwrap().to_owned(); +/// let base = Uri::parse("https://public.api.bsky.app").unwrap(); /// let call = http /// .xrpc(base) -/// .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT"))) -/// .accept_labelers(vec![CowStr::from("did:plc:labelerid")]) +/// .auth(AuthorizationToken::Bearer("ACCESS_JWT".into())) +/// .accept_labelers(vec!["did:plc:labelerid".into()]) /// .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example")); /// // let resp = call.send(&request).await?; /// # Ok(()) diff --git a/crates/jacquard/src/client.rs b/crates/jacquard/src/client.rs index c9df60000..d583e5f4b 100644 --- a/crates/jacquard/src/client.rs +++ b/crates/jacquard/src/client.rs @@ -144,7 +144,7 @@ impl BasicClient { /// # #[tokio::main] /// # async fn main() -> Result<(), Box> { /// let client = BasicClient::unauthenticated(); - /// let uri = AtUri::new_static("at://did:plc:xyz/app.bsky.feed.post/3l5abc").unwrap(); + /// let uri: AtUri = AtUri::new_static("at://did:plc:xyz/app.bsky.feed.post/3l5abc").unwrap(); /// let response = client.get_record::(&uri).await?; /// # Ok(()) /// # } @@ -655,7 +655,7 @@ type VecUpdatePutError = /// /// // Read it back /// let response = agent.get_record::(&output.uri).await?; -/// let record = response.parse()?; +/// let record = response.into_output()?; /// println!("Post: {}", record.value.text); /// # Ok(()) /// # } @@ -744,7 +744,7 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { /// Get a record from the repository using an at:// URI. /// /// Returns a typed `Response` that deserializes directly to the record type. - /// Use `.parse()` to borrow from the response buffer, or `.into_output()` for owned data. + /// Use `.into_output()` for owned data, or `.parse::()` to choose another string backing. /// /// # Example /// @@ -752,18 +752,20 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { /// # use jacquard::client::BasicClient; /// # use jacquard_api::app_bsky::feed::post::Post; /// # use jacquard_common::types::string::AtUri; - /// # use jacquard_common::IntoStatic; + /// # use jacquard_common::CowStr; /// use jacquard::client::AgentSessionExt; /// # #[tokio::main] /// # async fn main() -> Result<(), Box> { /// # let agent: BasicClient = todo!(); - /// let uri = AtUri::new_static("at://did:plc:xyz/app.bsky.feed.post/3l5bqm7lepk2c").unwrap(); + /// let uri: AtUri = AtUri::new_static("at://did:plc:xyz/app.bsky.feed.post/3l5bqm7lepk2c").unwrap(); /// let response = agent.get_record::(&uri).await?; - /// let output = response.parse()?; // PostGetRecordOutput<'_> borrowing from buffer + /// let output = response.into_output()?; /// println!("Post text: {}", output.value.text); /// - /// // Or get owned data - /// let output_owned = response.into_output()?; + /// // Or choose borrowed parsing explicitly. + /// let response = agent.get_record::(&uri).await?; + /// let borrowed = response.parse::>()?; + /// let borrowed_strs = response.parse::<&str>()?; /// # Ok(()) /// # } /// ``` @@ -945,17 +947,16 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { /// ```no_run /// # use jacquard::client::BasicClient; /// # use jacquard_api::app_bsky::actor::profile::Profile; - /// # use jacquard_common::CowStr; /// # use jacquard_common::types::string::AtUri; /// use jacquard::client::AgentSessionExt; /// # #[tokio::main] /// # async fn main() -> Result<(), Box> { /// # let agent: BasicClient = todo!(); - /// let uri = AtUri::new_static("at://did:plc:xyz/app.bsky.actor.profile/self").unwrap(); + /// let uri: AtUri = AtUri::new_static("at://did:plc:xyz/app.bsky.actor.profile/self").unwrap(); /// // Update profile record in-place /// agent.update_record::(&uri, |profile| { - /// profile.display_name = Some(CowStr::from("New Name")); - /// profile.description = Some(CowStr::from("Updated bio")); + /// profile.display_name = Some("New Name".into()); + /// profile.description = Some("Updated bio".into()); /// }).await?; /// # Ok(()) /// # } @@ -1124,7 +1125,7 @@ pub trait AgentSessionExt: AgentSession + IdentityResolver { /// # async fn main() -> Result<(), Box> { /// # let agent: BasicClient = todo!(); /// let data = std::fs::read("image.png")?; - /// let mime_type = MimeType::new_static("image/png"); + /// let mime_type = MimeType::new("image/png"); /// let blob_ref = agent.upload_blob(data, mime_type).await?; /// # Ok(()) /// # } @@ -1274,7 +1275,7 @@ where async move { CredentialSession::::session_info(self) .await - // Convert the SmolStr session id to CowStr<'static>. + // The session id is already owned as SmolStr. .map(|key| (key.did, Some(key.session_id))) } } @@ -1305,7 +1306,7 @@ where fn session_info(&self) -> impl Future)>> { async { let (did, sid) = OAuthSession::::session_info(self).await; - // did is already Did; convert SmolStr sid to CowStr<'static>. + // Both the DID and session id are already owned. Some((did, Some(sid))) } } diff --git a/crates/jacquard/src/lib.rs b/crates/jacquard/src/lib.rs index bdeabcf27..374380c4d 100644 --- a/crates/jacquard/src/lib.rs +++ b/crates/jacquard/src/lib.rs @@ -6,34 +6,32 @@ //! [Jacquard is simpler](https://whtwnd.com/nonbinary.computer/3m33efvsylz2s) because it is //! designed in a way which makes things simple that almost every other atproto library seems to make difficult. //! -//! 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. -//! //! //! ## Features //! -//! - Validated, spec-compliant, easy to work with, and performant baseline types -//! - Designed such that you can just work with generated API bindings easily -//! - Straightforward OAuth -//! - Server-side convenience features -//! - Lexicon Data value type for working with unknown atproto data (dag-cbor or json) -//! - An order of magnitude less boilerplate than some existing crates +//! - Validated, spec-compliant, easy to work with, and performant baseline types. +//! - Designed such that you can just work with generated API bindings easily. +//! - Straightforward OAuth. +//! - Server-side convenience features. +//! - Lexicon Data value type for working with unknown atproto data (dag-cbor or json). +//! - An order of magnitude less boilerplate than some existing crates. //! - Batteries-included, but easily replaceable batteries. -//! - Easy to extend with custom lexicons using code generation or handwritten api types -//! - Stateless options (or options where you handle the state) for rolling your own -//! - All the building blocks of the convenient abstractions are available -//! - Use as much or as little from the crates as you need -//! -//! +//! - Easy to extend with custom lexicons using code generation or handwritten api types. +//! - Stateless options (or options where you handle the state) for rolling your own. +//! - All the building blocks of the convenient abstractions are available. +//! - Use as much or as little from the crates as you need. //! //! ## Example //! -//! Dead simple API client: login with OAuth, then fetch the latest 5 posts. +//! Dead simple API client: resume a stored OAuth session or open a browser login, then fetch the +//! latest 5 posts. OAuth loopback 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. //! //! ```no_run -//! # use clap::Parser; -//! # use jacquard::CowStr; //! use jacquard::api::app_bsky::feed::get_timeline::GetTimeline; //! use jacquard::client::{Agent, FileAuthStore}; +//! use jacquard::common::session::SessionHint; //! use jacquard::oauth::client::OAuthClient; //! use jacquard::xrpc::XrpcClient; //! use jacquard::oauth::types::AuthorizeOptions; @@ -41,40 +39,36 @@ //! use jacquard::oauth::loopback::LoopbackConfig; //! # use miette::IntoDiagnostic; //! -//! # #[derive(Parser, Debug)] -//! # #[command(author, version, about = "Jacquard - OAuth (DPoP) loopback demo")] -//! # struct Args { -//! # /// Handle (e.g., alice.bsky.social), DID, or PDS URL -//! # input: CowStr<'static>, -//! # -//! # /// Path to auth store file (will be created if missing) -//! # #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")] -//! # store: String, -//! # } -//! # +//! const STORE_PATH: &str = "/tmp/jacquard-oauth-session.json"; +//! //! #[tokio::main] //! async fn main() -> miette::Result<()> { -//! let args = Args::parse(); +//! let login_hint = std::env::args().nth(1); +//! let oauth = OAuthClient::with_default_config(FileAuthStore::new(STORE_PATH)); +//! let hint = SessionHint::from_optional_input(login_hint.as_deref()); //! -//! // Build an OAuth client with file-backed auth store and default localhost config -//! let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store)); -//! // Authenticate with a PDS, using a loopback server to handle the callback flow //! # #[cfg(feature = "loopback")] -//! let session = oauth -//! .login_with_local_server( -//! args.input.clone(), +//! let Some(session) = oauth +//! .resume_or_login_with_local_server( +//! &hint, //! AuthorizeOptions::default(), //! LoopbackConfig::default(), //! ) -//! .await?; +//! .await? +//! else { +//! miette::bail!( +//! "no stored OAuth session found in {STORE_PATH}; pass a handle, DID, or PDS URL to log in" +//! ); +//! }; //! # #[cfg(not(feature = "loopback"))] //! # compile_error!("loopback feature must be enabled to run this example"); -//! // Wrap in Agent and fetch the timeline +//! //! let agent: Agent<_> = Agent::from(session); //! let timeline = agent //! .send(GetTimeline::new().limit(5).build()) //! .await? //! .into_output()?; +//! //! for (i, post) in timeline.feed.iter().enumerate() { //! println!("\n{}. by {}", i + 1, post.post.author.handle); //! println!( @@ -103,48 +97,21 @@ //! - [`jacquard-derive`](https://docs.rs/jacquard-derive/latest/jacquard_derive/index.html) - Macros (`#[lexicon]`, `#[open_union]`, `#[derive(IntoStatic)]`, `#[derive(LexiconSchema)]`, `#[derive(XrpcRequest)]`) //! //! -//! ### A note on lifetimes -//! -//! You'll notice a bunch of lifetimes all over Jacquard types, examples, and so on. If you're newer -//! to Rust or have simply avoided them, they're part of how Rust knows how long to keep something -//! around before cleaning it up. They're not unique to Rust (C and C++ have the same concept -//! internally) but Rust is perhaps the one language that makes them explicit, because they're part -//! of how it validates that things are memory-safe, and being able to give information to the compiler -//! about how long it can expect something to stick around lets the compiler reason out much more -//! 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. -//! -//! > On Jacquard types like [`CowStr`], a `'static` lifetime parameter is used to refer to the owned -//! version of a type, in the same way `String` is the owned version of `&str`. -//! -//! This is somewhat in tension with the 'make things simpler' goal of the crate, but it is honestly -//! pretty straightforward once you know the deal, and Jacquard provides a number of escape hatches -//! and easy ways to work. -//! -//! Because explicit lifetimes are somewhat unique to Rust and are not something you may be used to -//! thinking about, they can seem a bit scary to work with. Normally the compiler is pretty good at -//! 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 -//! speed and efficiency, because borrowing from your source buffer saves copying the data around. -//! -//! However, it does mean that any Jacquard type that can borrow (not all of them do) is annotated -//! 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 -//! at least the same amount of time, and that lifetime matches or exceeds the lifetime of the data -//! structure. This also imposes certain restrictions on deserialization. Namely the [`DeserializeOwned`](https://serde.rs/lifetimes.html) -//! bound does not apply to almost any types in Jacquard. There is a [`deserialize_owned`] function -//! which you can use in a serde `deserialize_with` attribute to help, but the general pattern is -//! to do borrowed deserialization and then call [`.into_static()`] if you need ownership. +//! ### String backing types //! -//! ### Easy mode +//! Most generated Jacquard types are parameterized over a string backing type: `Type`. +//! The default backing is owned and efficient. It is especially convenient when values need to be +//! stored, moved independently of a response buffer, or passed through frameworks and APIs with +//! `DeserializeOwned` bounds. In most examples you will not write the `S` parameter at all because +//! builders, constructors, and `.into_output()` infer or choose the owned default for you. //! -//! Easy mode for jacquard is to mostly just use `'static` for your lifetime params and derive/use -//! [`.into_static()`] as needed. When writing, first see if you can get away with `Thing<'_>` -//! and let the compiler infer. second-easiest after that is `Thing<'static>`, third-easiest is giving -//! everything one lifetime, e.g. `fn foo<'a>(&'a self, thing: Thing<'a>) -> /* thing with lifetime 'a */`. +//! When you are writing generic helpers or optimizing parsing, you can choose another backing such +//! as `String`, `&str`, or [`CowStr<'_>`] with the [`BosStr`] trait. For API responses, use +//! `.into_output()` as the normal path for owned/default-backed output. Use +//! `.parse::>()` or `.parse::<&str>()` when you specifically want to borrow from the +//! response buffer. //! -//! When parsing the output of atproto API calls, you can call `.into_output()` on the `Response` -//! struct to get an owned version with a `'static` lifetime. When deserializing, do not use -//! `from_writer()` type deserialization functions, or features like Axum's `Json` extractor, as they -//! have DeserializeOwned bounds and cannot borrow from their buffer. Either use Jacquard's features -//! to get an owned version or follow the same [patterns](https://whtwnd.com/nonbinary.computer/3m33efvsylz2s) it uses in your own code. +//! [`BosStr`]: crate::BosStr //! //! ## Client options //! @@ -162,7 +129,7 @@ //! #[tokio::main] //! async fn main() -> miette::Result<()> { //! let http = reqwest::Client::new(); -//! let base = Uri::parse("https://public.api.bsky.app").into_diagnostic()?.to_owned(); +//! let base = Uri::parse("https://public.api.bsky.app").into_diagnostic()?; //! let resp = http //! .xrpc(base) //! .send( @@ -190,18 +157,17 @@ //! # use jacquard::xrpc::XrpcExt; //! # use jacquard::api::app_bsky::feed::get_author_feed::GetAuthorFeed; //! # use jacquard::types::ident::AtIdentifier; -//! # use jacquard::CowStr; //! # use jacquard::deps::fluent_uri::Uri; //! # use miette::IntoDiagnostic; //! # //! #[tokio::main] //! async fn main() -> miette::Result<()> { //! let http = reqwest::Client::new(); -//! let base = Uri::parse("https://public.api.bsky.app").into_diagnostic()?.to_owned(); +//! let base = Uri::parse("https://public.api.bsky.app").into_diagnostic()?; //! let resp = http //! .xrpc(base) -//! .auth(AuthorizationToken::Bearer(CowStr::from("ACCESS_JWT"))) -//! .accept_labelers(vec![CowStr::from("did:plc:labelerid")]) +//! .auth(AuthorizationToken::Bearer("ACCESS_JWT".into())) +//! .accept_labelers(vec!["did:plc:labelerid".into()]) //! .header(http::header::USER_AGENT, http::HeaderValue::from_static("jacquard-example")) //! .send( //! &GetAuthorFeed::new() @@ -216,9 +182,7 @@ //! } //! ``` //! -//! [`deserialize_owned`]: crate::deserialize_owned //! [`AgentSessionExt`]: crate::client::AgentSessionExt -//! [`.into_static()`]: IntoStatic #![warn(missing_docs)] -- 2.51.2