A better Rust ATProto crate

ready to cut 0.4.0 release

Orual 0295a9cc a679f602

Changed files
+194 -9
crates
jacquard-api
jacquard-common
src
jacquard-identity
jacquard-lexicon
jacquard-oauth
+85
CHANGELOG.md
···
···
+
# Changelog
+
+
## [0.4.0] - 2025-10-11
+
+
### Breaking Changes
+
+
**Zero-copy deserialization** (`jacquard-common`, `jacquard-api`)
+
- `XrpcRequest` now takes a `'de` lifetime parameter and requires `Deserialize<'de>`
+
- For raw data, `Response::parse_data()` gives validated loosely-typed atproto data, while `Response::parse_raw()` gives the raw values, with minimal validation.
+
+
**XRPC module moved** (`jacquard-common`)
+
- `xrpc.rs` is now top-level instead of under `types`
+
- Import from `jacquard_common::xrpc::*` not `jacquard_common::types::xrpc::*`
+
+
**Response API changes** (`jacquard-common`)
+
- `XrpcRequest::Output` and `XrpcRequest::Err` are associated types with lifetimes
+
- Split response and request traits: `XrpcRequest<'de>` for client, `XrpcEndpoint` for server
+
- Added `XrpcResp` marker trait
+
+
**Various traits** (`jacquard`, `jacquard-common`, `jacquard-lexicon`, `jacquard-oauth`)
+
- Removed #[async_trait] attribute macro usage in favour of `impl Future` return types with manual bounds.
+
- Boxing imposed by asyc_trait negatively affected borrowing modes in async methods.
+
- Currently no semver guarantees on API trait bounds, if they need to tighten, they will.
+
+
### Added
+
+
**New crate: `jacquard-axum`**
+
- Server-side XRPC handlers for Axum
+
- `ExtractXrpc<R>` deserializes incoming requests (query params for Query, body for Procedure)
+
- Automatic error responses
+
+
**Lexicon codegen fixes** (`jacquard-lexicon`)
+
- Union variant collision detection: when multiple namespaces have similar type names, foreign ones get prefixed (e.g., `Images` vs `BskyImages`)
+
- Token types generate unit structs with `Display` instead of being skipped
+
- Namespace dependency tracking during union generation
+
- `generate_cargo_features()` outputs Cargo.toml features with correct deps
+
- `sanitize_name()` ensures valid Rust identifiers
+
+
**Lexicons** (`jacquard-api`)
+
+
Added 646 lexicon schemas. Highlights:
+
+
Core ATProto:
+
- `com.atproto.*`
+
- `com.bad-example.*` for identity resolution
+
+
Bluesky:
+
- `app.bsky.*` bluesky app
+
- `chat.bsky.*` chat client
+
- `tools.ozone.*` moderation
+
+
Third-party:
+
- `sh.tangled.*` - git forge
+
- `sh.weaver.*` - orual's WIP markdown blog platform
+
- `pub.leaflet.*` - longform publishing
+
- `net.anisota.*` - gamified and calming take on bluesky
+
- `network.slices.*` - serverless atproto hosting
+
- `tools.smokesignal.*` - automation
+
- `com.whtwnd.*` - markdown blogging
+
- `place.stream.*` - livestreaming
+
- `blue.2048.*` - 2048 game
+
- `community.lexicon.*` - community extensions (bookmarks, calendar, location, payments)
+
- `my.skylights.*` - media tracking
+
- `social.psky.*` - social extensions
+
- `blue.linkat.*` - link boards
+
+
Plus 30+ more experimental/community namespaces.
+
+
**Value types** (`jacquard-common`)
+
- `RawData` to `Data` conversion with type inference
+
- `from_data`, `from_raw_data`, `to_data`, and `to_raw_data` to serialize to and deserialize from the loosely typed value data formats. Particularly useful for second-stage deserialization of type "unknown" fields in lexicons, such as `PostView.record`.
+
+
### Changed
+
+
- `generate_union()` takes current NSID for dependency tracking
+
- Generated code uses `sanitize_name()` for identifiers more consistently
+
- Added derive macro for IntoStatic trait implementation
+
+
### Fixed
+
+
- Methods to extract the output from an XRPC response now behave well with respect to lifetimes and borrowing.
+
- Now possible to use jacquard types in places like axum extractors due to lifetime improvements
+
- Union variants don't collide when multiple namespaces define similar types and another namespace includes them
+
+
---
+2 -2
Cargo.lock
···
[[package]]
name = "jacquard-identity"
-
version = "0.3.1"
dependencies = [
"async-trait",
"bon",
···
[[package]]
name = "jacquard-oauth"
-
version = "0.3.1"
dependencies = [
"async-trait",
"base64 0.22.1",
···
[[package]]
name = "jacquard-identity"
+
version = "0.4.0"
dependencies = [
"async-trait",
"bon",
···
[[package]]
name = "jacquard-oauth"
+
version = "0.4.0"
dependencies = [
"async-trait",
"base64 0.22.1",
-1
crates/jacquard-api/Cargo.toml
···
# --- generated ---
# Generated namespace features
-
# Each namespace feature automatically enables its dependencies
app_blebbit = []
app_bsky = ["com_atproto"]
app_ocho = []
···
# --- generated ---
# Generated namespace features
app_blebbit = []
app_bsky = ["com_atproto"]
app_ocho = []
+101 -1
crates/jacquard-common/src/xrpc.rs
···
use std::{error::Error, marker::PhantomData};
use url::Url;
-
use crate::error::TransportError;
use crate::http_client::HttpClient;
use crate::types::value::Data;
use crate::{AuthorizationToken, error::AuthError};
use crate::{CowStr, error::XrpcResult};
use crate::{IntoStatic, error::DecodeError};
/// Error type for encoding XRPC requests
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
···
pub fn parse<'s>(
&'s self,
) -> Result<<Resp as XrpcResp>::Output<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> {
// 200: parse as output
if self.status.is_success() {
match serde_json::from_slice::<_>(&self.buffer) {
···
use std::{error::Error, marker::PhantomData};
use url::Url;
use crate::http_client::HttpClient;
use crate::types::value::Data;
use crate::{AuthorizationToken, error::AuthError};
use crate::{CowStr, error::XrpcResult};
use crate::{IntoStatic, error::DecodeError};
+
use crate::{error::TransportError, types::value::RawData};
/// Error type for encoding XRPC requests
#[derive(Debug, thiserror::Error, miette::Diagnostic)]
···
pub fn parse<'s>(
&'s self,
) -> Result<<Resp as XrpcResp>::Output<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> {
+
// 200: parse as output
+
if self.status.is_success() {
+
match serde_json::from_slice::<_>(&self.buffer) {
+
Ok(output) => Ok(output),
+
Err(e) => Err(XrpcError::Decode(e)),
+
}
+
// 400: try typed XRPC error, fallback to generic error
+
} else if self.status.as_u16() == 400 {
+
match serde_json::from_slice::<_>(&self.buffer) {
+
Ok(error) => Err(XrpcError::Xrpc(error)),
+
Err(_) => {
+
// Fallback to generic error (InvalidRequest, ExpiredToken, etc.)
+
match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
+
Ok(mut generic) => {
+
generic.nsid = Resp::NSID;
+
generic.method = ""; // method info only available on request
+
generic.http_status = self.status;
+
// Map auth-related errors to AuthError
+
match generic.error.as_str() {
+
"ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
+
"InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
+
_ => Err(XrpcError::Generic(generic)),
+
}
+
}
+
Err(e) => Err(XrpcError::Decode(e)),
+
}
+
}
+
}
+
// 401: always auth error
+
} else {
+
match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
+
Ok(mut generic) => {
+
generic.nsid = Resp::NSID;
+
generic.method = ""; // method info only available on request
+
generic.http_status = self.status;
+
match generic.error.as_str() {
+
"ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
+
"InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
+
_ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
+
}
+
}
+
Err(e) => Err(XrpcError::Decode(e)),
+
}
+
}
+
}
+
+
/// Parse this as validated, loosely typed atproto data.
+
///
+
/// NOTE: If the response is an error, it will still parse as the matching error type for the request.
+
pub fn parse_data<'s>(&'s self) -> Result<Data<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> {
+
// 200: parse as output
+
if self.status.is_success() {
+
match serde_json::from_slice::<_>(&self.buffer) {
+
Ok(output) => Ok(output),
+
Err(e) => Err(XrpcError::Decode(e)),
+
}
+
// 400: try typed XRPC error, fallback to generic error
+
} else if self.status.as_u16() == 400 {
+
match serde_json::from_slice::<_>(&self.buffer) {
+
Ok(error) => Err(XrpcError::Xrpc(error)),
+
Err(_) => {
+
// Fallback to generic error (InvalidRequest, ExpiredToken, etc.)
+
match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
+
Ok(mut generic) => {
+
generic.nsid = Resp::NSID;
+
generic.method = ""; // method info only available on request
+
generic.http_status = self.status;
+
// Map auth-related errors to AuthError
+
match generic.error.as_str() {
+
"ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
+
"InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
+
_ => Err(XrpcError::Generic(generic)),
+
}
+
}
+
Err(e) => Err(XrpcError::Decode(e)),
+
}
+
}
+
}
+
// 401: always auth error
+
} else {
+
match serde_json::from_slice::<GenericXrpcError>(&self.buffer) {
+
Ok(mut generic) => {
+
generic.nsid = Resp::NSID;
+
generic.method = ""; // method info only available on request
+
generic.http_status = self.status;
+
match generic.error.as_str() {
+
"ExpiredToken" => Err(XrpcError::Auth(AuthError::TokenExpired)),
+
"InvalidToken" => Err(XrpcError::Auth(AuthError::InvalidToken)),
+
_ => Err(XrpcError::Auth(AuthError::NotAuthenticated)),
+
}
+
}
+
Err(e) => Err(XrpcError::Decode(e)),
+
}
+
}
+
}
+
+
/// Parse this as raw atproto data with minimal validation.
+
///
+
/// NOTE: If the response is an error, it will still parse as the matching error type for the request.
+
pub fn parse_raw<'s>(&'s self) -> Result<RawData<'s>, XrpcError<<Resp as XrpcResp>::Err<'s>>> {
// 200: parse as output
if self.status.is_success() {
match serde_json::from_slice::<_>(&self.buffer) {
+1 -1
crates/jacquard-identity/Cargo.toml
···
[package]
name = "jacquard-identity"
edition.workspace = true
-
version = "0.3.1"
authors.workspace = true
repository.workspace = true
keywords.workspace = true
···
[package]
name = "jacquard-identity"
edition.workspace = true
+
version = "0.4.0"
authors.workspace = true
repository.workspace = true
keywords.workspace = true
+4 -3
crates/jacquard-lexicon/src/codegen.rs
···
if let Some(lib_rs) = lib_rs_path {
if let Ok(content) = std::fs::read_to_string(lib_rs) {
for line in content.lines() {
-
if let Some(feature) = line.trim()
.strip_prefix("#[cfg(feature = \"")
.and_then(|s| s.strip_suffix("\")]"))
{
···
let mut output = String::new();
writeln!(&mut output, "# Generated namespace features").unwrap();
-
writeln!(&mut output, "# Each namespace feature automatically enables its dependencies").unwrap();
// Convert namespace to feature name (matching module path sanitization)
let to_feature_name = |ns: &str| {
···
feature_names.sort();
// Map namespace to feature name for dependency lookup
-
let mut ns_to_feature: std::collections::HashMap<&str, String> = std::collections::HashMap::new();
for ns in &all_namespaces {
ns_to_feature.insert(ns.as_str(), to_feature_name(ns));
}
···
if let Some(lib_rs) = lib_rs_path {
if let Ok(content) = std::fs::read_to_string(lib_rs) {
for line in content.lines() {
+
if let Some(feature) = line
+
.trim()
.strip_prefix("#[cfg(feature = \"")
.and_then(|s| s.strip_suffix("\")]"))
{
···
let mut output = String::new();
writeln!(&mut output, "# Generated namespace features").unwrap();
// Convert namespace to feature name (matching module path sanitization)
let to_feature_name = |ns: &str| {
···
feature_names.sort();
// Map namespace to feature name for dependency lookup
+
let mut ns_to_feature: std::collections::HashMap<&str, String> =
+
std::collections::HashMap::new();
for ns in &all_namespaces {
ns_to_feature.insert(ns.as_str(), to_feature_name(ns));
}
+1 -1
crates/jacquard-oauth/Cargo.toml
···
[package]
name = "jacquard-oauth"
-
version = "0.3.1"
edition.workspace = true
description = "AT Protocol OAuth 2.1 core types and helpers for Jacquard"
authors.workspace = true
···
[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