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
···
# Changelog
## [0.4.0] - 2025-10-11
### Breaking Changes
···
# Changelog
+
## [0.5.0] - 2025-10-13
+
+
### Breaking Changes
+
+
**AgentSession trait** (`jacquard`)
+
- Removed `async fn` in favour of `impl Future` return types for better trait object compatibility
+
- Methods now return `impl Future` instead of being marked `async fn`
+
+
**XRPC improvements** (`jacquard-common`)
+
- Simplified response transmutation for typed record retrieval
+
- `Response::transmute()` added for zero-cost response type conversion
+
+
**jacquard-axum**
+
- Removed binary target (`main.rs`), now library-only
+
+
### Added
+
+
**Agent convenience methods** (`jacquard`)
+
- New `AgentSessionExt` trait automatically implemented for `AgentSession + IdentityResolver`
+
- **Basic CRUD**: `create_record()`, `get_record()`, `put_record()`, `delete_record()`
+
- **Update patterns**: `update_record()` (fetch-modify-put), `update_vec()`, `update_vec_item()`
+
- **Blob operations**: `upload_blob()`
+
- All methods auto-fill repo from session and collection from type's `Collection::NSID`
+
- Simplified bounds on `update_record` - no HRTB issues, works with all record types
+
+
**VecUpdate trait** (`jacquard`)
+
- `VecUpdate` trait for fetch-modify-put patterns on array-based endpoints
+
- `PreferencesUpdate` implementation for updating user preferences
+
- Enables type-safe updates to preferences, saved feeds, and other array endpoints
+
+
**Typed record retrieval** (`jacquard-api`, `jacquard-common`)
+
- Each collection generates `{Type}Record` marker struct implementing `XrpcResp`
+
- `Collection::Record` associated type points to the marker
+
- `get_record::<R>()` returns `Response<R::Record>` with zero-copy `.parse()`
+
- Response transmutation enables type-safe record operations
+
+
**Examples**
+
- `create_post.rs`: Creating posts with Agent convenience methods
+
- `update_profile.rs`: Updating profile with fetch-modify-put
+
- `post_with_image.rs`: Uploading images and creating posts with embeds
+
- `update_preferences.rs`: Using VecUpdate for preferences
+
- `create_whitewind_post.rs`, `read_whitewind_post.rs`: Third-party lexicons
+
- `read_tangled_repo.rs`: Reading git repo metadata from tangled.sh
+
- `resolve_did.rs`: Identity resolution examples
+
- `public_atproto_feed.rs`: Unauthenticated feed access
+
- `axum_server.rs`: Server-side XRPC handler
+
+
### Changed
+
+
**Code organization** (`jacquard-lexicon`)
+
- Refactored monolithic `codegen.rs` into focused modules:
+
- `codegen/structs.rs`: Record and object generation
+
- `codegen/xrpc.rs`: XRPC request/response generation
+
- `codegen/types.rs`: Type alias and union generation
+
- `codegen/names.rs`: Identifier sanitization and naming
+
- `codegen/lifetime.rs`: Lifetime propagation logic
+
- `codegen/output.rs`: Module and feature generation
+
- `codegen/utils.rs`: Shared utilities
+
- Improved code navigation and maintainability
+
+
**Documentation** (`jacquard`)
+
- Added comprehensive trait-level docs for `AgentSessionExt`
+
- Updated examples to use new convenience methods
+
+
### Fixed
+
+
- `update_record` now works with all record types without lifetime issues
+
- Proper `IdentityResolver` bounds on `AgentSessionExt`
+
+
## [0.4.1] - 2025-10-13
+
+
### Added
+
+
**Collection trait improvements** (`jacquard-api`)
+
- Generated `{Type}Record` marker structs for all record types
+
- Each implements `XrpcResp` with `Output<'de> = {Type}<'de>` and `Err<'de> = RecordError<'de>`
+
- Enables typed `get_record` returning `Response<R::Record>`
+
+
### Changed
+
+
- Minor improvements to derive macros (`jacquard-derive`)
+
- Identity resolution refinements (`jacquard-identity`)
+
- OAuth client improvements (`jacquard-oauth`)
+
## [0.4.0] - 2025-10-11
### Breaking Changes
+22 -110
Cargo.lock
···
[[package]]
name = "jacquard"
-
version = "0.4.0"
dependencies = [
"async-trait",
"bon",
"bytes",
"clap",
"http",
-
"jacquard-api 0.4.1",
-
"jacquard-common 0.4.0",
-
"jacquard-derive 0.4.0",
-
"jacquard-identity 0.4.0",
"jacquard-oauth",
"jose-jwk",
"miette",
···
[[package]]
name = "jacquard-api"
-
version = "0.4.0"
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#0cbdaf71e0721122b354892bb8ae49aa3ffcc9bc"
-
dependencies = [
-
"bon",
-
"bytes",
-
"jacquard-common 0.4.0 (git+https://tangled.org/@nonbinary.computer/jacquard)",
-
"jacquard-derive 0.4.0 (git+https://tangled.org/@nonbinary.computer/jacquard)",
-
"miette",
-
"serde",
-
"thiserror 2.0.17",
-
]
-
-
[[package]]
-
name = "jacquard-api"
version = "0.4.1"
dependencies = [
"bon",
"bytes",
-
"jacquard-common 0.4.0",
-
"jacquard-derive 0.4.0",
"miette",
"serde",
"thiserror 2.0.17",
···
[[package]]
name = "jacquard-axum"
-
version = "0.4.0"
dependencies = [
"axum",
"axum-macros",
"axum-test",
"bytes",
"jacquard",
-
"jacquard-common 0.4.0",
"miette",
"serde",
"serde_html_form",
···
[[package]]
name = "jacquard-common"
-
version = "0.4.0"
dependencies = [
"async-trait",
"base64 0.22.1",
···
]
[[package]]
-
name = "jacquard-common"
-
version = "0.4.0"
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#0cbdaf71e0721122b354892bb8ae49aa3ffcc9bc"
-
dependencies = [
-
"async-trait",
-
"base64 0.22.1",
-
"bon",
-
"bytes",
-
"chrono",
-
"cid",
-
"http",
-
"ipld-core",
-
"langtag",
-
"miette",
-
"multibase",
-
"multihash",
-
"num-traits",
-
"ouroboros",
-
"rand 0.9.2",
-
"regex",
-
"reqwest",
-
"serde",
-
"serde_html_form",
-
"serde_ipld_dagcbor",
-
"serde_json",
-
"serde_with",
-
"smol_str",
-
"thiserror 2.0.17",
-
"tokio",
-
"trait-variant",
-
"url",
-
]
-
-
[[package]]
name = "jacquard-derive"
-
version = "0.4.0"
dependencies = [
"heck 0.5.0",
"itertools",
-
"jacquard-common 0.4.0",
-
"prettyplease",
-
"proc-macro2",
-
"quote",
-
"serde",
-
"serde_json",
-
"serde_repr",
-
"serde_with",
-
"syn 2.0.106",
-
]
-
-
[[package]]
-
name = "jacquard-derive"
-
version = "0.4.0"
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#0cbdaf71e0721122b354892bb8ae49aa3ffcc9bc"
-
dependencies = [
-
"heck 0.5.0",
-
"itertools",
"prettyplease",
"proc-macro2",
"quote",
···
[[package]]
name = "jacquard-identity"
-
version = "0.4.0"
dependencies = [
"async-trait",
"bon",
"bytes",
"hickory-resolver",
"http",
-
"jacquard-api 0.4.1",
-
"jacquard-common 0.4.0",
-
"miette",
-
"percent-encoding",
-
"reqwest",
-
"serde",
-
"serde_html_form",
-
"serde_json",
-
"thiserror 2.0.17",
-
"tokio",
-
"url",
-
"urlencoding",
-
]
-
-
[[package]]
-
name = "jacquard-identity"
-
version = "0.4.0"
-
source = "git+https://tangled.org/@nonbinary.computer/jacquard#0cbdaf71e0721122b354892bb8ae49aa3ffcc9bc"
-
dependencies = [
-
"async-trait",
-
"bon",
-
"bytes",
-
"http",
-
"jacquard-api 0.4.0",
-
"jacquard-common 0.4.0 (git+https://tangled.org/@nonbinary.computer/jacquard)",
"miette",
"percent-encoding",
"reqwest",
···
[[package]]
name = "jacquard-lexicon"
-
version = "0.4.0"
dependencies = [
"async-trait",
"clap",
"glob",
"heck 0.5.0",
"itertools",
-
"jacquard-api 0.4.0",
-
"jacquard-common 0.4.0 (git+https://tangled.org/@nonbinary.computer/jacquard)",
-
"jacquard-identity 0.4.0 (git+https://tangled.org/@nonbinary.computer/jacquard)",
"kdl",
"miette",
"prettyplease",
···
[[package]]
name = "jacquard-oauth"
-
version = "0.4.0"
dependencies = [
"async-trait",
"base64 0.22.1",
···
"dashmap",
"elliptic-curve",
"http",
-
"jacquard-common 0.4.0",
-
"jacquard-identity 0.4.0",
"jose-jwa",
"jose-jwk",
"miette",
···
[[package]]
name = "jacquard"
+
version = "0.5.0"
dependencies = [
"async-trait",
"bon",
"bytes",
"clap",
"http",
+
"jacquard-api",
+
"jacquard-common",
+
"jacquard-derive",
+
"jacquard-identity",
"jacquard-oauth",
"jose-jwk",
"miette",
···
[[package]]
name = "jacquard-api"
version = "0.4.1"
dependencies = [
"bon",
"bytes",
+
"jacquard-common",
+
"jacquard-derive",
"miette",
"serde",
"thiserror 2.0.17",
···
[[package]]
name = "jacquard-axum"
+
version = "0.5.0"
dependencies = [
"axum",
"axum-macros",
"axum-test",
"bytes",
"jacquard",
+
"jacquard-common",
"miette",
"serde",
"serde_html_form",
···
[[package]]
name = "jacquard-common"
+
version = "0.5.0"
dependencies = [
"async-trait",
"base64 0.22.1",
···
]
[[package]]
name = "jacquard-derive"
+
version = "0.5.0"
dependencies = [
"heck 0.5.0",
"itertools",
+
"jacquard-common",
"prettyplease",
"proc-macro2",
"quote",
···
[[package]]
name = "jacquard-identity"
+
version = "0.4.1"
dependencies = [
"async-trait",
"bon",
"bytes",
"hickory-resolver",
"http",
+
"jacquard-api",
+
"jacquard-common",
"miette",
"percent-encoding",
"reqwest",
···
[[package]]
name = "jacquard-lexicon"
+
version = "0.5.0"
dependencies = [
"async-trait",
"clap",
"glob",
"heck 0.5.0",
"itertools",
+
"jacquard-api",
+
"jacquard-common",
+
"jacquard-identity",
"kdl",
"miette",
"prettyplease",
···
[[package]]
name = "jacquard-oauth"
+
version = "0.4.1"
dependencies = [
"async-trait",
"base64 0.22.1",
···
"dashmap",
"elliptic-curve",
"http",
+
"jacquard-common",
+
"jacquard-identity",
"jose-jwa",
"jose-jwk",
"miette",
+1 -1
Cargo.toml
···
[workspace.package]
edition = "2024"
-
version = "0.4.0"
authors = ["Orual <orual@nonbinary.computer>"]
repository = "https://tangled.org/@nonbinary.computer/jacquard"
keywords = ["atproto", "at", "bluesky", "api", "client"]
···
[workspace.package]
edition = "2024"
+
version = "0.5.0"
authors = ["Orual <orual@nonbinary.computer>"]
repository = "https://tangled.org/@nonbinary.computer/jacquard"
keywords = ["atproto", "at", "bluesky", "api", "client"]
+2 -2
crates/jacquard-api/Cargo.toml
···
[dependencies]
bon.workspace = true
bytes = { workspace = true, features = ["serde"] }
-
jacquard-common = { version = "0.4", path = "../jacquard-common" }
-
jacquard-derive = { version = "0.4", path = "../jacquard-derive" }
miette.workspace = true
serde.workspace = true
thiserror.workspace = true
···
[dependencies]
bon.workspace = true
bytes = { workspace = true, features = ["serde"] }
+
jacquard-common = { version = "0.5", path = "../jacquard-common" }
+
jacquard-derive = { version = "0.5", path = "../jacquard-derive" }
miette.workspace = true
serde.workspace = true
thiserror.workspace = true
+3 -3
crates/jacquard-axum/Cargo.toml
···
[package]
name = "jacquard-axum"
edition.workspace = true
-
version = "0.4.0"
authors.workspace = true
repository.workspace = true
keywords.workspace = true
···
axum = "0.8.6"
axum-macros = "0.5.0"
bytes.workspace = true
-
jacquard = { version = "0.4", path = "../jacquard" }
-
jacquard-common = { version = "0.4", path = "../jacquard-common", features = ["reqwest-client"] }
miette.workspace = true
serde.workspace = true
serde_html_form.workspace = true
···
[package]
name = "jacquard-axum"
edition.workspace = true
+
version = "0.5.0"
authors.workspace = true
repository.workspace = true
keywords.workspace = true
···
axum = "0.8.6"
axum-macros = "0.5.0"
bytes.workspace = true
+
jacquard = { version = "0.5", path = "../jacquard" }
+
jacquard-common = { version = "0.5", path = "../jacquard-common", features = ["reqwest-client"] }
miette.workspace = true
serde.workspace = true
serde_html_form.workspace = true
+1 -1
crates/jacquard-common/Cargo.toml
···
name = "jacquard-common"
description = "Core AT Protocol types and utilities for Jacquard"
edition.workspace = true
-
version = "0.4.0"
authors.workspace = true
repository.workspace = true
keywords.workspace = true
···
name = "jacquard-common"
description = "Core AT Protocol types and utilities for Jacquard"
edition.workspace = true
+
version = "0.5.0"
authors.workspace = true
repository.workspace = true
keywords.workspace = true
+1 -1
crates/jacquard-derive/Cargo.toml
···
[dev-dependencies]
-
jacquard-common = { version = "0.4", path = "../jacquard-common" }
···
[dev-dependencies]
+
jacquard-common = { version = "0.5", path = "../jacquard-common" }
+3 -3
crates/jacquard-identity/Cargo.toml
···
[package]
name = "jacquard-identity"
edition.workspace = true
-
version = "0.4.0"
authors.workspace = true
repository.workspace = true
keywords.workspace = true
···
async-trait.workspace = true
bon.workspace = true
bytes.workspace = true
-
jacquard-common = { version = "0.4", path = "../jacquard-common", features = ["reqwest-client"] }
-
jacquard-api = { version = "0.4", path = "../jacquard-api" }
percent-encoding.workspace = true
reqwest.workspace = true
url.workspace = true
···
[package]
name = "jacquard-identity"
edition.workspace = true
+
version = "0.4.1"
authors.workspace = true
repository.workspace = true
keywords.workspace = true
···
async-trait.workspace = true
bon.workspace = true
bytes.workspace = true
+
jacquard-common = { version = "0.5", path = "../jacquard-common", features = ["reqwest-client"] }
+
jacquard-api = { version = "0.4.1", path = "../jacquard-api" }
percent-encoding.workspace = true
reqwest.workspace = true
url.workspace = true
+3 -3
crates/jacquard-lexicon/Cargo.toml
···
glob = "0.3"
heck.workspace = true
itertools.workspace = true
-
jacquard-api = { version = "0.4", git = "https://tangled.org/@nonbinary.computer/jacquard" }
-
jacquard-common = { version = "0.4", git = "https://tangled.org/@nonbinary.computer/jacquard" }
-
jacquard-identity = { version = "0.4", git = "https://tangled.org/@nonbinary.computer/jacquard" }
kdl = "6"
miette = { workspace = true, features = ["fancy"] }
prettyplease.workspace = true
···
glob = "0.3"
heck.workspace = true
itertools.workspace = true
+
jacquard-api = { version = "0.4.1", git = "https://tangled.org/@nonbinary.computer/jacquard" }
+
jacquard-common = { version = "0.5", git = "https://tangled.org/@nonbinary.computer/jacquard" }
+
jacquard-identity = { version = "0.4.1", git = "https://tangled.org/@nonbinary.computer/jacquard" }
kdl = "6"
miette = { workspace = true, features = ["fancy"] }
prettyplease.workspace = true
+3 -3
crates/jacquard-oauth/Cargo.toml
···
[package]
name = "jacquard-oauth"
-
version = "0.4.0"
edition.workspace = true
description = "AT Protocol OAuth 2.1 core types and helpers for Jacquard"
authors.workspace = true
···
license.workspace = true
[dependencies]
-
jacquard-common = { version = "0.4", path = "../jacquard-common", features = ["reqwest-client"] }
-
jacquard-identity = { version = "0.4", path = "../jacquard-identity" }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
url = { workspace = true }
···
[package]
name = "jacquard-oauth"
+
version = "0.4.1"
edition.workspace = true
description = "AT Protocol OAuth 2.1 core types and helpers for Jacquard"
authors.workspace = true
···
license.workspace = true
[dependencies]
+
jacquard-common = { version = "0.5", path = "../jacquard-common", features = ["reqwest-client"] }
+
jacquard-identity = { version = "0.4.1", path = "../jacquard-identity" }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
url = { workspace = true }
+5 -5
crates/jacquard/Cargo.toml
···
required-features = ["fancy"]
[dependencies]
-
jacquard-api = { version = "0.4", path = "../jacquard-api" }
-
jacquard-common = { version = "0.4", path = "../jacquard-common", features = ["reqwest-client"] }
-
jacquard-oauth = { version = "0.4", path = "../jacquard-oauth" }
-
jacquard-derive = { version = "0.4", path = "../jacquard-derive", optional = true }
-
jacquard-identity = { version = "0.4", path = "../jacquard-identity" }
bon.workspace = true
async-trait.workspace = true
···
required-features = ["fancy"]
[dependencies]
+
jacquard-api = { version = "0.4.1", path = "../jacquard-api" }
+
jacquard-common = { version = "0.5", path = "../jacquard-common", features = ["reqwest-client"] }
+
jacquard-oauth = { version = "0.4.1", path = "../jacquard-oauth" }
+
jacquard-derive = { version = "0.5", path = "../jacquard-derive", optional = true }
+
jacquard-identity = { version = "0.4.1", path = "../jacquard-identity" }
bon.workspace = true
async-trait.workspace = true
+402 -279
crates/jacquard/src/client.rs
···
use jacquard_api::com_atproto::repo::create_record::CreateRecordOutput;
use jacquard_api::com_atproto::repo::delete_record::DeleteRecordOutput;
use jacquard_api::com_atproto::repo::put_record::PutRecordOutput;
use jacquard_api::com_atproto::server::create_session::CreateSessionOutput;
use jacquard_api::com_atproto::server::refresh_session::RefreshSessionOutput;
use jacquard_common::error::TransportError;
···
pub async fn refresh(&self) -> Result<AuthorizationToken<'static>, ClientError> {
self.inner.refresh().await
}
-
// Convenience methods for repository operations
-
/// Create a new record in the repository.
///
/// The collection is inferred from the record type's `Collection::NSID`.
···
/// # use jacquard_api::app_bsky::feed::post::Post;
/// # use jacquard_common::types::string::Datetime;
/// # use jacquard_common::CowStr;
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// # let agent: BasicClient = todo!();
···
/// # Ok(())
/// # }
/// ```
-
pub async fn create_record<R>(
&self,
record: R,
rkey: Option<RecordKey<Rkey<'_>>>,
-
) -> Result<CreateRecordOutput<'static>, AgentError>
where
R: Collection + serde::Serialize,
{
-
use jacquard_api::com_atproto::repo::create_record::CreateRecord;
-
use jacquard_common::types::ident::AtIdentifier;
-
use jacquard_common::types::value::to_data;
-
let (did, _) = self.info().await.ok_or(AgentError::NoSession)?;
-
let data = to_data(&record).map_err(|e| AgentError::SubOperation {
-
step: "serialize record",
-
error: Box::new(e),
-
})?;
-
let request = CreateRecord::new()
-
.repo(AtIdentifier::Did(did))
-
.collection(R::nsid())
-
.record(data)
-
.maybe_rkey(rkey)
-
.build();
-
let response = self.send(request).await?;
-
response.into_output().map_err(|e| match e {
-
XrpcError::Auth(auth) => AgentError::Auth(auth),
-
XrpcError::Generic(g) => AgentError::Generic(g),
-
XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)),
-
XrpcError::Xrpc(typed) => AgentError::SubOperation {
-
step: "create record",
-
error: Box::new(typed),
-
},
-
})
}
/// Delete a record from the repository.
///
/// The collection is inferred from the type parameter.
/// The repo is automatically filled from the session info.
-
pub async fn delete_record<R>(
&self,
rkey: RecordKey<Rkey<'_>>,
-
) -> Result<DeleteRecordOutput<'static>, AgentError>
where
R: Collection,
{
-
use jacquard_api::com_atproto::repo::delete_record::DeleteRecord;
-
use jacquard_common::types::ident::AtIdentifier;
-
let (did, _) = self.info().await.ok_or(AgentError::NoSession)?;
-
let request = DeleteRecord::new()
-
.repo(AtIdentifier::Did(did))
-
.collection(R::nsid())
-
.rkey(rkey)
-
.build();
-
let response = self.send(request).await?;
-
response.into_output().map_err(|e| match e {
-
XrpcError::Auth(auth) => AgentError::Auth(auth),
-
XrpcError::Generic(g) => AgentError::Generic(g),
-
XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)),
-
XrpcError::Xrpc(typed) => AgentError::SubOperation {
-
step: "delete record",
-
error: Box::new(typed),
-
},
-
})
}
/// Put (upsert) a record in the repository.
///
/// The collection is inferred from the record type's `Collection::NSID`.
/// The repo is automatically filled from the session info.
-
pub async fn put_record<R>(
&self,
rkey: RecordKey<Rkey<'static>>,
record: R,
-
) -> Result<PutRecordOutput<'static>, AgentError>
where
R: Collection + serde::Serialize,
{
-
use jacquard_api::com_atproto::repo::put_record::PutRecord;
-
use jacquard_common::types::ident::AtIdentifier;
-
use jacquard_common::types::value::to_data;
-
let (did, _) = self.info().await.ok_or(AgentError::NoSession)?;
-
let data = to_data(&record).map_err(|e| AgentError::SubOperation {
-
step: "serialize record",
-
error: Box::new(e),
-
})?;
-
let request = PutRecord::new()
-
.repo(AtIdentifier::Did(did))
-
.collection(R::nsid())
-
.rkey(rkey)
-
.record(data)
-
.build();
-
let response = self.send(request).await?;
-
response.into_output().map_err(|e| match e {
-
XrpcError::Auth(auth) => AgentError::Auth(auth),
-
XrpcError::Generic(g) => AgentError::Generic(g),
-
XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)),
-
XrpcError::Xrpc(typed) => AgentError::SubOperation {
-
step: "put record",
-
error: Box::new(typed),
-
},
-
})
}
/// Upload a blob to the repository.
···
/// ```no_run
/// # use jacquard::client::BasicClient;
/// # use jacquard_common::types::blob::MimeType;
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// # let agent: BasicClient = todo!();
···
/// # Ok(())
/// # }
/// ```
-
pub async fn upload_blob(
&self,
data: impl Into<bytes::Bytes>,
mime_type: MimeType<'_>,
-
) -> Result<Blob<'static>, AgentError> {
-
use http::header::CONTENT_TYPE;
-
use jacquard_api::com_atproto::repo::upload_blob::UploadBlob;
-
let bytes = data.into();
-
let request = UploadBlob::new().body(bytes).build();
-
// Override Content-Type header with actual mime type instead of */*
-
let base = self.base_uri();
-
let mut opts = self.opts().await;
-
opts.extra_headers.push((
-
CONTENT_TYPE,
-
http::HeaderValue::from_str(mime_type.as_str()).map_err(|e| {
-
AgentError::SubOperation {
-
step: "set Content-Type header",
-
error: Box::new(e),
-
}
-
})?,
-
));
-
let response = self.xrpc(base).with_options(opts).send(&request).await?;
-
let output = response.into_output().map_err(|e| match e {
-
XrpcError::Auth(auth) => AgentError::Auth(auth),
-
XrpcError::Generic(g) => AgentError::Generic(g),
-
XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)),
-
XrpcError::Xrpc(typed) => AgentError::SubOperation {
-
step: "upload blob",
-
error: Box::new(typed),
-
},
-
})?;
-
Ok(output.blob.into_static())
}
/// Update a vec-based data structure with a fetch-modify-put pattern.
···
/// prefs.retain(|p| !matches!(p, Preference::Hidden(_)));
/// }).await?;
/// ```
-
pub async fn update_vec<'s, U>(
&'s self,
modify: impl FnOnce(&mut Vec<<U as vec_update::VecUpdate>::Item>),
-
) -> Result<xrpc::Response<<U::PutRequest<'s> as XrpcRequest<'s>>::Response>, AgentError>
where
U: vec_update::VecUpdate + 's,
{
-
// Fetch current data
-
let get_request = U::build_get();
-
let response = self.send(get_request).await?;
-
let output = response.parse().map_err(|e| match e {
-
XrpcError::Auth(auth) => AgentError::Auth(auth),
-
XrpcError::Generic(g) => AgentError::Generic(g),
-
XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)),
-
XrpcError::Xrpc(_) => AgentError::SubOperation {
-
step: "get vec",
-
error: format!("{:?}", e).into(),
-
},
-
})?;
-
// Extract vec (converts to owned via IntoStatic)
-
let mut items = U::extract_vec(output);
-
// Apply modification
-
modify(&mut items);
-
// Build put request
-
let put_request = U::build_put(items);
-
// Send it
-
Ok(self.send(put_request).await?)
}
/// Update a single item in a vec-based data structure.
···
/// let pref = AdultContentPref::new().enabled(true).build();
/// agent.update_vec_item::<PreferencesUpdate>(pref.into()).await?;
/// ```
-
pub async fn update_vec_item<'s, U>(
&'s self,
item: <U as vec_update::VecUpdate>::Item,
-
) -> Result<xrpc::Response<<U::PutRequest<'s> as XrpcRequest<'s>>::Response>, AgentError>
where
U: vec_update::VecUpdate + 's,
{
-
self.update_vec::<U>(|vec| {
-
if let Some(pos) = vec.iter().position(|i| U::matches(i, &item)) {
-
vec[pos] = item;
-
} else {
-
vec.push(item);
-
}
-
})
-
.await
-
}
-
}
-
-
impl<A: AgentSession + IdentityResolver> Agent<A> {
-
/// 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.
-
///
-
/// # Example
-
///
-
/// ```no_run
-
/// # use jacquard::client::BasicClient;
-
/// # use jacquard_api::app_bsky::feed::post::Post;
-
/// # use jacquard_common::types::string::AtUri;
-
/// # use jacquard_common::IntoStatic;
-
/// # #[tokio::main]
-
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
-
/// # let agent: BasicClient = todo!();
-
/// let uri = AtUri::new_static("at://did:plc:xyz/app.bsky.feed.post/3l5bqm7lepk2c").unwrap();
-
/// let response = agent.get_record::<Post>(uri).await?;
-
/// let output = response.parse()?; // PostGetRecordOutput<'_> borrowing from buffer
-
/// println!("Post text: {}", output.value.text);
-
///
-
/// // Or get owned data
-
/// let output_owned = response.into_output()?;
-
/// # Ok(())
-
/// # }
-
/// ```
-
pub async fn get_record<R>(&self, uri: AtUri<'_>) -> Result<Response<R::Record>, ClientError>
-
where
-
R: Collection,
-
{
-
// Validate that URI's collection matches the expected type
-
if let Some(uri_collection) = uri.collection() {
-
if uri_collection.as_str() != R::nsid().as_str() {
-
return Err(ClientError::Transport(TransportError::Other(
-
format!(
-
"Collection mismatch: URI contains '{}' but type parameter expects '{}'",
-
uri_collection,
-
R::nsid()
-
)
-
.into(),
-
)));
-
}
}
-
-
let rkey = uri.rkey().ok_or_else(|| {
-
ClientError::Transport(TransportError::Other("AtUri missing rkey".into()))
-
})?;
-
-
// Resolve authority (DID or handle) to get DID and PDS
-
use jacquard_common::types::ident::AtIdentifier;
-
let (repo_did, pds_url) = match uri.authority() {
-
AtIdentifier::Did(did) => {
-
let pds = self.pds_for_did(did).await.map_err(|e| {
-
ClientError::Transport(TransportError::Other(
-
format!("Failed to resolve PDS for {}: {}", did, e).into(),
-
))
-
})?;
-
(did.clone(), pds)
-
}
-
AtIdentifier::Handle(handle) => self.pds_for_handle(handle).await.map_err(|e| {
-
ClientError::Transport(TransportError::Other(
-
format!("Failed to resolve handle {}: {}", handle, e).into(),
-
))
-
})?,
-
};
-
-
// Make stateless XRPC call to that PDS (no auth required for public records)
-
use jacquard_api::com_atproto::repo::get_record::GetRecord;
-
let request = GetRecord::new()
-
.repo(AtIdentifier::Did(repo_did))
-
.collection(R::nsid())
-
.rkey(rkey.clone())
-
.build();
-
-
let response = self.xrpc(pds_url).send(&request).await?;
-
Ok(response.transmute())
}
-
-
/// Update a record in-place with a fetch-modify-put pattern.
-
///
-
/// This fetches the record using an at:// URI, converts it to owned data, applies
-
/// the modification function, and puts it back. The modification function receives
-
/// a mutable reference to the record data.
-
///
-
/// # Example
-
///
-
/// ```no_run
-
/// # use jacquard::client::BasicClient;
-
/// # use jacquard_api::app_bsky::actor::profile::Profile;
-
/// # use jacquard_common::CowStr;
-
/// # use jacquard_common::types::string::AtUri;
-
/// # #[tokio::main]
-
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
-
/// # let agent: BasicClient = todo!();
-
/// let uri = AtUri::new_static("at://did:plc:xyz/app.bsky.actor.profile/self").unwrap();
-
/// // Update profile record in-place
-
/// agent.update_record::<Profile>(uri, |profile| {
-
/// profile.display_name = Some(CowStr::from("New Name"));
-
/// profile.description = Some(CowStr::from("Updated bio"));
-
/// }).await?;
-
/// # Ok(())
-
/// # }
-
/// ```
-
pub async fn update_record<R>(
-
&self,
-
uri: AtUri<'_>,
-
f: impl FnOnce(&mut R),
-
) -> Result<PutRecordOutput<'static>, AgentError>
-
where
-
R: Collection + Serialize,
-
R: for<'a> From<<<R as Collection>::Record as XrpcResp>::Output<'a>>,
-
{
-
// Fetch the record - Response<R::Record> where R::Record::Output<'de> = R<'de>
-
let response = self.get_record::<R>(uri.clone()).await?;
-
// Parse to get R<'_> borrowing from response buffer
-
let record = response.parse().map_err(|e| match e {
-
XrpcError::Auth(auth) => AgentError::Auth(auth),
-
XrpcError::Generic(g) => AgentError::Generic(g),
-
XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)),
-
XrpcError::Xrpc(typed) => AgentError::SubOperation {
-
step: "get record",
-
error: format!("{:?}", typed).into(),
-
},
-
})?;
-
-
// Convert to owned
-
let mut owned = R::from(record);
-
-
// Apply modification
-
f(&mut owned);
-
-
// Put it back
-
let rkey = uri
-
.rkey()
-
.ok_or(AgentError::SubOperation {
-
step: "extract rkey",
-
error: "AtUri missing rkey".into(),
-
})?
-
.clone()
-
.into_static();
-
self.put_record::<R>(rkey, owned).await
-
}
-
}
impl<A: AgentSession> HttpClient for Agent<A> {
type Error = <A as HttpClient>::Error;
···
}
}
impl<A: AgentSession> From<A> for Agent<A> {
fn from(inner: A) -> Self {
Self::new(inner)
···
/// # use jacquard::client::BasicClient;
/// # use jacquard::types::string::AtUri;
/// # use jacquard_api::app_bsky::feed::post::Post;
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = BasicClient::unauthenticated();
···
use jacquard_api::com_atproto::repo::create_record::CreateRecordOutput;
use jacquard_api::com_atproto::repo::delete_record::DeleteRecordOutput;
+
use jacquard_api::com_atproto::repo::get_record::GetRecordResponse;
use jacquard_api::com_atproto::repo::put_record::PutRecordOutput;
+
use jacquard_api::com_atproto::repo::upload_blob::UploadBlobResponse;
use jacquard_api::com_atproto::server::create_session::CreateSessionOutput;
use jacquard_api::com_atproto::server::refresh_session::RefreshSessionOutput;
use jacquard_common::error::TransportError;
···
pub async fn refresh(&self) -> Result<AuthorizationToken<'static>, ClientError> {
self.inner.refresh().await
}
+
}
+
/// Extension trait providing convenience methods for common repository operations.
+
///
+
/// This trait is automatically implemented for any type that implements both
+
/// [`AgentSession`] and [`IdentityResolver`]. It provides higher-level methods
+
/// that handle common patterns like fetch-modify-put, with automatic repo resolution
+
/// for at:// uris, and typed record operations.
+
///
+
/// # Available Operations
+
///
+
/// - **Basic CRUD**: [`create_record`](Self::create_record), [`get_record`](Self::get_record),
+
/// [`put_record`](Self::put_record), [`delete_record`](Self::delete_record)
+
/// - **Update patterns**: [`update_record`](Self::update_record) (fetch-modify-put for records),
+
/// [`update_vec`](Self::update_vec) and [`update_vec_item`](Self::update_vec_item) (for array endpoints)
+
/// - **Blob operations**: [`upload_blob`](Self::upload_blob)
+
///
+
/// # Example
+
///
+
/// ```no_run
+
/// # use jacquard::client::BasicClient;
+
/// # use jacquard_api::app_bsky::feed::post::Post;
+
/// # use jacquard_common::types::string::{AtUri, Datetime};
+
/// # use jacquard_common::CowStr;
+
/// use jacquard::client::AgentSessionExt;
+
/// # #[tokio::main]
+
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
+
/// # let agent: BasicClient = todo!();
+
/// // Create a post
+
/// let post = Post {
+
/// text: CowStr::from("Hello from Jacquard!"),
+
/// created_at: Datetime::now(),
+
/// # embed: None, entities: None, facets: None, labels: None,
+
/// # langs: None, reply: None, tags: None, extra_data: Default::default(),
+
/// };
+
/// let output = agent.create_record(post, None).await?;
+
///
+
/// // Read it back
+
/// let response = agent.get_record::<Post>(output.uri).await?;
+
/// let record = response.parse()?;
+
/// println!("Post: {}", record.value.text);
+
/// # Ok(())
+
/// # }
+
/// ```
+
pub trait AgentSessionExt: AgentSession + IdentityResolver {
/// Create a new record in the repository.
///
/// The collection is inferred from the record type's `Collection::NSID`.
···
/// # use jacquard_api::app_bsky::feed::post::Post;
/// # use jacquard_common::types::string::Datetime;
/// # use jacquard_common::CowStr;
+
/// use jacquard::client::AgentSessionExt;
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// # let agent: BasicClient = todo!();
···
/// # Ok(())
/// # }
/// ```
+
fn create_record<R>(
&self,
record: R,
rkey: Option<RecordKey<Rkey<'_>>>,
+
) -> impl std::future::Future<Output = Result<CreateRecordOutput<'static>, AgentError>>
where
R: Collection + serde::Serialize,
{
+
async move {
+
use jacquard_api::com_atproto::repo::create_record::CreateRecord;
+
use jacquard_common::types::ident::AtIdentifier;
+
use jacquard_common::types::value::to_data;
+
+
let (did, _) = self.session_info().await.ok_or(AgentError::NoSession)?;
+
+
let data = to_data(&record).map_err(|e| AgentError::SubOperation {
+
step: "serialize record",
+
error: Box::new(e),
+
})?;
+
+
let request = CreateRecord::new()
+
.repo(AtIdentifier::Did(did))
+
.collection(R::nsid())
+
.record(data)
+
.maybe_rkey(rkey)
+
.build();
+
+
let response = self.send(request).await?;
+
response.into_output().map_err(|e| match e {
+
XrpcError::Auth(auth) => AgentError::Auth(auth),
+
XrpcError::Generic(g) => AgentError::Generic(g),
+
XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)),
+
XrpcError::Xrpc(typed) => AgentError::SubOperation {
+
step: "create record",
+
error: Box::new(typed),
+
},
+
})
+
}
+
}
+
+
/// 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.
+
///
+
/// # Example
+
///
+
/// ```no_run
+
/// # use jacquard::client::BasicClient;
+
/// # use jacquard_api::app_bsky::feed::post::Post;
+
/// # use jacquard_common::types::string::AtUri;
+
/// # use jacquard_common::IntoStatic;
+
/// use jacquard::client::AgentSessionExt;
+
/// # #[tokio::main]
+
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
+
/// # let agent: BasicClient = todo!();
+
/// let uri = AtUri::new_static("at://did:plc:xyz/app.bsky.feed.post/3l5bqm7lepk2c").unwrap();
+
/// let response = agent.get_record::<Post>(uri).await?;
+
/// let output = response.parse()?; // PostGetRecordOutput<'_> borrowing from buffer
+
/// println!("Post text: {}", output.value.text);
+
///
+
/// // Or get owned data
+
/// let output_owned = response.into_output()?;
+
/// # Ok(())
+
/// # }
+
/// ```
+
fn get_record<R>(
+
&self,
+
uri: AtUri<'_>,
+
) -> impl std::future::Future<Output = Result<Response<R::Record>, ClientError>>
+
where
+
R: Collection,
+
{
+
async move {
+
// Validate that URI's collection matches the expected type
+
if let Some(uri_collection) = uri.collection() {
+
if uri_collection.as_str() != R::nsid().as_str() {
+
return Err(ClientError::Transport(TransportError::Other(
+
format!(
+
"Collection mismatch: URI contains '{}' but type parameter expects '{}'",
+
uri_collection,
+
R::nsid()
+
)
+
.into(),
+
)));
+
}
+
}
+
+
let rkey = uri.rkey().ok_or_else(|| {
+
ClientError::Transport(TransportError::Other("AtUri missing rkey".into()))
+
})?;
+
+
// Resolve authority (DID or handle) to get DID and PDS
+
use jacquard_common::types::ident::AtIdentifier;
+
let (repo_did, pds_url) = match uri.authority() {
+
AtIdentifier::Did(did) => {
+
let pds = self.pds_for_did(did).await.map_err(|e| {
+
ClientError::Transport(TransportError::Other(
+
format!("Failed to resolve PDS for {}: {}", did, e).into(),
+
))
+
})?;
+
(did.clone(), pds)
+
}
+
AtIdentifier::Handle(handle) => self.pds_for_handle(handle).await.map_err(|e| {
+
ClientError::Transport(TransportError::Other(
+
format!("Failed to resolve handle {}: {}", handle, e).into(),
+
))
+
})?,
+
};
+
+
// Make stateless XRPC call to that PDS (no auth required for public records)
+
use jacquard_api::com_atproto::repo::get_record::GetRecord;
+
let request = GetRecord::new()
+
.repo(AtIdentifier::Did(repo_did))
+
.collection(R::nsid())
+
.rkey(rkey.clone())
+
.build();
+
+
let response: Response<GetRecordResponse> = {
+
let http_request = xrpc::build_http_request(&pds_url, &request, &self.opts().await)
+
.map_err(|e| ClientError::Transport(TransportError::from(e)))?;
+
+
let http_response = self
+
.send_http(http_request)
+
.await
+
.map_err(|e| ClientError::Transport(TransportError::Other(Box::new(e))))?;
+
+
xrpc::process_response(http_response)
+
}?;
+
Ok(response.transmute())
+
}
+
}
+
+
/// Update a record in-place with a fetch-modify-put pattern.
+
///
+
/// This fetches the record using an at:// URI, converts it to owned data, applies
+
/// the modification function, and puts it back. The modification function receives
+
/// a mutable reference to the record data.
+
///
+
/// # Example
+
///
+
/// ```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<dyn std::error::Error>> {
+
/// # let agent: BasicClient = todo!();
+
/// let uri = AtUri::new_static("at://did:plc:xyz/app.bsky.actor.profile/self").unwrap();
+
/// // Update profile record in-place
+
/// agent.update_record::<Profile>(uri, |profile| {
+
/// profile.display_name = Some(CowStr::from("New Name"));
+
/// profile.description = Some(CowStr::from("Updated bio"));
+
/// }).await?;
+
/// # Ok(())
+
/// # }
+
/// ```
+
fn update_record<R>(
+
&self,
+
uri: AtUri<'_>,
+
f: impl FnOnce(&mut R),
+
) -> impl std::future::Future<Output = Result<PutRecordOutput<'static>, AgentError>>
+
where
+
R: Collection + Serialize,
+
R: for<'a> From<<<R as Collection>::Record as XrpcResp>::Output<'a>>,
+
{
+
async move {
+
// Fetch the record - Response<R::Record> where R::Record::Output<'de> = R<'de>
+
let response = self.get_record::<R>(uri.clone()).await?;
+
// Parse to get R<'_> borrowing from response buffer
+
let record = response.parse().map_err(|e| match e {
+
XrpcError::Auth(auth) => AgentError::Auth(auth),
+
XrpcError::Generic(g) => AgentError::Generic(g),
+
XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)),
+
XrpcError::Xrpc(typed) => AgentError::SubOperation {
+
step: "get record",
+
error: format!("{:?}", typed).into(),
+
},
+
})?;
+
// Convert to owned
+
let mut owned = R::from(record);
+
// Apply modification
+
f(&mut owned);
+
// Put it back
+
let rkey = uri
+
.rkey()
+
.ok_or(AgentError::SubOperation {
+
step: "extract rkey",
+
error: "AtUri missing rkey".into(),
+
})?
+
.clone()
+
.into_static();
+
self.put_record::<R>(rkey, owned).await
+
}
}
/// Delete a record from the repository.
///
/// The collection is inferred from the type parameter.
/// The repo is automatically filled from the session info.
+
fn delete_record<R>(
&self,
rkey: RecordKey<Rkey<'_>>,
+
) -> impl std::future::Future<Output = Result<DeleteRecordOutput<'static>, AgentError>>
where
R: Collection,
{
+
async {
+
use jacquard_api::com_atproto::repo::delete_record::DeleteRecord;
+
use jacquard_common::types::ident::AtIdentifier;
+
let (did, _) = self.session_info().await.ok_or(AgentError::NoSession)?;
+
let request = DeleteRecord::new()
+
.repo(AtIdentifier::Did(did))
+
.collection(R::nsid())
+
.rkey(rkey)
+
.build();
+
let response = self.send(request).await?;
+
response.into_output().map_err(|e| match e {
+
XrpcError::Auth(auth) => AgentError::Auth(auth),
+
XrpcError::Generic(g) => AgentError::Generic(g),
+
XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)),
+
XrpcError::Xrpc(typed) => AgentError::SubOperation {
+
step: "delete record",
+
error: Box::new(typed),
+
},
+
})
+
}
}
/// Put (upsert) a record in the repository.
///
/// The collection is inferred from the record type's `Collection::NSID`.
/// The repo is automatically filled from the session info.
+
fn put_record<R>(
&self,
rkey: RecordKey<Rkey<'static>>,
record: R,
+
) -> impl std::future::Future<Output = Result<PutRecordOutput<'static>, AgentError>>
where
R: Collection + serde::Serialize,
{
+
async move {
+
use jacquard_api::com_atproto::repo::put_record::PutRecord;
+
use jacquard_common::types::ident::AtIdentifier;
+
use jacquard_common::types::value::to_data;
+
let (did, _) = self.session_info().await.ok_or(AgentError::NoSession)?;
+
let data = to_data(&record).map_err(|e| AgentError::SubOperation {
+
step: "serialize record",
+
error: Box::new(e),
+
})?;
+
let request = PutRecord::new()
+
.repo(AtIdentifier::Did(did))
+
.collection(R::nsid())
+
.rkey(rkey)
+
.record(data)
+
.build();
+
let response = self.send(request).await?;
+
response.into_output().map_err(|e| match e {
+
XrpcError::Auth(auth) => AgentError::Auth(auth),
+
XrpcError::Generic(g) => AgentError::Generic(g),
+
XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)),
+
XrpcError::Xrpc(typed) => AgentError::SubOperation {
+
step: "put record",
+
error: Box::new(typed),
+
},
+
})
+
}
}
/// Upload a blob to the repository.
···
/// ```no_run
/// # use jacquard::client::BasicClient;
/// # use jacquard_common::types::blob::MimeType;
+
/// use jacquard::client::AgentSessionExt;
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// # let agent: BasicClient = todo!();
···
/// # Ok(())
/// # }
/// ```
+
fn upload_blob(
&self,
data: impl Into<bytes::Bytes>,
mime_type: MimeType<'_>,
+
) -> impl std::future::Future<Output = Result<Blob<'static>, AgentError>> {
+
async move {
+
use http::header::CONTENT_TYPE;
+
use jacquard_api::com_atproto::repo::upload_blob::UploadBlob;
+
+
let bytes = data.into();
+
let request = UploadBlob::new().body(bytes).build();
+
+
// Override Content-Type header with actual mime type instead of */*
+
let base = self.base_uri();
+
let mut opts = self.opts().await;
+
opts.extra_headers.push((
+
CONTENT_TYPE,
+
http::HeaderValue::from_str(mime_type.as_str()).map_err(|e| {
+
AgentError::SubOperation {
+
step: "set Content-Type header",
+
error: Box::new(e),
+
}
+
})?,
+
));
+
let response: Response<UploadBlobResponse> = {
+
let http_request =
+
xrpc::build_http_request(&base, &request, &opts).map_err(|e| {
+
AgentError::Client(ClientError::Transport(TransportError::from(e)))
+
})?;
+
let http_response = self.send_http(http_request).await.map_err(|e| {
+
AgentError::Client(ClientError::Transport(TransportError::Other(Box::new(e))))
+
})?;
+
xrpc::process_response(http_response)
+
}?;
+
let output = response.into_output().map_err(|e| match e {
+
XrpcError::Auth(auth) => AgentError::Auth(auth),
+
XrpcError::Generic(g) => AgentError::Generic(g),
+
XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)),
+
XrpcError::Xrpc(typed) => AgentError::SubOperation {
+
step: "upload blob",
+
error: Box::new(typed),
+
},
+
})?;
+
Ok(output.blob.into_static())
+
}
}
/// Update a vec-based data structure with a fetch-modify-put pattern.
···
/// prefs.retain(|p| !matches!(p, Preference::Hidden(_)));
/// }).await?;
/// ```
+
fn update_vec<'s, U>(
&'s self,
modify: impl FnOnce(&mut Vec<<U as vec_update::VecUpdate>::Item>),
+
) -> impl std::future::Future<
+
Output = Result<
+
xrpc::Response<<U::PutRequest<'s> as XrpcRequest<'s>>::Response>,
+
AgentError,
+
>,
+
>
where
U: vec_update::VecUpdate + 's,
{
+
async {
+
// Fetch current data
+
let get_request = U::build_get();
+
let response = self.send(get_request).await?;
+
let output = response.parse().map_err(|e| match e {
+
XrpcError::Auth(auth) => AgentError::Auth(auth),
+
XrpcError::Generic(g) => AgentError::Generic(g),
+
XrpcError::Decode(e) => AgentError::Decode(DecodeError::Json(e)),
+
XrpcError::Xrpc(_) => AgentError::SubOperation {
+
step: "get vec",
+
error: format!("{:?}", e).into(),
+
},
+
})?;
+
// Extract vec (converts to owned via IntoStatic)
+
let mut items = U::extract_vec(output);
+
// Apply modification
+
modify(&mut items);
+
// Build put request
+
let put_request = U::build_put(items);
+
// Send it
+
Ok(self.send(put_request).await?)
+
}
}
/// Update a single item in a vec-based data structure.
···
/// let pref = AdultContentPref::new().enabled(true).build();
/// agent.update_vec_item::<PreferencesUpdate>(pref.into()).await?;
/// ```
+
fn update_vec_item<'s, U>(
&'s self,
item: <U as vec_update::VecUpdate>::Item,
+
) -> impl std::future::Future<
+
Output = Result<
+
xrpc::Response<<U::PutRequest<'s> as XrpcRequest<'s>>::Response>,
+
AgentError,
+
>,
+
>
where
U: vec_update::VecUpdate + 's,
{
+
async {
+
self.update_vec::<U>(|vec| {
+
if let Some(pos) = vec.iter().position(|i| U::matches(i, &item)) {
+
vec[pos] = item;
+
} else {
+
vec.push(item);
+
}
+
})
+
.await
}
}
+
}
+
impl<T: AgentSession + IdentityResolver> AgentSessionExt for T {}
impl<A: AgentSession> HttpClient for Agent<A> {
type Error = <A as HttpClient>::Error;
···
}
}
+
impl<A: AgentSession> AgentSession for Agent<A> {
+
fn session_kind(&self) -> AgentKind {
+
self.kind()
+
}
+
+
fn session_info(
+
&self,
+
) -> impl Future<Output = Option<(Did<'static>, Option<CowStr<'static>>)>> {
+
async { self.info().await }
+
}
+
+
fn endpoint(&self) -> impl Future<Output = url::Url> {
+
async { self.endpoint().await }
+
}
+
+
fn set_options<'a>(&'a self, opts: CallOptions<'a>) -> impl Future<Output = ()> {
+
async { self.set_options(opts).await }
+
}
+
+
fn refresh(&self) -> impl Future<Output = Result<AuthorizationToken<'static>, ClientError>> {
+
async { self.refresh().await }
+
}
+
}
+
impl<A: AgentSession> From<A> for Agent<A> {
fn from(inner: A) -> Self {
Self::new(inner)
···
/// # use jacquard::client::BasicClient;
/// # use jacquard::types::string::AtUri;
/// # use jacquard_api::app_bsky::feed::post::Post;
+
/// use crate::jacquard::client::AgentSessionExt;
/// # #[tokio::main]
/// # async fn main() -> Result<(), Box<dyn std::error::Error>> {
/// let client = BasicClient::unauthenticated();
+1
crates/jacquard/src/client/credential_session.rs
···
)
}
}
async fn send<'s, R>(
&self,
request: R,
···
)
}
}
+
async fn send<'s, R>(
&self,
request: R,
+3 -38
examples/update_preferences.rs
···
use clap::Parser;
use jacquard::api::app_bsky::actor::{AdultContentPref, PreferencesItem};
-
use jacquard::client::vec_update::VecUpdate;
use jacquard::client::{Agent, FileAuthStore};
-
use jacquard::oauth::atproto::AtprotoClientMetadata;
use jacquard::oauth::client::OAuthClient;
use jacquard::oauth::loopback::LoopbackConfig;
-
use jacquard::{CowStr, IntoStatic};
#[derive(Parser, Debug)]
#[command(author, version, about = "Update Bluesky preferences")]
···
/// Path to auth store file (will be created if missing)
#[arg(long, default_value = "/tmp/jacquard-oauth-session.json")]
store: String,
-
}
-
-
/// Helper struct for the VecUpdate pattern on preferences
-
pub struct PreferencesUpdate;
-
-
impl VecUpdate for PreferencesUpdate {
-
type GetRequest<'de> = jacquard::api::app_bsky::actor::get_preferences::GetPreferences;
-
type PutRequest<'de> = jacquard::api::app_bsky::actor::put_preferences::PutPreferences<'de>;
-
type Item = PreferencesItem<'static>;
-
-
fn build_get<'s>() -> Self::GetRequest<'s> {
-
jacquard::api::app_bsky::actor::get_preferences::GetPreferences::new().build()
-
}
-
-
fn build_put<'s>(items: Vec<Self::Item>) -> Self::PutRequest<'s> {
-
jacquard::api::app_bsky::actor::put_preferences::PutPreferences {
-
preferences: items,
-
extra_data: Default::default(),
-
}
-
}
-
-
fn extract_vec(
-
output: jacquard::api::app_bsky::actor::get_preferences::GetPreferencesOutput<'_>,
-
) -> Vec<Self::Item> {
-
output
-
.preferences
-
.into_iter()
-
.map(|p| p.into_static())
-
.collect()
-
}
-
-
fn matches(a: &Self::Item, b: &Self::Item) -> bool {
-
// Match by enum variant discriminant
-
std::mem::discriminant(a) == std::mem::discriminant(b)
-
}
}
#[tokio::main]
···
use clap::Parser;
+
use jacquard::CowStr;
use jacquard::api::app_bsky::actor::{AdultContentPref, PreferencesItem};
+
use jacquard::client::AgentSessionExt;
+
use jacquard::client::vec_update::PreferencesUpdate;
use jacquard::client::{Agent, FileAuthStore};
use jacquard::oauth::client::OAuthClient;
use jacquard::oauth::loopback::LoopbackConfig;
#[derive(Parser, Debug)]
#[command(author, version, about = "Update Bluesky preferences")]
···
/// Path to auth store file (will be created if missing)
#[arg(long, default_value = "/tmp/jacquard-oauth-session.json")]
store: String,
}
#[tokio::main]