A better Rust ATProto crate

reworked workspace deps, descriptions, added example to readme

Orual 590fc90f 30ecb131

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