A better Rust ATProto crate

reworked helper methods to be on a trait, readying 0.5 release

Orual 8c229615 1558344a

Changed files
+534 -449
crates
jacquard
jacquard-api
jacquard-axum
jacquard-common
jacquard-derive
jacquard-identity
jacquard-lexicon
jacquard-oauth
examples
+84
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## [0.5.0] - 2025-10-13 4 + 5 + ### Breaking Changes 6 + 7 + **AgentSession trait** (`jacquard`) 8 + - Removed `async fn` in favour of `impl Future` return types for better trait object compatibility 9 + - Methods now return `impl Future` instead of being marked `async fn` 10 + 11 + **XRPC improvements** (`jacquard-common`) 12 + - Simplified response transmutation for typed record retrieval 13 + - `Response::transmute()` added for zero-cost response type conversion 14 + 15 + **jacquard-axum** 16 + - Removed binary target (`main.rs`), now library-only 17 + 18 + ### Added 19 + 20 + **Agent convenience methods** (`jacquard`) 21 + - New `AgentSessionExt` trait automatically implemented for `AgentSession + IdentityResolver` 22 + - **Basic CRUD**: `create_record()`, `get_record()`, `put_record()`, `delete_record()` 23 + - **Update patterns**: `update_record()` (fetch-modify-put), `update_vec()`, `update_vec_item()` 24 + - **Blob operations**: `upload_blob()` 25 + - All methods auto-fill repo from session and collection from type's `Collection::NSID` 26 + - Simplified bounds on `update_record` - no HRTB issues, works with all record types 27 + 28 + **VecUpdate trait** (`jacquard`) 29 + - `VecUpdate` trait for fetch-modify-put patterns on array-based endpoints 30 + - `PreferencesUpdate` implementation for updating user preferences 31 + - Enables type-safe updates to preferences, saved feeds, and other array endpoints 32 + 33 + **Typed record retrieval** (`jacquard-api`, `jacquard-common`) 34 + - Each collection generates `{Type}Record` marker struct implementing `XrpcResp` 35 + - `Collection::Record` associated type points to the marker 36 + - `get_record::<R>()` returns `Response<R::Record>` with zero-copy `.parse()` 37 + - Response transmutation enables type-safe record operations 38 + 39 + **Examples** 40 + - `create_post.rs`: Creating posts with Agent convenience methods 41 + - `update_profile.rs`: Updating profile with fetch-modify-put 42 + - `post_with_image.rs`: Uploading images and creating posts with embeds 43 + - `update_preferences.rs`: Using VecUpdate for preferences 44 + - `create_whitewind_post.rs`, `read_whitewind_post.rs`: Third-party lexicons 45 + - `read_tangled_repo.rs`: Reading git repo metadata from tangled.sh 46 + - `resolve_did.rs`: Identity resolution examples 47 + - `public_atproto_feed.rs`: Unauthenticated feed access 48 + - `axum_server.rs`: Server-side XRPC handler 49 + 50 + ### Changed 51 + 52 + **Code organization** (`jacquard-lexicon`) 53 + - Refactored monolithic `codegen.rs` into focused modules: 54 + - `codegen/structs.rs`: Record and object generation 55 + - `codegen/xrpc.rs`: XRPC request/response generation 56 + - `codegen/types.rs`: Type alias and union generation 57 + - `codegen/names.rs`: Identifier sanitization and naming 58 + - `codegen/lifetime.rs`: Lifetime propagation logic 59 + - `codegen/output.rs`: Module and feature generation 60 + - `codegen/utils.rs`: Shared utilities 61 + - Improved code navigation and maintainability 62 + 63 + **Documentation** (`jacquard`) 64 + - Added comprehensive trait-level docs for `AgentSessionExt` 65 + - Updated examples to use new convenience methods 66 + 67 + ### Fixed 68 + 69 + - `update_record` now works with all record types without lifetime issues 70 + - Proper `IdentityResolver` bounds on `AgentSessionExt` 71 + 72 + ## [0.4.1] - 2025-10-13 73 + 74 + ### Added 75 + 76 + **Collection trait improvements** (`jacquard-api`) 77 + - Generated `{Type}Record` marker structs for all record types 78 + - Each implements `XrpcResp` with `Output<'de> = {Type}<'de>` and `Err<'de> = RecordError<'de>` 79 + - Enables typed `get_record` returning `Response<R::Record>` 80 + 81 + ### Changed 82 + 83 + - Minor improvements to derive macros (`jacquard-derive`) 84 + - Identity resolution refinements (`jacquard-identity`) 85 + - OAuth client improvements (`jacquard-oauth`) 86 + 3 87 ## [0.4.0] - 2025-10-11 4 88 5 89 ### Breaking Changes
+22 -110
Cargo.lock
··· 1754 1754 1755 1755 [[package]] 1756 1756 name = "jacquard" 1757 - version = "0.4.0" 1757 + version = "0.5.0" 1758 1758 dependencies = [ 1759 1759 "async-trait", 1760 1760 "bon", 1761 1761 "bytes", 1762 1762 "clap", 1763 1763 "http", 1764 - "jacquard-api 0.4.1", 1765 - "jacquard-common 0.4.0", 1766 - "jacquard-derive 0.4.0", 1767 - "jacquard-identity 0.4.0", 1764 + "jacquard-api", 1765 + "jacquard-common", 1766 + "jacquard-derive", 1767 + "jacquard-identity", 1768 1768 "jacquard-oauth", 1769 1769 "jose-jwk", 1770 1770 "miette", ··· 1785 1785 1786 1786 [[package]] 1787 1787 name = "jacquard-api" 1788 - version = "0.4.0" 1789 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#0cbdaf71e0721122b354892bb8ae49aa3ffcc9bc" 1790 - dependencies = [ 1791 - "bon", 1792 - "bytes", 1793 - "jacquard-common 0.4.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", 1794 - "jacquard-derive 0.4.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", 1795 - "miette", 1796 - "serde", 1797 - "thiserror 2.0.17", 1798 - ] 1799 - 1800 - [[package]] 1801 - name = "jacquard-api" 1802 1788 version = "0.4.1" 1803 1789 dependencies = [ 1804 1790 "bon", 1805 1791 "bytes", 1806 - "jacquard-common 0.4.0", 1807 - "jacquard-derive 0.4.0", 1792 + "jacquard-common", 1793 + "jacquard-derive", 1808 1794 "miette", 1809 1795 "serde", 1810 1796 "thiserror 2.0.17", ··· 1812 1798 1813 1799 [[package]] 1814 1800 name = "jacquard-axum" 1815 - version = "0.4.0" 1801 + version = "0.5.0" 1816 1802 dependencies = [ 1817 1803 "axum", 1818 1804 "axum-macros", 1819 1805 "axum-test", 1820 1806 "bytes", 1821 1807 "jacquard", 1822 - "jacquard-common 0.4.0", 1808 + "jacquard-common", 1823 1809 "miette", 1824 1810 "serde", 1825 1811 "serde_html_form", ··· 1835 1821 1836 1822 [[package]] 1837 1823 name = "jacquard-common" 1838 - version = "0.4.0" 1824 + version = "0.5.0" 1839 1825 dependencies = [ 1840 1826 "async-trait", 1841 1827 "base64 0.22.1", ··· 1869 1855 ] 1870 1856 1871 1857 [[package]] 1872 - name = "jacquard-common" 1873 - version = "0.4.0" 1874 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#0cbdaf71e0721122b354892bb8ae49aa3ffcc9bc" 1875 - dependencies = [ 1876 - "async-trait", 1877 - "base64 0.22.1", 1878 - "bon", 1879 - "bytes", 1880 - "chrono", 1881 - "cid", 1882 - "http", 1883 - "ipld-core", 1884 - "langtag", 1885 - "miette", 1886 - "multibase", 1887 - "multihash", 1888 - "num-traits", 1889 - "ouroboros", 1890 - "rand 0.9.2", 1891 - "regex", 1892 - "reqwest", 1893 - "serde", 1894 - "serde_html_form", 1895 - "serde_ipld_dagcbor", 1896 - "serde_json", 1897 - "serde_with", 1898 - "smol_str", 1899 - "thiserror 2.0.17", 1900 - "tokio", 1901 - "trait-variant", 1902 - "url", 1903 - ] 1904 - 1905 - [[package]] 1906 1858 name = "jacquard-derive" 1907 - version = "0.4.0" 1859 + version = "0.5.0" 1908 1860 dependencies = [ 1909 1861 "heck 0.5.0", 1910 1862 "itertools", 1911 - "jacquard-common 0.4.0", 1912 - "prettyplease", 1913 - "proc-macro2", 1914 - "quote", 1915 - "serde", 1916 - "serde_json", 1917 - "serde_repr", 1918 - "serde_with", 1919 - "syn 2.0.106", 1920 - ] 1921 - 1922 - [[package]] 1923 - name = "jacquard-derive" 1924 - version = "0.4.0" 1925 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#0cbdaf71e0721122b354892bb8ae49aa3ffcc9bc" 1926 - dependencies = [ 1927 - "heck 0.5.0", 1928 - "itertools", 1863 + "jacquard-common", 1929 1864 "prettyplease", 1930 1865 "proc-macro2", 1931 1866 "quote", ··· 1938 1873 1939 1874 [[package]] 1940 1875 name = "jacquard-identity" 1941 - version = "0.4.0" 1876 + version = "0.4.1" 1942 1877 dependencies = [ 1943 1878 "async-trait", 1944 1879 "bon", 1945 1880 "bytes", 1946 1881 "hickory-resolver", 1947 1882 "http", 1948 - "jacquard-api 0.4.1", 1949 - "jacquard-common 0.4.0", 1950 - "miette", 1951 - "percent-encoding", 1952 - "reqwest", 1953 - "serde", 1954 - "serde_html_form", 1955 - "serde_json", 1956 - "thiserror 2.0.17", 1957 - "tokio", 1958 - "url", 1959 - "urlencoding", 1960 - ] 1961 - 1962 - [[package]] 1963 - name = "jacquard-identity" 1964 - version = "0.4.0" 1965 - source = "git+https://tangled.org/@nonbinary.computer/jacquard#0cbdaf71e0721122b354892bb8ae49aa3ffcc9bc" 1966 - dependencies = [ 1967 - "async-trait", 1968 - "bon", 1969 - "bytes", 1970 - "http", 1971 - "jacquard-api 0.4.0", 1972 - "jacquard-common 0.4.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", 1883 + "jacquard-api", 1884 + "jacquard-common", 1973 1885 "miette", 1974 1886 "percent-encoding", 1975 1887 "reqwest", ··· 1984 1896 1985 1897 [[package]] 1986 1898 name = "jacquard-lexicon" 1987 - version = "0.4.0" 1899 + version = "0.5.0" 1988 1900 dependencies = [ 1989 1901 "async-trait", 1990 1902 "clap", 1991 1903 "glob", 1992 1904 "heck 0.5.0", 1993 1905 "itertools", 1994 - "jacquard-api 0.4.0", 1995 - "jacquard-common 0.4.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", 1996 - "jacquard-identity 0.4.0 (git+https://tangled.org/@nonbinary.computer/jacquard)", 1906 + "jacquard-api", 1907 + "jacquard-common", 1908 + "jacquard-identity", 1997 1909 "kdl", 1998 1910 "miette", 1999 1911 "prettyplease", ··· 2013 1925 2014 1926 [[package]] 2015 1927 name = "jacquard-oauth" 2016 - version = "0.4.0" 1928 + version = "0.4.1" 2017 1929 dependencies = [ 2018 1930 "async-trait", 2019 1931 "base64 0.22.1", ··· 2022 1934 "dashmap", 2023 1935 "elliptic-curve", 2024 1936 "http", 2025 - "jacquard-common 0.4.0", 2026 - "jacquard-identity 0.4.0", 1937 + "jacquard-common", 1938 + "jacquard-identity", 2027 1939 "jose-jwa", 2028 1940 "jose-jwk", 2029 1941 "miette",
+1 -1
Cargo.toml
··· 5 5 6 6 [workspace.package] 7 7 edition = "2024" 8 - version = "0.4.0" 8 + version = "0.5.0" 9 9 authors = ["Orual <orual@nonbinary.computer>"] 10 10 repository = "https://tangled.org/@nonbinary.computer/jacquard" 11 11 keywords = ["atproto", "at", "bluesky", "api", "client"]
+2 -2
crates/jacquard-api/Cargo.toml
··· 17 17 [dependencies] 18 18 bon.workspace = true 19 19 bytes = { workspace = true, features = ["serde"] } 20 - jacquard-common = { version = "0.4", path = "../jacquard-common" } 21 - jacquard-derive = { version = "0.4", path = "../jacquard-derive" } 20 + jacquard-common = { version = "0.5", path = "../jacquard-common" } 21 + jacquard-derive = { version = "0.5", path = "../jacquard-derive" } 22 22 miette.workspace = true 23 23 serde.workspace = true 24 24 thiserror.workspace = true
+3 -3
crates/jacquard-axum/Cargo.toml
··· 1 1 [package] 2 2 name = "jacquard-axum" 3 3 edition.workspace = true 4 - version = "0.4.0" 4 + version = "0.5.0" 5 5 authors.workspace = true 6 6 repository.workspace = true 7 7 keywords.workspace = true ··· 24 24 axum = "0.8.6" 25 25 axum-macros = "0.5.0" 26 26 bytes.workspace = true 27 - jacquard = { version = "0.4", path = "../jacquard" } 28 - jacquard-common = { version = "0.4", path = "../jacquard-common", features = ["reqwest-client"] } 27 + jacquard = { version = "0.5", path = "../jacquard" } 28 + jacquard-common = { version = "0.5", path = "../jacquard-common", features = ["reqwest-client"] } 29 29 miette.workspace = true 30 30 serde.workspace = true 31 31 serde_html_form.workspace = true
+1 -1
crates/jacquard-common/Cargo.toml
··· 2 2 name = "jacquard-common" 3 3 description = "Core AT Protocol types and utilities for Jacquard" 4 4 edition.workspace = true 5 - version = "0.4.0" 5 + version = "0.5.0" 6 6 authors.workspace = true 7 7 repository.workspace = true 8 8 keywords.workspace = true
+1 -1
crates/jacquard-derive/Cargo.toml
··· 28 28 29 29 30 30 [dev-dependencies] 31 - jacquard-common = { version = "0.4", path = "../jacquard-common" } 31 + jacquard-common = { version = "0.5", path = "../jacquard-common" }
+3 -3
crates/jacquard-identity/Cargo.toml
··· 1 1 [package] 2 2 name = "jacquard-identity" 3 3 edition.workspace = true 4 - version = "0.4.0" 4 + version = "0.4.1" 5 5 authors.workspace = true 6 6 repository.workspace = true 7 7 keywords.workspace = true ··· 19 19 async-trait.workspace = true 20 20 bon.workspace = true 21 21 bytes.workspace = true 22 - jacquard-common = { version = "0.4", path = "../jacquard-common", features = ["reqwest-client"] } 23 - jacquard-api = { version = "0.4", path = "../jacquard-api" } 22 + jacquard-common = { version = "0.5", path = "../jacquard-common", features = ["reqwest-client"] } 23 + jacquard-api = { version = "0.4.1", path = "../jacquard-api" } 24 24 percent-encoding.workspace = true 25 25 reqwest.workspace = true 26 26 url.workspace = true
+3 -3
crates/jacquard-lexicon/Cargo.toml
··· 25 25 glob = "0.3" 26 26 heck.workspace = true 27 27 itertools.workspace = true 28 - jacquard-api = { version = "0.4", git = "https://tangled.org/@nonbinary.computer/jacquard" } 29 - jacquard-common = { version = "0.4", git = "https://tangled.org/@nonbinary.computer/jacquard" } 30 - jacquard-identity = { version = "0.4", git = "https://tangled.org/@nonbinary.computer/jacquard" } 28 + jacquard-api = { version = "0.4.1", git = "https://tangled.org/@nonbinary.computer/jacquard" } 29 + jacquard-common = { version = "0.5", git = "https://tangled.org/@nonbinary.computer/jacquard" } 30 + jacquard-identity = { version = "0.4.1", git = "https://tangled.org/@nonbinary.computer/jacquard" } 31 31 kdl = "6" 32 32 miette = { workspace = true, features = ["fancy"] } 33 33 prettyplease.workspace = true
+3 -3
crates/jacquard-oauth/Cargo.toml
··· 1 1 [package] 2 2 name = "jacquard-oauth" 3 - version = "0.4.0" 3 + version = "0.4.1" 4 4 edition.workspace = true 5 5 description = "AT Protocol OAuth 2.1 core types and helpers for Jacquard" 6 6 authors.workspace = true ··· 12 12 license.workspace = true 13 13 14 14 [dependencies] 15 - jacquard-common = { version = "0.4", path = "../jacquard-common", features = ["reqwest-client"] } 16 - jacquard-identity = { version = "0.4", path = "../jacquard-identity" } 15 + jacquard-common = { version = "0.5", path = "../jacquard-common", features = ["reqwest-client"] } 16 + jacquard-identity = { version = "0.4.1", path = "../jacquard-identity" } 17 17 serde = { workspace = true, features = ["derive"] } 18 18 serde_json = { workspace = true } 19 19 url = { workspace = true }
+5 -5
crates/jacquard/Cargo.toml
··· 79 79 required-features = ["fancy"] 80 80 81 81 [dependencies] 82 - jacquard-api = { version = "0.4", path = "../jacquard-api" } 83 - jacquard-common = { version = "0.4", path = "../jacquard-common", features = ["reqwest-client"] } 84 - jacquard-oauth = { version = "0.4", path = "../jacquard-oauth" } 85 - jacquard-derive = { version = "0.4", path = "../jacquard-derive", optional = true } 86 - jacquard-identity = { version = "0.4", path = "../jacquard-identity" } 82 + jacquard-api = { version = "0.4.1", path = "../jacquard-api" } 83 + jacquard-common = { version = "0.5", path = "../jacquard-common", features = ["reqwest-client"] } 84 + jacquard-oauth = { version = "0.4.1", path = "../jacquard-oauth" } 85 + jacquard-derive = { version = "0.5", path = "../jacquard-derive", optional = true } 86 + jacquard-identity = { version = "0.4.1", path = "../jacquard-identity" } 87 87 88 88 bon.workspace = true 89 89 async-trait.workspace = true
+402 -279
crates/jacquard/src/client.rs
··· 27 27 28 28 use jacquard_api::com_atproto::repo::create_record::CreateRecordOutput; 29 29 use jacquard_api::com_atproto::repo::delete_record::DeleteRecordOutput; 30 + use jacquard_api::com_atproto::repo::get_record::GetRecordResponse; 30 31 use jacquard_api::com_atproto::repo::put_record::PutRecordOutput; 32 + use jacquard_api::com_atproto::repo::upload_blob::UploadBlobResponse; 31 33 use jacquard_api::com_atproto::server::create_session::CreateSessionOutput; 32 34 use jacquard_api::com_atproto::server::refresh_session::RefreshSessionOutput; 33 35 use jacquard_common::error::TransportError; ··· 318 320 pub async fn refresh(&self) -> Result<AuthorizationToken<'static>, ClientError> { 319 321 self.inner.refresh().await 320 322 } 323 + } 321 324 322 - // Convenience methods for repository operations 323 - 325 + /// Extension trait providing convenience methods for common repository operations. 326 + /// 327 + /// This trait is automatically implemented for any type that implements both 328 + /// [`AgentSession`] and [`IdentityResolver`]. It provides higher-level methods 329 + /// that handle common patterns like fetch-modify-put, with automatic repo resolution 330 + /// for at:// uris, and typed record operations. 331 + /// 332 + /// # Available Operations 333 + /// 334 + /// - **Basic CRUD**: [`create_record`](Self::create_record), [`get_record`](Self::get_record), 335 + /// [`put_record`](Self::put_record), [`delete_record`](Self::delete_record) 336 + /// - **Update patterns**: [`update_record`](Self::update_record) (fetch-modify-put for records), 337 + /// [`update_vec`](Self::update_vec) and [`update_vec_item`](Self::update_vec_item) (for array endpoints) 338 + /// - **Blob operations**: [`upload_blob`](Self::upload_blob) 339 + /// 340 + /// # Example 341 + /// 342 + /// ```no_run 343 + /// # use jacquard::client::BasicClient; 344 + /// # use jacquard_api::app_bsky::feed::post::Post; 345 + /// # use jacquard_common::types::string::{AtUri, Datetime}; 346 + /// # use jacquard_common::CowStr; 347 + /// use jacquard::client::AgentSessionExt; 348 + /// # #[tokio::main] 349 + /// # async fn main() -> Result<(), Box<dyn std::error::Error>> { 350 + /// # let agent: BasicClient = todo!(); 351 + /// // Create a post 352 + /// let post = Post { 353 + /// text: CowStr::from("Hello from Jacquard!"), 354 + /// created_at: Datetime::now(), 355 + /// # embed: None, entities: None, facets: None, labels: None, 356 + /// # langs: None, reply: None, tags: None, extra_data: Default::default(), 357 + /// }; 358 + /// let output = agent.create_record(post, None).await?; 359 + /// 360 + /// // Read it back 361 + /// let response = agent.get_record::<Post>(output.uri).await?; 362 + /// let record = response.parse()?; 363 + /// println!("Post: {}", record.value.text); 364 + /// # Ok(()) 365 + /// # } 366 + /// ``` 367 + pub trait AgentSessionExt: AgentSession + IdentityResolver { 324 368 /// Create a new record in the repository. 325 369 /// 326 370 /// The collection is inferred from the record type's `Collection::NSID`. ··· 333 377 /// # use jacquard_api::app_bsky::feed::post::Post; 334 378 /// # use jacquard_common::types::string::Datetime; 335 379 /// # use jacquard_common::CowStr; 380 + /// use jacquard::client::AgentSessionExt; 336 381 /// # #[tokio::main] 337 382 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> { 338 383 /// # let agent: BasicClient = todo!(); ··· 353 398 /// # Ok(()) 354 399 /// # } 355 400 /// ``` 356 - pub async fn create_record<R>( 401 + fn create_record<R>( 357 402 &self, 358 403 record: R, 359 404 rkey: Option<RecordKey<Rkey<'_>>>, 360 - ) -> Result<CreateRecordOutput<'static>, AgentError> 405 + ) -> impl std::future::Future<Output = Result<CreateRecordOutput<'static>, AgentError>> 361 406 where 362 407 R: Collection + serde::Serialize, 363 408 { 364 - use jacquard_api::com_atproto::repo::create_record::CreateRecord; 365 - use jacquard_common::types::ident::AtIdentifier; 366 - use jacquard_common::types::value::to_data; 409 + async move { 410 + use jacquard_api::com_atproto::repo::create_record::CreateRecord; 411 + use jacquard_common::types::ident::AtIdentifier; 412 + use jacquard_common::types::value::to_data; 413 + 414 + let (did, _) = self.session_info().await.ok_or(AgentError::NoSession)?; 415 + 416 + let data = to_data(&record).map_err(|e| AgentError::SubOperation { 417 + step: "serialize record", 418 + error: Box::new(e), 419 + })?; 420 + 421 + let request = CreateRecord::new() 422 + .repo(AtIdentifier::Did(did)) 423 + .collection(R::nsid()) 424 + .record(data) 425 + .maybe_rkey(rkey) 426 + .build(); 427 + 428 + let response = self.send(request).await?; 429 + response.into_output().map_err(|e| match e { 430 + XrpcError::Auth(auth) => AgentError::Auth(auth), 431 + XrpcError::Generic(g) => AgentError::Generic(g), 432 + XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)), 433 + XrpcError::Xrpc(typed) => AgentError::SubOperation { 434 + step: "create record", 435 + error: Box::new(typed), 436 + }, 437 + }) 438 + } 439 + } 440 + 441 + /// Get a record from the repository using an at:// URI. 442 + /// 443 + /// Returns a typed `Response` that deserializes directly to the record type. 444 + /// Use `.parse()` to borrow from the response buffer, or `.into_output()` for owned data. 445 + /// 446 + /// # Example 447 + /// 448 + /// ```no_run 449 + /// # use jacquard::client::BasicClient; 450 + /// # use jacquard_api::app_bsky::feed::post::Post; 451 + /// # use jacquard_common::types::string::AtUri; 452 + /// # use jacquard_common::IntoStatic; 453 + /// use jacquard::client::AgentSessionExt; 454 + /// # #[tokio::main] 455 + /// # async fn main() -> Result<(), Box<dyn std::error::Error>> { 456 + /// # let agent: BasicClient = todo!(); 457 + /// let uri = AtUri::new_static("at://did:plc:xyz/app.bsky.feed.post/3l5bqm7lepk2c").unwrap(); 458 + /// let response = agent.get_record::<Post>(uri).await?; 459 + /// let output = response.parse()?; // PostGetRecordOutput<'_> borrowing from buffer 460 + /// println!("Post text: {}", output.value.text); 461 + /// 462 + /// // Or get owned data 463 + /// let output_owned = response.into_output()?; 464 + /// # Ok(()) 465 + /// # } 466 + /// ``` 467 + fn get_record<R>( 468 + &self, 469 + uri: AtUri<'_>, 470 + ) -> impl std::future::Future<Output = Result<Response<R::Record>, ClientError>> 471 + where 472 + R: Collection, 473 + { 474 + async move { 475 + // Validate that URI's collection matches the expected type 476 + if let Some(uri_collection) = uri.collection() { 477 + if uri_collection.as_str() != R::nsid().as_str() { 478 + return Err(ClientError::Transport(TransportError::Other( 479 + format!( 480 + "Collection mismatch: URI contains '{}' but type parameter expects '{}'", 481 + uri_collection, 482 + R::nsid() 483 + ) 484 + .into(), 485 + ))); 486 + } 487 + } 488 + 489 + let rkey = uri.rkey().ok_or_else(|| { 490 + ClientError::Transport(TransportError::Other("AtUri missing rkey".into())) 491 + })?; 492 + 493 + // Resolve authority (DID or handle) to get DID and PDS 494 + use jacquard_common::types::ident::AtIdentifier; 495 + let (repo_did, pds_url) = match uri.authority() { 496 + AtIdentifier::Did(did) => { 497 + let pds = self.pds_for_did(did).await.map_err(|e| { 498 + ClientError::Transport(TransportError::Other( 499 + format!("Failed to resolve PDS for {}: {}", did, e).into(), 500 + )) 501 + })?; 502 + (did.clone(), pds) 503 + } 504 + AtIdentifier::Handle(handle) => self.pds_for_handle(handle).await.map_err(|e| { 505 + ClientError::Transport(TransportError::Other( 506 + format!("Failed to resolve handle {}: {}", handle, e).into(), 507 + )) 508 + })?, 509 + }; 510 + 511 + // Make stateless XRPC call to that PDS (no auth required for public records) 512 + use jacquard_api::com_atproto::repo::get_record::GetRecord; 513 + let request = GetRecord::new() 514 + .repo(AtIdentifier::Did(repo_did)) 515 + .collection(R::nsid()) 516 + .rkey(rkey.clone()) 517 + .build(); 518 + 519 + let response: Response<GetRecordResponse> = { 520 + let http_request = xrpc::build_http_request(&pds_url, &request, &self.opts().await) 521 + .map_err(|e| ClientError::Transport(TransportError::from(e)))?; 522 + 523 + let http_response = self 524 + .send_http(http_request) 525 + .await 526 + .map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?; 527 + 528 + xrpc::process_response(http_response) 529 + }?; 530 + Ok(response.transmute()) 531 + } 532 + } 533 + 534 + /// Update a record in-place with a fetch-modify-put pattern. 535 + /// 536 + /// This fetches the record using an at:// URI, converts it to owned data, applies 537 + /// the modification function, and puts it back. The modification function receives 538 + /// a mutable reference to the record data. 539 + /// 540 + /// # Example 541 + /// 542 + /// ```no_run 543 + /// # use jacquard::client::BasicClient; 544 + /// # use jacquard_api::app_bsky::actor::profile::Profile; 545 + /// # use jacquard_common::CowStr; 546 + /// # use jacquard_common::types::string::AtUri; 547 + /// use jacquard::client::AgentSessionExt; 548 + /// # #[tokio::main] 549 + /// # async fn main() -> Result<(), Box<dyn std::error::Error>> { 550 + /// # let agent: BasicClient = todo!(); 551 + /// let uri = AtUri::new_static("at://did:plc:xyz/app.bsky.actor.profile/self").unwrap(); 552 + /// // Update profile record in-place 553 + /// agent.update_record::<Profile>(uri, |profile| { 554 + /// profile.display_name = Some(CowStr::from("New Name")); 555 + /// profile.description = Some(CowStr::from("Updated bio")); 556 + /// }).await?; 557 + /// # Ok(()) 558 + /// # } 559 + /// ``` 560 + fn update_record<R>( 561 + &self, 562 + uri: AtUri<'_>, 563 + f: impl FnOnce(&mut R), 564 + ) -> impl std::future::Future<Output = Result<PutRecordOutput<'static>, AgentError>> 565 + where 566 + R: Collection + Serialize, 567 + R: for<'a> From<<<R as Collection>::Record as XrpcResp>::Output<'a>>, 568 + { 569 + async move { 570 + // Fetch the record - Response<R::Record> where R::Record::Output<'de> = R<'de> 571 + let response = self.get_record::<R>(uri.clone()).await?; 367 572 368 - let (did, _) = self.info().await.ok_or(AgentError::NoSession)?; 573 + // Parse to get R<'_> borrowing from response buffer 574 + let record = response.parse().map_err(|e| match e { 575 + XrpcError::Auth(auth) => AgentError::Auth(auth), 576 + XrpcError::Generic(g) => AgentError::Generic(g), 577 + XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)), 578 + XrpcError::Xrpc(typed) => AgentError::SubOperation { 579 + step: "get record", 580 + error: format!("{:?}", typed).into(), 581 + }, 582 + })?; 369 583 370 - let data = to_data(&record).map_err(|e| AgentError::SubOperation { 371 - step: "serialize record", 372 - error: Box::new(e), 373 - })?; 584 + // Convert to owned 585 + let mut owned = R::from(record); 374 586 375 - let request = CreateRecord::new() 376 - .repo(AtIdentifier::Did(did)) 377 - .collection(R::nsid()) 378 - .record(data) 379 - .maybe_rkey(rkey) 380 - .build(); 587 + // Apply modification 588 + f(&mut owned); 381 589 382 - let response = self.send(request).await?; 383 - response.into_output().map_err(|e| match e { 384 - XrpcError::Auth(auth) => AgentError::Auth(auth), 385 - XrpcError::Generic(g) => AgentError::Generic(g), 386 - XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)), 387 - XrpcError::Xrpc(typed) => AgentError::SubOperation { 388 - step: "create record", 389 - error: Box::new(typed), 390 - }, 391 - }) 590 + // Put it back 591 + let rkey = uri 592 + .rkey() 593 + .ok_or(AgentError::SubOperation { 594 + step: "extract rkey", 595 + error: "AtUri missing rkey".into(), 596 + })? 597 + .clone() 598 + .into_static(); 599 + self.put_record::<R>(rkey, owned).await 600 + } 392 601 } 393 602 394 603 /// Delete a record from the repository. 395 604 /// 396 605 /// The collection is inferred from the type parameter. 397 606 /// The repo is automatically filled from the session info. 398 - pub async fn delete_record<R>( 607 + fn delete_record<R>( 399 608 &self, 400 609 rkey: RecordKey<Rkey<'_>>, 401 - ) -> Result<DeleteRecordOutput<'static>, AgentError> 610 + ) -> impl std::future::Future<Output = Result<DeleteRecordOutput<'static>, AgentError>> 402 611 where 403 612 R: Collection, 404 613 { 405 - use jacquard_api::com_atproto::repo::delete_record::DeleteRecord; 406 - use jacquard_common::types::ident::AtIdentifier; 614 + async { 615 + use jacquard_api::com_atproto::repo::delete_record::DeleteRecord; 616 + use jacquard_common::types::ident::AtIdentifier; 407 617 408 - let (did, _) = self.info().await.ok_or(AgentError::NoSession)?; 618 + let (did, _) = self.session_info().await.ok_or(AgentError::NoSession)?; 409 619 410 - let request = DeleteRecord::new() 411 - .repo(AtIdentifier::Did(did)) 412 - .collection(R::nsid()) 413 - .rkey(rkey) 414 - .build(); 620 + let request = DeleteRecord::new() 621 + .repo(AtIdentifier::Did(did)) 622 + .collection(R::nsid()) 623 + .rkey(rkey) 624 + .build(); 415 625 416 - let response = self.send(request).await?; 417 - response.into_output().map_err(|e| match e { 418 - XrpcError::Auth(auth) => AgentError::Auth(auth), 419 - XrpcError::Generic(g) => AgentError::Generic(g), 420 - XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)), 421 - XrpcError::Xrpc(typed) => AgentError::SubOperation { 422 - step: "delete record", 423 - error: Box::new(typed), 424 - }, 425 - }) 626 + let response = self.send(request).await?; 627 + response.into_output().map_err(|e| match e { 628 + XrpcError::Auth(auth) => AgentError::Auth(auth), 629 + XrpcError::Generic(g) => AgentError::Generic(g), 630 + XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)), 631 + XrpcError::Xrpc(typed) => AgentError::SubOperation { 632 + step: "delete record", 633 + error: Box::new(typed), 634 + }, 635 + }) 636 + } 426 637 } 427 638 428 639 /// Put (upsert) a record in the repository. 429 640 /// 430 641 /// The collection is inferred from the record type's `Collection::NSID`. 431 642 /// The repo is automatically filled from the session info. 432 - pub async fn put_record<R>( 643 + fn put_record<R>( 433 644 &self, 434 645 rkey: RecordKey<Rkey<'static>>, 435 646 record: R, 436 - ) -> Result<PutRecordOutput<'static>, AgentError> 647 + ) -> impl std::future::Future<Output = Result<PutRecordOutput<'static>, AgentError>> 437 648 where 438 649 R: Collection + serde::Serialize, 439 650 { 440 - use jacquard_api::com_atproto::repo::put_record::PutRecord; 441 - use jacquard_common::types::ident::AtIdentifier; 442 - use jacquard_common::types::value::to_data; 651 + async move { 652 + use jacquard_api::com_atproto::repo::put_record::PutRecord; 653 + use jacquard_common::types::ident::AtIdentifier; 654 + use jacquard_common::types::value::to_data; 443 655 444 - let (did, _) = self.info().await.ok_or(AgentError::NoSession)?; 656 + let (did, _) = self.session_info().await.ok_or(AgentError::NoSession)?; 445 657 446 - let data = to_data(&record).map_err(|e| AgentError::SubOperation { 447 - step: "serialize record", 448 - error: Box::new(e), 449 - })?; 658 + let data = to_data(&record).map_err(|e| AgentError::SubOperation { 659 + step: "serialize record", 660 + error: Box::new(e), 661 + })?; 450 662 451 - let request = PutRecord::new() 452 - .repo(AtIdentifier::Did(did)) 453 - .collection(R::nsid()) 454 - .rkey(rkey) 455 - .record(data) 456 - .build(); 663 + let request = PutRecord::new() 664 + .repo(AtIdentifier::Did(did)) 665 + .collection(R::nsid()) 666 + .rkey(rkey) 667 + .record(data) 668 + .build(); 457 669 458 - let response = self.send(request).await?; 459 - response.into_output().map_err(|e| match e { 460 - XrpcError::Auth(auth) => AgentError::Auth(auth), 461 - XrpcError::Generic(g) => AgentError::Generic(g), 462 - XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)), 463 - XrpcError::Xrpc(typed) => AgentError::SubOperation { 464 - step: "put record", 465 - error: Box::new(typed), 466 - }, 467 - }) 670 + let response = self.send(request).await?; 671 + response.into_output().map_err(|e| match e { 672 + XrpcError::Auth(auth) => AgentError::Auth(auth), 673 + XrpcError::Generic(g) => AgentError::Generic(g), 674 + XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)), 675 + XrpcError::Xrpc(typed) => AgentError::SubOperation { 676 + step: "put record", 677 + error: Box::new(typed), 678 + }, 679 + }) 680 + } 468 681 } 469 682 470 683 /// Upload a blob to the repository. ··· 477 690 /// ```no_run 478 691 /// # use jacquard::client::BasicClient; 479 692 /// # use jacquard_common::types::blob::MimeType; 693 + /// use jacquard::client::AgentSessionExt; 480 694 /// # #[tokio::main] 481 695 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> { 482 696 /// # let agent: BasicClient = todo!(); ··· 486 700 /// # Ok(()) 487 701 /// # } 488 702 /// ``` 489 - pub async fn upload_blob( 703 + fn upload_blob( 490 704 &self, 491 705 data: impl Into<bytes::Bytes>, 492 706 mime_type: MimeType<'_>, 493 - ) -> Result<Blob<'static>, AgentError> { 494 - use http::header::CONTENT_TYPE; 495 - use jacquard_api::com_atproto::repo::upload_blob::UploadBlob; 707 + ) -> impl std::future::Future<Output = Result<Blob<'static>, AgentError>> { 708 + async move { 709 + use http::header::CONTENT_TYPE; 710 + use jacquard_api::com_atproto::repo::upload_blob::UploadBlob; 711 + 712 + let bytes = data.into(); 713 + let request = UploadBlob::new().body(bytes).build(); 714 + 715 + // Override Content-Type header with actual mime type instead of */* 716 + let base = self.base_uri(); 717 + let mut opts = self.opts().await; 718 + opts.extra_headers.push(( 719 + CONTENT_TYPE, 720 + http::HeaderValue::from_str(mime_type.as_str()).map_err(|e| { 721 + AgentError::SubOperation { 722 + step: "set Content-Type header", 723 + error: Box::new(e), 724 + } 725 + })?, 726 + )); 496 727 497 - let bytes = data.into(); 498 - let request = UploadBlob::new().body(bytes).build(); 728 + let response: Response<UploadBlobResponse> = { 729 + let http_request = 730 + xrpc::build_http_request(&base, &request, &opts).map_err(|e| { 731 + AgentError::Client(ClientError::Transport(TransportError::from(e))) 732 + })?; 499 733 500 - // Override Content-Type header with actual mime type instead of */* 501 - let base = self.base_uri(); 502 - let mut opts = self.opts().await; 503 - opts.extra_headers.push(( 504 - CONTENT_TYPE, 505 - http::HeaderValue::from_str(mime_type.as_str()).map_err(|e| { 506 - AgentError::SubOperation { 507 - step: "set Content-Type header", 508 - error: Box::new(e), 509 - } 510 - })?, 511 - )); 734 + let http_response = self.send_http(http_request).await.map_err(|e| { 735 + AgentError::Client(ClientError::Transport(TransportError::Other(Box::new(e)))) 736 + })?; 512 737 513 - let response = self.xrpc(base).with_options(opts).send(&request).await?; 738 + xrpc::process_response(http_response) 739 + }?; 514 740 515 - let output = response.into_output().map_err(|e| match e { 516 - XrpcError::Auth(auth) => AgentError::Auth(auth), 517 - XrpcError::Generic(g) => AgentError::Generic(g), 518 - XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)), 519 - XrpcError::Xrpc(typed) => AgentError::SubOperation { 520 - step: "upload blob", 521 - error: Box::new(typed), 522 - }, 523 - })?; 524 - Ok(output.blob.into_static()) 741 + let output = response.into_output().map_err(|e| match e { 742 + XrpcError::Auth(auth) => AgentError::Auth(auth), 743 + XrpcError::Generic(g) => AgentError::Generic(g), 744 + XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)), 745 + XrpcError::Xrpc(typed) => AgentError::SubOperation { 746 + step: "upload blob", 747 + error: Box::new(typed), 748 + }, 749 + })?; 750 + Ok(output.blob.into_static()) 751 + } 525 752 } 526 753 527 754 /// Update a vec-based data structure with a fetch-modify-put pattern. ··· 537 764 /// prefs.retain(|p| !matches!(p, Preference::Hidden(_))); 538 765 /// }).await?; 539 766 /// ``` 540 - pub async fn update_vec<'s, U>( 767 + fn update_vec<'s, U>( 541 768 &'s self, 542 769 modify: impl FnOnce(&mut Vec<<U as vec_update::VecUpdate>::Item>), 543 - ) -> Result<xrpc::Response<<U::PutRequest<'s> as XrpcRequest<'s>>::Response>, AgentError> 770 + ) -> impl std::future::Future< 771 + Output = Result< 772 + xrpc::Response<<U::PutRequest<'s> as XrpcRequest<'s>>::Response>, 773 + AgentError, 774 + >, 775 + > 544 776 where 545 777 U: vec_update::VecUpdate + 's, 546 778 { 547 - // Fetch current data 548 - let get_request = U::build_get(); 549 - let response = self.send(get_request).await?; 550 - let output = response.parse().map_err(|e| match e { 551 - XrpcError::Auth(auth) => AgentError::Auth(auth), 552 - XrpcError::Generic(g) => AgentError::Generic(g), 553 - XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)), 554 - XrpcError::Xrpc(_) => AgentError::SubOperation { 555 - step: "get vec", 556 - error: format!("{:?}", e).into(), 557 - }, 558 - })?; 779 + async { 780 + // Fetch current data 781 + let get_request = U::build_get(); 782 + let response = self.send(get_request).await?; 783 + let output = response.parse().map_err(|e| match e { 784 + XrpcError::Auth(auth) => AgentError::Auth(auth), 785 + XrpcError::Generic(g) => AgentError::Generic(g), 786 + XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)), 787 + XrpcError::Xrpc(_) => AgentError::SubOperation { 788 + step: "get vec", 789 + error: format!("{:?}", e).into(), 790 + }, 791 + })?; 559 792 560 - // Extract vec (converts to owned via IntoStatic) 561 - let mut items = U::extract_vec(output); 793 + // Extract vec (converts to owned via IntoStatic) 794 + let mut items = U::extract_vec(output); 562 795 563 - // Apply modification 564 - modify(&mut items); 796 + // Apply modification 797 + modify(&mut items); 565 798 566 - // Build put request 567 - let put_request = U::build_put(items); 799 + // Build put request 800 + let put_request = U::build_put(items); 568 801 569 - // Send it 570 - Ok(self.send(put_request).await?) 802 + // Send it 803 + Ok(self.send(put_request).await?) 804 + } 571 805 } 572 806 573 807 /// Update a single item in a vec-based data structure. ··· 581 815 /// let pref = AdultContentPref::new().enabled(true).build(); 582 816 /// agent.update_vec_item::<PreferencesUpdate>(pref.into()).await?; 583 817 /// ``` 584 - pub async fn update_vec_item<'s, U>( 818 + fn update_vec_item<'s, U>( 585 819 &'s self, 586 820 item: <U as vec_update::VecUpdate>::Item, 587 - ) -> Result<xrpc::Response<<U::PutRequest<'s> as XrpcRequest<'s>>::Response>, AgentError> 821 + ) -> impl std::future::Future< 822 + Output = Result< 823 + xrpc::Response<<U::PutRequest<'s> as XrpcRequest<'s>>::Response>, 824 + AgentError, 825 + >, 826 + > 588 827 where 589 828 U: vec_update::VecUpdate + 's, 590 829 { 591 - self.update_vec::<U>(|vec| { 592 - if let Some(pos) = vec.iter().position(|i| U::matches(i, &item)) { 593 - vec[pos] = item; 594 - } else { 595 - vec.push(item); 596 - } 597 - }) 598 - .await 599 - } 600 - } 601 - 602 - impl<A: AgentSession + IdentityResolver> Agent<A> { 603 - /// Get a record from the repository using an at:// URI. 604 - /// 605 - /// Returns a typed `Response` that deserializes directly to the record type. 606 - /// Use `.parse()` to borrow from the response buffer, or `.into_output()` for owned data. 607 - /// 608 - /// # Example 609 - /// 610 - /// ```no_run 611 - /// # use jacquard::client::BasicClient; 612 - /// # use jacquard_api::app_bsky::feed::post::Post; 613 - /// # use jacquard_common::types::string::AtUri; 614 - /// # use jacquard_common::IntoStatic; 615 - /// # #[tokio::main] 616 - /// # async fn main() -> Result<(), Box<dyn std::error::Error>> { 617 - /// # let agent: BasicClient = todo!(); 618 - /// let uri = AtUri::new_static("at://did:plc:xyz/app.bsky.feed.post/3l5bqm7lepk2c").unwrap(); 619 - /// let response = agent.get_record::<Post>(uri).await?; 620 - /// let output = response.parse()?; // PostGetRecordOutput<'_> borrowing from buffer 621 - /// println!("Post text: {}", output.value.text); 622 - /// 623 - /// // Or get owned data 624 - /// let output_owned = response.into_output()?; 625 - /// # Ok(()) 626 - /// # } 627 - /// ``` 628 - pub async fn get_record<R>(&self, uri: AtUri<'_>) -> Result<Response<R::Record>, ClientError> 629 - where 630 - R: Collection, 631 - { 632 - // Validate that URI's collection matches the expected type 633 - if let Some(uri_collection) = uri.collection() { 634 - if uri_collection.as_str() != R::nsid().as_str() { 635 - return Err(ClientError::Transport(TransportError::Other( 636 - format!( 637 - "Collection mismatch: URI contains '{}' but type parameter expects '{}'", 638 - uri_collection, 639 - R::nsid() 640 - ) 641 - .into(), 642 - ))); 643 - } 830 + async { 831 + self.update_vec::<U>(|vec| { 832 + if let Some(pos) = vec.iter().position(|i| U::matches(i, &item)) { 833 + vec[pos] = item; 834 + } else { 835 + vec.push(item); 836 + } 837 + }) 838 + .await 644 839 } 645 - 646 - let rkey = uri.rkey().ok_or_else(|| { 647 - ClientError::Transport(TransportError::Other("AtUri missing rkey".into())) 648 - })?; 649 - 650 - // Resolve authority (DID or handle) to get DID and PDS 651 - use jacquard_common::types::ident::AtIdentifier; 652 - let (repo_did, pds_url) = match uri.authority() { 653 - AtIdentifier::Did(did) => { 654 - let pds = self.pds_for_did(did).await.map_err(|e| { 655 - ClientError::Transport(TransportError::Other( 656 - format!("Failed to resolve PDS for {}: {}", did, e).into(), 657 - )) 658 - })?; 659 - (did.clone(), pds) 660 - } 661 - AtIdentifier::Handle(handle) => self.pds_for_handle(handle).await.map_err(|e| { 662 - ClientError::Transport(TransportError::Other( 663 - format!("Failed to resolve handle {}: {}", handle, e).into(), 664 - )) 665 - })?, 666 - }; 667 - 668 - // Make stateless XRPC call to that PDS (no auth required for public records) 669 - use jacquard_api::com_atproto::repo::get_record::GetRecord; 670 - let request = GetRecord::new() 671 - .repo(AtIdentifier::Did(repo_did)) 672 - .collection(R::nsid()) 673 - .rkey(rkey.clone()) 674 - .build(); 675 - 676 - let response = self.xrpc(pds_url).send(&request).await?; 677 - Ok(response.transmute()) 678 840 } 679 - 680 - /// Update a record in-place with a fetch-modify-put pattern. 681 - /// 682 - /// This fetches the record using an at:// URI, converts it to owned data, applies 683 - /// the modification function, and puts it back. The modification function receives 684 - /// a mutable reference to the record data. 685 - /// 686 - /// # Example 687 - /// 688 - /// ```no_run 689 - /// # use jacquard::client::BasicClient; 690 - /// # use jacquard_api::app_bsky::actor::profile::Profile; 691 - /// # use jacquard_common::CowStr; 692 - /// # use jacquard_common::types::string::AtUri; 693 - /// # #[tokio::main] 694 - /// # async fn main() -> Result<(), Box<dyn std::error::Error>> { 695 - /// # let agent: BasicClient = todo!(); 696 - /// let uri = AtUri::new_static("at://did:plc:xyz/app.bsky.actor.profile/self").unwrap(); 697 - /// // Update profile record in-place 698 - /// agent.update_record::<Profile>(uri, |profile| { 699 - /// profile.display_name = Some(CowStr::from("New Name")); 700 - /// profile.description = Some(CowStr::from("Updated bio")); 701 - /// }).await?; 702 - /// # Ok(()) 703 - /// # } 704 - /// ``` 705 - pub async fn update_record<R>( 706 - &self, 707 - uri: AtUri<'_>, 708 - f: impl FnOnce(&mut R), 709 - ) -> Result<PutRecordOutput<'static>, AgentError> 710 - where 711 - R: Collection + Serialize, 712 - R: for<'a> From<<<R as Collection>::Record as XrpcResp>::Output<'a>>, 713 - { 714 - // Fetch the record - Response<R::Record> where R::Record::Output<'de> = R<'de> 715 - let response = self.get_record::<R>(uri.clone()).await?; 841 + } 716 842 717 - // Parse to get R<'_> borrowing from response buffer 718 - let record = response.parse().map_err(|e| match e { 719 - XrpcError::Auth(auth) => AgentError::Auth(auth), 720 - XrpcError::Generic(g) => AgentError::Generic(g), 721 - XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)), 722 - XrpcError::Xrpc(typed) => AgentError::SubOperation { 723 - step: "get record", 724 - error: format!("{:?}", typed).into(), 725 - }, 726 - })?; 727 - 728 - // Convert to owned 729 - let mut owned = R::from(record); 730 - 731 - // Apply modification 732 - f(&mut owned); 733 - 734 - // Put it back 735 - let rkey = uri 736 - .rkey() 737 - .ok_or(AgentError::SubOperation { 738 - step: "extract rkey", 739 - error: "AtUri missing rkey".into(), 740 - })? 741 - .clone() 742 - .into_static(); 743 - self.put_record::<R>(rkey, owned).await 744 - } 745 - } 843 + impl<T: AgentSession + IdentityResolver> AgentSessionExt for T {} 746 844 747 845 impl<A: AgentSession> HttpClient for Agent<A> { 748 846 type Error = <A as HttpClient>::Error; ··· 800 898 } 801 899 } 802 900 901 + impl<A: AgentSession> AgentSession for Agent<A> { 902 + fn session_kind(&self) -> AgentKind { 903 + self.kind() 904 + } 905 + 906 + fn session_info( 907 + &self, 908 + ) -> impl Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>> { 909 + async { self.info().await } 910 + } 911 + 912 + fn endpoint(&self) -> impl Future<Output = url::Url> { 913 + async { self.endpoint().await } 914 + } 915 + 916 + fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future<Output = ()> { 917 + async { self.set_options(opts).await } 918 + } 919 + 920 + fn refresh(&self) -> impl Future<Output = Result<AuthorizationToken<'static>, ClientError>> { 921 + async { self.refresh().await } 922 + } 923 + } 924 + 803 925 impl<A: AgentSession> From<A> for Agent<A> { 804 926 fn from(inner: A) -> Self { 805 927 Self::new(inner) ··· 831 953 /// # use jacquard::client::BasicClient; 832 954 /// # use jacquard::types::string::AtUri; 833 955 /// # use jacquard_api::app_bsky::feed::post::Post; 956 + /// use crate::jacquard::client::AgentSessionExt; 834 957 /// # #[tokio::main] 835 958 /// # async fn main() -> Result<(), Box<dyn std::error::Error>> { 836 959 /// let client = BasicClient::unauthenticated();
+1
crates/jacquard/src/client/credential_session.rs
··· 408 408 ) 409 409 } 410 410 } 411 + 411 412 async fn send<'s, R>( 412 413 &self, 413 414 request: R,
+3 -38
examples/update_preferences.rs
··· 1 1 use clap::Parser; 2 + use jacquard::CowStr; 2 3 use jacquard::api::app_bsky::actor::{AdultContentPref, PreferencesItem}; 3 - use jacquard::client::vec_update::VecUpdate; 4 + use jacquard::client::AgentSessionExt; 5 + use jacquard::client::vec_update::PreferencesUpdate; 4 6 use jacquard::client::{Agent, FileAuthStore}; 5 - use jacquard::oauth::atproto::AtprotoClientMetadata; 6 7 use jacquard::oauth::client::OAuthClient; 7 8 use jacquard::oauth::loopback::LoopbackConfig; 8 - use jacquard::{CowStr, IntoStatic}; 9 9 10 10 #[derive(Parser, Debug)] 11 11 #[command(author, version, about = "Update Bluesky preferences")] ··· 20 20 /// Path to auth store file (will be created if missing) 21 21 #[arg(long, default_value = "/tmp/jacquard-oauth-session.json")] 22 22 store: String, 23 - } 24 - 25 - /// Helper struct for the VecUpdate pattern on preferences 26 - pub struct PreferencesUpdate; 27 - 28 - impl VecUpdate for PreferencesUpdate { 29 - type GetRequest<'de> = jacquard::api::app_bsky::actor::get_preferences::GetPreferences; 30 - type PutRequest<'de> = jacquard::api::app_bsky::actor::put_preferences::PutPreferences<'de>; 31 - type Item = PreferencesItem<'static>; 32 - 33 - fn build_get<'s>() -> Self::GetRequest<'s> { 34 - jacquard::api::app_bsky::actor::get_preferences::GetPreferences::new().build() 35 - } 36 - 37 - fn build_put<'s>(items: Vec<Self::Item>) -> Self::PutRequest<'s> { 38 - jacquard::api::app_bsky::actor::put_preferences::PutPreferences { 39 - preferences: items, 40 - extra_data: Default::default(), 41 - } 42 - } 43 - 44 - fn extract_vec( 45 - output: jacquard::api::app_bsky::actor::get_preferences::GetPreferencesOutput<'_>, 46 - ) -> Vec<Self::Item> { 47 - output 48 - .preferences 49 - .into_iter() 50 - .map(|p| p.into_static()) 51 - .collect() 52 - } 53 - 54 - fn matches(a: &Self::Item, b: &Self::Item) -> bool { 55 - // Match by enum variant discriminant 56 - std::mem::discriminant(a) == std::mem::discriminant(b) 57 - } 58 23 } 59 24 60 25 #[tokio::main]