From 0295a9cc7623d840cd8908a4a3bf95025621cd08 Mon Sep 17 00:00:00 2001 From: Orual Date: Sat, 11 Oct 2025 17:46:11 -0400 Subject: [PATCH] ready to cut 0.4.0 release --- CHANGELOG.md | 85 +++++++++++++++++++++ Cargo.lock | 4 +- crates/jacquard-api/Cargo.toml | 1 - crates/jacquard-common/src/xrpc.rs | 102 ++++++++++++++++++++++++- crates/jacquard-identity/Cargo.toml | 2 +- crates/jacquard-lexicon/src/codegen.rs | 7 +- crates/jacquard-oauth/Cargo.toml | 2 +- 7 files changed, 194 insertions(+), 9 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..563973929 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,85 @@ +# 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` 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 + +--- diff --git a/Cargo.lock b/Cargo.lock index bd6f0d82b..69a57b3a8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1874,7 +1874,7 @@ dependencies = [ [[package]] name = "jacquard-identity" -version = "0.3.1" +version = "0.4.0" dependencies = [ "async-trait", "bon", @@ -1926,7 +1926,7 @@ dependencies = [ [[package]] name = "jacquard-oauth" -version = "0.3.1" +version = "0.4.0" dependencies = [ "async-trait", "base64 0.22.1", diff --git a/crates/jacquard-api/Cargo.toml b/crates/jacquard-api/Cargo.toml index 4af3d5af1..ab2bfa29e 100644 --- a/crates/jacquard-api/Cargo.toml +++ b/crates/jacquard-api/Cargo.toml @@ -34,7 +34,6 @@ ufos = [ "moe_karashiiro", "dev_regnault", "buzz_bookhive", "uk_ewancroft", "wi # --- generated --- # Generated namespace features -# Each namespace feature automatically enables its dependencies app_blebbit = [] app_bsky = ["com_atproto"] app_ocho = [] diff --git a/crates/jacquard-common/src/xrpc.rs b/crates/jacquard-common/src/xrpc.rs index da991f7fb..4c58ee390 100644 --- a/crates/jacquard-common/src/xrpc.rs +++ b/crates/jacquard-common/src/xrpc.rs @@ -21,12 +21,12 @@ use std::fmt::{self, Debug}; 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}; +use crate::{error::TransportError, types::value::RawData}; /// Error type for encoding XRPC requests #[derive(Debug, thiserror::Error, miette::Diagnostic)] @@ -586,6 +586,106 @@ where } } } + + /// 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, XrpcError<::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::(&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::(&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, XrpcError<::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::(&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::(&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)), + } + } + } } impl Response diff --git a/crates/jacquard-identity/Cargo.toml b/crates/jacquard-identity/Cargo.toml index 3e2a4e9cd..322360101 100644 --- a/crates/jacquard-identity/Cargo.toml +++ b/crates/jacquard-identity/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "jacquard-identity" edition.workspace = true -version = "0.3.1" +version = "0.4.0" authors.workspace = true repository.workspace = true keywords.workspace = true diff --git a/crates/jacquard-lexicon/src/codegen.rs b/crates/jacquard-lexicon/src/codegen.rs index 051ce31cb..ba8d6456f 100644 --- a/crates/jacquard-lexicon/src/codegen.rs +++ b/crates/jacquard-lexicon/src/codegen.rs @@ -2606,7 +2606,8 @@ impl<'c> CodeGenerator<'c> { 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() + if let Some(feature) = line + .trim() .strip_prefix("#[cfg(feature = \"") .and_then(|s| s.strip_suffix("\")]")) { @@ -2618,7 +2619,6 @@ impl<'c> CodeGenerator<'c> { 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| { @@ -2648,7 +2648,8 @@ impl<'c> CodeGenerator<'c> { 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(); + 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)); } diff --git a/crates/jacquard-oauth/Cargo.toml b/crates/jacquard-oauth/Cargo.toml index bf975f60c..157d5c02f 100644 --- a/crates/jacquard-oauth/Cargo.toml +++ b/crates/jacquard-oauth/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "jacquard-oauth" -version = "0.3.1" +version = "0.4.0" edition.workspace = true description = "AT Protocol OAuth 2.1 core types and helpers for Jacquard" authors.workspace = true -- 2.51.2