A better Rust ATProto crate

docs and changelog updates, version bump to 0.10

Orual af37cbe9 7725398f

+310 -208
+53 -1
CHANGELOG.md
··· 1 1 # Changelog 2 2 3 + ## [0.10.0] - 2026-03-20 4 + 5 + ### Breaking changes 6 + 7 + **URL type migration** (`jacquard-common`, `jacquard`, `jacquard-oauth`, `jacquard-identity`, `jacquard-api`) 8 + - Migrated from `url` crate to `fluent_uri` for validated URL/URI types 9 + - All `Url` types are now `Uri` from `fluent_uri` 10 + - Affects any code that constructs, passes, or pattern-matches on endpoint URLs 11 + 12 + **Re-exported crate paths** (`jacquard-api`, `jacquard-common`) 13 + - Re-exported crates (including non-proc-macro dependencies of the generated API crate) are now centralized into a distinct module 14 + - Import paths for re-exported types have changed as a result 15 + 16 + ### Added 17 + 18 + **`no_std` groundwork** (`jacquard-common`, `jacquard-api`) 19 + - Initial steps toward `no_std` support for core types 20 + - `jacquard-api` gains feature gating for `std`/`no_std` usage 21 + 22 + **Datetime improvements** (`jacquard-common`) 23 + - [PR from @blyoom.dev](https://tangled.org/nonbinary.computer/jacquard/pulls/6/round/0) exposing timestamps directly on `Datetime` type 24 + - Naming aligned with `chrono` conventions 25 + 26 + **Handle normalization** (`jacquard-common`) 27 + - Handles are now lowercase-normalized on construction 28 + 29 + **Embedded PDS primitives** (`jacquard-repo`) 30 + - Initial lazy disk-spilling collection types for embedded PDS use cases 31 + - Repo firehose types now use generated API types instead of hand-written equivalents 32 + 33 + **Lexicon codegen improvements** (`jacquard-lexicon`, `jacquard-api`) 34 + - `knownValues` generation now aligned with AT Protocol spec and triggers more frequently 35 + - Improved feature dependency tracking for API crate features 36 + 37 + ### Fixed 38 + 39 + **Identity resolution** (`jacquard-identity`) 40 + - [PR from @alephcubed.com](https://tangled.org/nonbinary.computer/jacquard/pulls/7/round/0) fixing `DidDocument::handles()` always failing when parsed from `MiniDoc` 41 + 42 + **Error handling** (`jacquard-common`, `jacquard`, `jacquard-oauth`) 43 + - Big error quality-of-life pass with richer, more actionable diagnostics 44 + - More resilient error parsing for auth errors 45 + - Better lexicon parsing error messages 46 + 47 + **WASM** (`jacquard-common`) 48 + - Fixed WASM CI smoke test compilation 49 + 50 + ### Changed 51 + 52 + **Lexicons** (`jacquard-api`) 53 + - Large batch of lexicon schema updates with manual cleanup 54 + 3 55 ## [0.9.6] - 2025-12-19 4 56 5 57 ### Changed ··· 7 59 **Logging** (`jacquard`, `jacquard-axum`) 8 60 - [PR from @nekomimi.pet](https://tangled.org/nonbinary.computer/jacquard/pulls/5) cleaning up more debug logs, and adding tracing feature gate to jacquard-axum 9 61 10 - ## Fixed 62 + ### Fixed 11 63 12 64 **Repo commit signatures** (`jacquard-repo`) 13 65 - commit signatures generated by `jacquard-repo` should now be consistent with other implementations
+11 -11
Cargo.lock
··· 2371 2371 2372 2372 [[package]] 2373 2373 name = "jacquard" 2374 - version = "0.9.5" 2374 + version = "0.10.0" 2375 2375 dependencies = [ 2376 2376 "bytes", 2377 2377 "clap", ··· 2405 2405 2406 2406 [[package]] 2407 2407 name = "jacquard-api" 2408 - version = "0.9.5" 2408 + version = "0.10.0" 2409 2409 dependencies = [ 2410 2410 "jacquard-common", 2411 2411 "jacquard-derive", ··· 2417 2417 2418 2418 [[package]] 2419 2419 name = "jacquard-axum" 2420 - version = "0.9.6" 2420 + version = "0.10.0" 2421 2421 dependencies = [ 2422 2422 "axum", 2423 2423 "axum-macros", ··· 2447 2447 2448 2448 [[package]] 2449 2449 name = "jacquard-common" 2450 - version = "0.9.5" 2450 + version = "0.10.0" 2451 2451 dependencies = [ 2452 2452 "base64 0.22.1", 2453 2453 "bon", ··· 2502 2502 2503 2503 [[package]] 2504 2504 name = "jacquard-derive" 2505 - version = "0.9.5" 2505 + version = "0.10.0" 2506 2506 dependencies = [ 2507 2507 "heck 0.5.0", 2508 2508 "inventory", ··· 2518 2518 2519 2519 [[package]] 2520 2520 name = "jacquard-identity" 2521 - version = "0.9.5" 2521 + version = "0.10.0" 2522 2522 dependencies = [ 2523 2523 "bon", 2524 2524 "bytes", ··· 2542 2542 2543 2543 [[package]] 2544 2544 name = "jacquard-lexgen" 2545 - version = "0.9.5" 2545 + version = "0.10.0" 2546 2546 dependencies = [ 2547 2547 "clap", 2548 2548 "clap_complete", ··· 2569 2569 2570 2570 [[package]] 2571 2571 name = "jacquard-lexicon" 2572 - version = "0.9.5" 2572 + version = "0.10.0" 2573 2573 dependencies = [ 2574 2574 "bytes", 2575 2575 "cid", ··· 2598 2598 2599 2599 [[package]] 2600 2600 name = "jacquard-oauth" 2601 - version = "0.9.6" 2601 + version = "0.10.0" 2602 2602 dependencies = [ 2603 2603 "base64 0.22.1", 2604 2604 "bytes", ··· 2629 2629 2630 2630 [[package]] 2631 2631 name = "jacquard-repo" 2632 - version = "0.9.6" 2632 + version = "0.10.0" 2633 2633 dependencies = [ 2634 2634 "anyhow", 2635 2635 "bytes", ··· 2770 2770 2771 2771 [[package]] 2772 2772 name = "lazy-collections" 2773 - version = "0.9.5" 2773 + version = "0.10.0" 2774 2774 dependencies = [ 2775 2775 "buffer", 2776 2776 "bytes",
+1 -1
Cargo.toml
··· 5 5 6 6 [workspace.package] 7 7 edition = "2024" 8 - version = "0.9.5" 8 + version = "0.10.0" 9 9 authors = ["Orual <orual@nonbinary.computer>"] 10 10 #repository = "https://github.com/rsform/jacquard" 11 11 repository = "https://tangled.org/@nonbinary.computer/jacquard"
+22 -57
README.md
··· 22 22 - All the building blocks of the convenient abstractions are available 23 23 - Use as much or as little from the crates as you need 24 24 25 - ## 0.9.X Release Highlights: 26 - 27 - **`#[derive(LexiconSchema)]` + `#[lexicon_union]` macros** 28 - - Automatic schema generation for custom lexicons from Rust structs 29 - - Supports all lexicon constraints via attributes (max_length, max_graphemes, min/max, etc.) 30 - - Generates `LexiconDoc` at compile time for runtime validation 31 - 32 - **Runtime lexicon data validation** 33 - - Validation of structural and/or value contraints of data against a lexicon 34 - - caching for value validations 35 - - LexiconSchema trait generated implementations for runtime validation 36 - - detailed validation error results 37 - 38 - **Lexicon resolver** 39 - - Fetch lexicons at runtime for addition to schema registry 40 - 41 - **Query and path DSLs for `Data` and `RawData` value types** 42 - - Pattern-based querying of nested `Data` structures 43 - - `data.query(pattern)` with expressive syntax: 44 - - `field.nested` - exact path navigation 45 - - `[..]` - wildcard over collections (array elements or object values) 46 - - `field..nested` - scoped recursion (find nested within field, expect one) 47 - - `...field` - global recursion (find all occurrences anywhere) 48 - - `get_at_path()` for simple path-based field access on `Data` and `RawData` 49 - - Path syntax: `embed.images[0].alt` for navigating nested structures 50 - - `type_discriminator()` helper methods for AT Protocol union discrimination 51 - - Collection helper methods: `get()`, `contains_key()`, `len()`, `is_empty()`, `iter()`, `keys()`, `values()` 52 - - Index trait implemented: `obj["key"]` and `arr[0]` 53 - 54 - **Caching in identity/lexicon resolver** 55 - - Basic LRU in-memory cache implementation using `mini-moka` 56 - - Reduces number of network requests for certain operations 57 - - Works on both native and WebAssembly via vendored patched version of mini-moka 58 - 59 - 60 - **XRPC client improvements** 61 - - `set_options()` and `set_endpoint()` methods on `XrpcClient` trait 62 - - Default no-op implementations for stateless clients 63 - - Enables runtime reconfiguration of stateful clients 64 - - Better support for custom endpoint and option overrides 65 - - Fixed bug where setting a custom 'Content-Type' header wouldn't be respected 66 - 67 - **Major generated API compilation time improvements** 68 - - Generated code output now includes a typestate builder implementation, similar to the `bon` crate 69 - - Moves the substantial `syn` tax of generating the builders to code generation time, not compile time. 70 - 71 - **New `jacquard-lexgen` crate** 72 - - Moves binaries out of jacquard-lexicon to reduce size further 73 - - Flake app for `lex-fetch` 74 25 75 26 ## Example 76 27 ··· 134 85 If you have `just` installed, you can run the [examples](https://tangled.org/nonbinary.computer/jacquard/tree/main/examples) using `just example {example-name} {ARGS}` or `just examples` to see what's available. 135 86 136 87 > [!WARNING] 137 - > A lot of the streaming code is still pretty experimental. The examples work, though.\ 138 - The modules are also less well-documented, and don't have code examples. There are also a lot of utility functions for conveniently working with the streams and transforming them which are lacking. Use [`n0-future`](https://docs.rs/n0-future/latest/n0_future/index.html) to work with them, that is what Jacquard uses internally as much as possible.\ 139 - >I would also note the same for the repository crate until I've had more third parties test it. 88 + > The latest version swaps from the `url` crate to the lighter and quicker `fluent-uri`. It also moves the re-exported crate paths around and renames the `Uri<'_>` value type enum to `UriValue<'_>` to avoid confusion. This is likely to have broken some things. Migrating is pretty straightforward but consider yourself forewarned. This crate is *not* 1.0 for a reason. 140 89 141 90 ### Changelog 142 91 143 92 [CHANGELOG.md](./CHANGELOG.md) 144 93 145 - <!--### Testimonials 94 + #### 0.10 Release Highlights: 95 + 96 + **URL type migration** 97 + - Migrated from `url` crate to `fluent_uri` for validated URL/URI types 98 + - All `Url` types are now `Uri` from `fluent_uri` 99 + - Affects any code that constructs, passes, or pattern-matches on endpoint URLs 100 + 101 + **Re-exported crate paths** 102 + - Re-exported crates (including non-proc-macro dependencies of the generated API crate) are now centralized into a distinct module 103 + - Import paths for re-exported types have changed 104 + 105 + **`no_std` groundwork** 106 + - Initial work toward allowing jacquard to function on platforms without access to the standard library. 107 + - `std` usage is now feature-gated. the library currently *does not compile* without `std` due to some remaining dependencies. 108 + 109 + ### Testimonials 146 110 147 111 - ["the most straightforward interface to atproto I've encountered so far."](https://bsky.app/profile/offline.mountainherder.xyz/post/3m3xwewzs3k2v) - @offline.mountainherder.xyz 148 - 149 - - "It has saved me a lot of time already! Well worth a few beers and or microcontrollers" - [@baileytownsend.dev](https://bsky.app/profile/baileytownsend.dev)--> 112 + - "It has saved me a lot of time already! Well worth a few beers and or microcontrollers" - [@baileytownsend.dev](https://bsky.app/profile/baileytownsend.dev) 150 113 151 114 ### Projects using Jacquard 152 115 153 116 - [skywatch-phash-rs](https://tangled.org/skywatch.blue/skywatch-phash-rs) 154 - - [Weaver](https://alpha.weaver.sh/) - [tangled repository](https://tangled.org/nonbinary.computer/weaver) 155 - - [wisp.place CLI tool](https://docs.wisp.place/cli/) 117 + - [Weaver](https://weaver.sh/) - [tangled repository](https://tangled.org/nonbinary.computer/weaver) 118 + - [wisp.place CLI tool](https://docs.wisp.place/cli/) - formerly 156 119 - [PDS MOOver](https://pdsmoover.com/) - [tangled repository](https://tangled.org/baileytownsend.dev/pds-moover) 157 120 158 121 ## Component crates ··· 188 151 ``` 189 152 190 153 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. 154 + 155 + 191 156 192 157 [![License](https://img.shields.io/crates/l/jacquard.svg)](./LICENSE)
+4 -4
crates/jacquard-api/Cargo.toml
··· 2 2 name = "jacquard-api" 3 3 description = "Generated AT Protocol API bindings for Jacquard" 4 4 edition.workspace = true 5 - version = "0.9.5" 5 + version = "0.10.0" 6 6 authors.workspace = true 7 7 repository.workspace = true 8 8 keywords.workspace = true ··· 15 15 features = [ "bluesky", "other", "streaming" ] 16 16 17 17 [dependencies] 18 - jacquard-common = { version = "0.9", path = "../jacquard-common" } 19 - jacquard-derive = { version = "0.9", path = "../jacquard-derive" } 20 - jacquard-lexicon = { version = "0.9", path = "../jacquard-lexicon", default-features = false } 18 + jacquard-common = { version = "0.10", path = "../jacquard-common" } 19 + jacquard-derive = { version = "0.10", path = "../jacquard-derive" } 20 + jacquard-lexicon = { version = "0.10", path = "../jacquard-lexicon", default-features = false } 21 21 miette.workspace = true 22 22 serde.workspace = true 23 23 thiserror.workspace = true
+5 -5
crates/jacquard-axum/Cargo.toml
··· 1 1 [package] 2 2 name = "jacquard-axum" 3 3 edition.workspace = true 4 - version = "0.9.6" 4 + version = "0.10.0" 5 5 authors.workspace = true 6 6 repository.workspace = true 7 7 keywords.workspace = true ··· 22 22 [dependencies] 23 23 axum = "0.8.6" 24 24 bytes.workspace = true 25 - jacquard = { version = "0.9", path = "../jacquard", default-features = false, features = ["api"] } 26 - jacquard-common = { version = "0.9", path = "../jacquard-common", features = ["reqwest-client"] } 27 - jacquard-derive = { version = "0.9", path = "../jacquard-derive" } 28 - jacquard-identity = { version = "0.9", path = "../jacquard-identity", optional = true } 25 + jacquard = { version = "0.10", path = "../jacquard", default-features = false, features = ["api"] } 26 + jacquard-common = { version = "0.10", path = "../jacquard-common", features = ["reqwest-client"] } 27 + jacquard-derive = { version = "0.10", path = "../jacquard-derive" } 28 + jacquard-identity = { version = "0.10", path = "../jacquard-identity", optional = true } 29 29 miette.workspace = true 30 30 multibase = { version = "0.9.1", optional = true } 31 31 serde.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.9.5" 5 + version = "0.10.0" 6 6 authors.workspace = true 7 7 repository.workspace = true 8 8 keywords.workspace = true
+4 -28
crates/jacquard-common/src/lib.rs
··· 227 227 pub mod error; 228 228 pub mod http_client; 229 229 pub mod macros; 230 + pub mod opt_serde_bytes_helper; 231 + pub mod serde_bytes_helper; 230 232 #[cfg(feature = "service-auth")] 231 233 pub mod service_auth; 232 234 pub mod session; 235 + #[cfg(feature = "streaming")] 236 + pub mod stream; 233 237 /// Compile-time TLD lookup for disambiguating handles from NSIDs. 234 238 pub(crate) mod tld; 235 239 /// Baseline fundamental AT Protocol data types. 236 240 pub mod types; 237 - // XRPC protocol types and traits 238 - pub mod opt_serde_bytes_helper; 239 - pub mod serde_bytes_helper; 240 - #[cfg(feature = "streaming")] 241 - pub mod stream; 242 241 pub mod xrpc; 243 242 244 243 #[cfg(feature = "streaming")] ··· 289 288 let value = T::deserialize(deserializer)?; 290 289 Ok(value.into_static()) 291 290 } 292 - 293 - #[cfg(test)] 294 - mod tests { 295 - use crate::deps::bytes; 296 - use crate::deps::chrono; 297 - use crate::deps::smol_str::SmolStr; 298 - 299 - #[test] 300 - fn deps_smol_str() { 301 - let s = SmolStr::new_static("test"); 302 - assert_eq!(s, "test"); 303 - } 304 - 305 - #[test] 306 - fn deps_bytes() { 307 - let _x = bytes::Bytes::from_static(b"hello"); 308 - } 309 - 310 - #[test] 311 - fn deps_chrono() { 312 - let _now = chrono::Utc::now(); 313 - } 314 - }
+1 -1
crates/jacquard-common/src/types/value.rs
··· 1177 1177 /// A single match from a query operation 1178 1178 #[derive(Debug, Clone, PartialEq)] 1179 1179 pub struct QueryMatch<'s> { 1180 - /// Path where this value was found (e.g., "actors[0].handle") 1180 + /// Path where this value was found (e.g., "actors\[0\].handle") 1181 1181 pub path: SmolStr, 1182 1182 /// The value (None if field was missing during wildcard iteration) 1183 1183 pub value: Option<&'s Data<'s>>,
+1 -1
crates/jacquard-common/src/xrpc.rs
··· 58 58 /// Normalize a base URI by removing trailing slashes. 59 59 /// 60 60 /// This is useful for XRPC clients where the base URI might be provided with 61 - /// a trailing slash (e.g., "https://bsky.social/") but needs to be normalized 61 + /// a trailing slash (e.g., "<https://bsky.social/>") but needs to be normalized 62 62 /// for consistent path building. Since trimming a trailing slash from a valid URI 63 63 /// always yields a valid URI, the result is guaranteed to be valid. 64 64 pub fn normalize_base_uri(uri: Uri<String>) -> Uri<String> {
+1 -1
crates/jacquard-common/src/xrpc/subscription.rs
··· 720 720 /// This exists primarily for server-side frameworks (like Axum) to extract 721 721 /// typed subscription parameters without lifetime issues. 722 722 pub trait SubscriptionEndpoint { 723 - /// Fully-qualified path ('/xrpc/[nsid]') where this subscription endpoint lives 723 + /// Fully-qualified path ('/xrpc/{nsid}') where this subscription endpoint lives 724 724 const PATH: &'static str; 725 725 726 726 /// Message encoding (JSON or DAG-CBOR)
+3 -3
crates/jacquard-derive/Cargo.toml
··· 16 16 17 17 [dependencies] 18 18 heck.workspace = true 19 - jacquard-lexicon = { version = "0.9", path = "../jacquard-lexicon", features = ["codegen"] } 19 + jacquard-lexicon = { version = "0.10", path = "../jacquard-lexicon", features = ["codegen"] } 20 20 proc-macro2.workspace = true 21 21 quote.workspace = true 22 22 syn.workspace = true 23 23 24 24 [dev-dependencies] 25 25 inventory = "0.3" 26 - jacquard-common = { version = "0.9", path = "../jacquard-common" } 27 - jacquard-lexicon = { version = "0.9", path = "../jacquard-lexicon" } 26 + jacquard-common = { version = "0.10", path = "../jacquard-common" } 27 + jacquard-lexicon = { version = "0.10", path = "../jacquard-lexicon" } 28 28 serde.workspace = true 29 29 serde_json.workspace = true 30 30 unicode-segmentation = "1.12"
+4 -4
crates/jacquard-identity/Cargo.toml
··· 1 1 [package] 2 2 name = "jacquard-identity" 3 3 edition.workspace = true 4 - version = "0.9.5" 4 + version = "0.10.0" 5 5 authors.workspace = true 6 6 repository.workspace = true 7 7 keywords.workspace = true ··· 22 22 trait-variant.workspace = true 23 23 bon.workspace = true 24 24 bytes.workspace = true 25 - jacquard-common = { version = "0.9", path = "../jacquard-common", features = ["reqwest-client"] } 26 - jacquard-api = { version = "0.9", path = "../jacquard-api", default-features = false, features = ["minimal"] } 27 - jacquard-lexicon = { version = "0.9", path = "../jacquard-lexicon", default-features = false } 25 + jacquard-common = { version = "0.10", path = "../jacquard-common", features = ["reqwest-client"] } 26 + jacquard-api = { version = "0.10", path = "../jacquard-api", default-features = false, features = ["minimal"] } 27 + jacquard-lexicon = { version = "0.10", path = "../jacquard-lexicon", default-features = false } 28 28 reqwest.workspace = true 29 29 serde.workspace = true 30 30 serde_json.workspace = true
+5 -5
crates/jacquard-lexgen/Cargo.toml
··· 32 32 clap.workspace = true 33 33 glob = "0.3" 34 34 inventory = "0.3" 35 - jacquard-api = { version = "0.9", path = "../jacquard-api", default-features = false, features = [ "minimal" ] } 36 - jacquard-common = { version = "0.9", features = [ "reqwest-client" ], path = "../jacquard-common" } 37 - jacquard-derive = { version = "0.9", path = "../jacquard-derive" } 38 - jacquard-identity = { version = "0.9", path = "../jacquard-identity", features = ["dns"] } 39 - jacquard-lexicon = { version = "0.9", path = "../jacquard-lexicon" } 35 + jacquard-api = { version = "0.10", path = "../jacquard-api", default-features = false, features = [ "minimal" ] } 36 + jacquard-common = { version = "0.10", features = [ "reqwest-client" ], path = "../jacquard-common" } 37 + jacquard-derive = { version = "0.10", path = "../jacquard-derive" } 38 + jacquard-identity = { version = "0.10", path = "../jacquard-identity", features = ["dns"] } 39 + jacquard-lexicon = { version = "0.10", path = "../jacquard-lexicon" } 40 40 kdl = "6" 41 41 miette = { workspace = true, features = ["fancy"] } 42 42 reqwest = { workspace = true, features = ["json", "http2", "system-proxy", "rustls-tls"] }
+3 -3
crates/jacquard-lexicon/Cargo.toml
··· 2 2 name = "jacquard-lexicon" 3 3 description = "Lexicon schema parsing and code generation for Jacquard" 4 4 edition.workspace = true 5 - version = "0.9.5" 5 + version = "0.10.0" 6 6 authors.workspace = true 7 7 repository.workspace = true 8 8 keywords.workspace = true ··· 20 20 dashmap.workspace = true 21 21 heck = { workspace = true, optional = true } 22 22 inventory = "0.3" 23 - jacquard-common = { version = "0.9", path = "../jacquard-common" } 23 + jacquard-common = { version = "0.10", path = "../jacquard-common" } 24 24 miette = { workspace = true } 25 25 multihash.workspace = true 26 26 prettyplease = { workspace = true, optional = true } ··· 39 39 40 40 [dev-dependencies] 41 41 bytes = { workspace = true } 42 - jacquard-derive = { version = "0.9", path = "../jacquard-derive"} 42 + jacquard-derive = { version = "0.10", path = "../jacquard-derive"} 43 43 tempfile = { version = "3.23.0" }
+1 -1
crates/jacquard-lexicon/src/codegen/builder_gen/state_mod.rs
··· 1 1 //! State module generation for builders 2 2 //! 3 - //! Generates the state trait, Empty state, and SetX<S> transition types 3 + //! Generates the state trait, Empty state, and `SetX<S>` transition types 4 4 //! that enable type-safe builder patterns. 5 5 6 6 use std::collections::HashSet;
+1 -1
crates/jacquard-lexicon/src/derive_impl/lexicon_attr.rs
··· 1 - //! Implementation of #[lexicon] attribute macro 1 + //! Implementation of `#[lexicon]` attribute macro 2 2 3 3 use proc_macro2::TokenStream; 4 4 use quote::quote;
+1 -1
crates/jacquard-lexicon/src/derive_impl/lexicon_union.rs
··· 1 - //! Implementation of #[lexicon_union] attribute macro 1 + //! Implementation of `#[lexicon_union]` attribute macro 2 2 3 3 use proc_macro2::TokenStream; 4 4 use quote::quote;
+1 -1
crates/jacquard-lexicon/src/derive_impl/open_union_attr.rs
··· 1 - //! Implementation of #[open_union] attribute macro 1 + //! Implementation of `#[open_union]` attribute macro 2 2 3 3 use proc_macro2::TokenStream; 4 4 use quote::quote;
-1
crates/jacquard-lexicon/src/lib.rs
··· 10 10 //! - [`corpus`] - Lexicon corpus management and namespace organization 11 11 //! - [`lexicon`] - Schema parsing and validation 12 12 //! - [`schema`] - Schema generation from Rust types (reverse codegen) 13 - //! - [`union_registry`] - Tracks union types for collision detection 14 13 //! - [`fs`] - Filesystem utilities for lexicon storage 15 14 //! - [`derive_impl`] - Implementation functions for derive macros (used by jacquard-derive) 16 15 //! - [`validation`] - Runtime validation of Data against lexicon schemas
+2 -2
crates/jacquard-lexicon/src/schema/from_ast/parse.rs
··· 262 262 Ok(None) 263 263 } 264 264 265 - /// Extract T from Option<T>, return (type, is_required) 265 + /// Extract T from `Option<T>`, return (type, is_required) 266 266 pub fn extract_option_inner(ty: &syn::Type) -> (&syn::Type, bool) { 267 267 if let syn::Type::Path(type_path) = ty { 268 268 if let Some(segment) = type_path.path.segments.last() { ··· 278 278 (ty, true) 279 279 } 280 280 281 - /// Check if type has #[open_union] attribute 281 + /// Check if type has `#[open_union]` attribute 282 282 pub fn has_open_union_attr(attrs: &[Attribute]) -> bool { 283 283 attrs.iter().any(|attr| attr.path().is_ident("open_union")) 284 284 }
+1 -1
crates/jacquard-lexicon/src/schema/from_ast/types.rs
··· 40 40 pub schema_name: String, 41 41 /// Rust type path (for diagnostic purposes) 42 42 pub field_type: String, 43 - /// Is this field required (not Option<T>)? 43 + /// Is this field required (not `Option<T>`)? 44 44 pub is_required: bool, 45 45 /// Is this validating an array length (vs string length)? 46 46 pub is_array: bool,
+3 -3
crates/jacquard-oauth/Cargo.toml
··· 1 1 [package] 2 2 name = "jacquard-oauth" 3 - version = "0.9.6" 3 + version = "0.10.0" 4 4 edition.workspace = true 5 5 description = "AT Protocol OAuth 2.1 core types and helpers for Jacquard" 6 6 authors.workspace = true ··· 21 21 streaming = ["jacquard-common/streaming", "dep:n0-future"] 22 22 23 23 [dependencies] 24 - jacquard-common = { version = "0.9", path = "../jacquard-common", features = ["reqwest-client"] } 25 - jacquard-identity = { version = "0.9", path = "../jacquard-identity" } 24 + jacquard-common = { version = "0.10", path = "../jacquard-common", features = ["reqwest-client"] } 25 + jacquard-identity = { version = "0.10", path = "../jacquard-identity" } 26 26 serde = { workspace = true, features = ["derive"] } 27 27 serde_json = { workspace = true } 28 28 smol_str = { workspace = true }
+15 -19
crates/jacquard-oauth/src/atproto.rs
··· 93 93 pub privacy_policy_uri: Option<Uri<String>>, 94 94 } 95 95 96 - impl<'m> AtprotoClientMetadata<'m> { 97 - pub fn new( 98 - client_id: Uri<String>, 99 - client_uri: Option<Uri<String>>, 100 - redirect_uris: Vec<Uri<String>>, 101 - grant_types: Vec<GrantType>, 102 - scopes: Vec<Scope<'m>>, 103 - jwks_uri: Option<Uri<String>>, 104 - ) -> Self { 105 - Self { 106 - client_id, 107 - client_uri, 108 - redirect_uris, 109 - grant_types, 110 - scopes, 111 - jwks_uri, 112 - client_name: None, 113 - logo_uri: None, 114 - tos_uri: None, 96 + impl<'m> IntoStatic for AtprotoClientMetadata<'m> { 97 + type Output = AtprotoClientMetadata<'static>; 98 + fn into_static(self) -> AtprotoClientMetadata<'static> { 99 + AtprotoClientMetadata { 100 + client_id: self.client_id, 101 + client_uri: self.client_uri, 102 + redirect_uris: self.redirect_uris, 103 + grant_types: self.grant_types, 104 + scopes: self.scopes.into_static(), 105 + jwks_uri: self.jwks_uri, 106 + client_name: self.client_name, 107 + logo_uri: self.logo_uri, 108 + tos_uri: self.tos_uri, 115 109 privacy_policy_uri: None, 116 110 } 117 111 } 112 + } 118 113 114 + impl<'m> AtprotoClientMetadata<'m> { 119 115 pub fn with_prod_info( 120 116 mut self, 121 117 client_name: &str,
+1
crates/jacquard-oauth/src/lib.rs
··· 46 46 //! 47 47 //! See [`atproto`] module for AT Protocol-specific metadata helpers. 48 48 49 + #![warn(missing_docs)] 49 50 pub mod atproto; 50 51 pub mod authstore; 51 52 pub mod client;
+129 -32
crates/jacquard-oauth/src/loopback.rs
··· 1 + //! 2 + //! Helpers for the local loopback server method of atproto OAuth. 3 + //! 4 + //! `OAuthClient::login_with_local_server()` is the nice helper. Here is where 5 + //! it and its components live. Below is what it does, so you can have more 6 + //! granular control without having to make your own loopback server. 7 + //! 8 + //! ```ignore 9 + //! let input = "your_handle_here"; 10 + //! let cfg = LoopbackConfig::default(); 11 + //! let opts = AuthorizeOptions::default(); 12 + //! let port = match cfg.port { 13 + //! LoopbackPort::Fixed(p) => p, 14 + //! LoopbackPort::Ephemeral => 0, 15 + //! }; 16 + //! // TODO: fix this to it also accepts ipv6 and properly finds a free port 17 + //! let bind_addr: SocketAddr = format!("0.0.0.0:{}", port) 18 + //! .parse() 19 + //! .expect("invalid loopback host/port"); 20 + //! let oauth = OAuthClient::with_default_config(FileAuthStore::new(&args.store)); 21 + //! 22 + //! let (local_addr, handle) = one_shot_server(bind_addr); 23 + //! println!("Listening on {}", local_addr); 24 + //! 25 + //! let client_data = oauth.build_localhost_client_data(&cfg, &opts, local_addr); 26 + //! // Build client using store and resolver 27 + //! let flow_client = OAuthClient::new_with_shared( 28 + //! self.registry.store.clone(), 29 + //! self.client.clone(), 30 + //! client_data, 31 + //! ); 32 + //! 33 + //! // Start auth and get authorization URL 34 + //! let auth_url = flow_client.start_auth(input.as_ref(), opts).await?; 35 + //! // Print URL for copy/paste 36 + //! println!("To authenticate with your PDS, visit:\n{}\n", auth_url); 37 + //! // Optionally open browser 38 + //! if cfg.open_browser { 39 + //! let _ = try_open_in_browser(&auth_url); 40 + //! } 41 + //! 42 + //! handle_localhost_callback(handle, &flow_client, &cfg).await 43 + //! ``` 44 + //! 45 + //! 1 46 #![cfg(feature = "loopback")] 2 - 3 47 use crate::{ 4 48 atproto::AtprotoClientMetadata, 5 49 authstore::ClientAuthStore, ··· 41 85 } 42 86 43 87 #[cfg(feature = "browser-open")] 44 - fn try_open_in_browser(url: &str) -> bool { 88 + pub fn try_open_in_browser(url: &str) -> bool { 45 89 webbrowser::open(url).is_ok() 46 90 } 47 91 #[cfg(not(feature = "browser-open"))] 48 - fn try_open_in_browser(_url: &str) -> bool { 92 + pub fn try_open_in_browser(_url: &str) -> bool { 49 93 false 50 94 } 51 95 52 - pub fn create_callback_router( 96 + fn create_callback_router( 53 97 request: &rouille::Request, 54 98 tx: mpsc::Sender<CallbackParams>, 55 99 ) -> rouille::Response { ··· 70 114 ) 71 115 } 72 116 73 - struct CallbackHandle { 117 + pub struct CallbackHandle { 74 118 #[allow(dead_code)] 75 119 server_handle: std::thread::JoinHandle<()>, 76 120 server_stop: std::sync::mpsc::Sender<()>, 77 121 callback_rx: mpsc::Receiver<CallbackParams<'static>>, 78 122 } 79 123 80 - fn one_shot_server(addr: SocketAddr) -> (SocketAddr, CallbackHandle) { 124 + /// One-shot OAuth callback server. 125 + /// 126 + /// Starts an ephemeral in-process web server that listens for the OAuth 127 + /// callback redirect. Returns the server address and a [`CallbackHandle`] 128 + /// that can be used to wait for the callback and stop the server. 129 + /// 130 + /// Use in combination with [`handle_localhost_callback`] to handle the 131 + /// callback for the localhost loopback server. 132 + pub fn one_shot_server(addr: SocketAddr) -> (SocketAddr, CallbackHandle) { 81 133 let (tx, callback_rx) = mpsc::channel(5); 82 134 let server = Server::new(addr, move |request| { 83 135 create_callback_router(request, tx.clone()) ··· 92 144 (addr, handle) 93 145 } 94 146 147 + /// Handles the OAuth callback for the localhost loopback server. 148 + /// 149 + /// Returns a session if the callback succeeds within the configured timeout 150 + /// and shuts down the server. 151 + pub async fn handle_localhost_callback<T, S>( 152 + handle: CallbackHandle, 153 + flow_client: &super::client::OAuthClient<T, S>, 154 + cfg: &LoopbackConfig, 155 + ) -> crate::error::Result<super::client::OAuthSession<T, S>> 156 + where 157 + T: OAuthResolver + DpopExt + Send + Sync + 'static, 158 + S: ClientAuthStore + Send + Sync + 'static, 159 + { 160 + // Await callback or timeout 161 + let mut callback_rx = handle.callback_rx; 162 + let cb = tokio::time::timeout( 163 + std::time::Duration::from_millis(cfg.timeout_ms), 164 + callback_rx.recv(), 165 + ) 166 + .await; 167 + // trigger shutdown 168 + let _ = handle.server_stop.send(()); 169 + if let Ok(Some(cb)) = cb { 170 + // Handle callback and create a session 171 + Ok(flow_client.callback(cb).await?) 172 + } else { 173 + Err(OAuthError::Callback(CallbackError::Timeout)) 174 + } 175 + } 176 + 95 177 impl<T, S> OAuthClient<T, S> 96 178 where 97 179 T: OAuthResolver + DpopExt + Send + Sync + 'static, 98 180 S: ClientAuthStore + Send + Sync + 'static, 99 181 { 100 182 /// Drive the full OAuth flow using a local loopback server. 183 + /// 184 + /// This uses localhost OAuth and an ephemeral in-process web server to 185 + /// handle the OAuth callback redirect. It has a bunch of nice friendly 186 + /// defaults to help you get started and will basically drive the *entire* 187 + /// callback flow itself. 188 + /// 189 + /// Best used for development and for small CLI applications that don't 190 + /// require long session lengths. For long-running unattended sessions, 191 + /// app passwords (via CredentialSession in the jacquard crate) remain 192 + /// the best option. For more complex OAuth, or if you want more control 193 + /// over the process, use the other methods on OAuthClient. 194 + /// 195 + /// 'input' parameter is what you type in the login box (usually, your handle) 196 + /// for it to look up your PDS and redirect to its authentication interface. 197 + /// 198 + /// If the `browser-open` feature is enabled, this will open a web browser 199 + /// for you to authenticate with your PDS. It will also print the 200 + /// callback url to the console for you to copy. 101 201 pub async fn login_with_local_server( 102 202 &self, 103 203 input: impl AsRef<str>, ··· 114 214 .expect("invalid loopback host/port"); 115 215 let (local_addr, handle) = one_shot_server(bind_addr); 116 216 println!("Listening on {}", local_addr); 117 - // build redirect uri 118 - let redirect_uri = format!("http://{}:{}/oauth/callback", cfg.host, local_addr.port(),); 119 - let redirect = Uri::parse(redirect_uri).unwrap(); 120 - 121 - let scopes = if opts.scopes.is_empty() { 122 - Some(self.registry.client_data.config.scopes.clone()) 123 - } else { 124 - Some(opts.scopes.clone().into_static()) 125 - }; 126 217 127 - let client_data = crate::session::ClientData { 128 - keyset: self.registry.client_data.keyset.clone(), 129 - config: AtprotoClientMetadata::new_localhost(Some(vec![redirect]), scopes), 130 - }; 218 + let client_data = self.build_localhost_client_data(&cfg, &opts, local_addr); 131 219 // Build client using store and resolver 132 220 let flow_client = OAuthClient::new_with_shared( 133 221 self.registry.store.clone(), ··· 144 232 let _ = try_open_in_browser(&auth_url); 145 233 } 146 234 147 - // Await callback or timeout 148 - let mut callback_rx = handle.callback_rx; 149 - let cb = tokio::time::timeout( 150 - std::time::Duration::from_millis(cfg.timeout_ms), 151 - callback_rx.recv(), 152 - ) 153 - .await; 154 - // trigger shutdown 155 - let _ = handle.server_stop.send(()); 156 - if let Ok(Some(cb)) = cb { 157 - // Handle callback and create a session 158 - Ok(flow_client.callback(cb).await?) 235 + handle_localhost_callback(handle, &flow_client, &cfg).await 236 + } 237 + 238 + /// Builds a [`crate::session::ClientData`] for use with the local loopback server method of OAuth. 239 + pub fn build_localhost_client_data( 240 + &self, 241 + cfg: &LoopbackConfig, 242 + opts: &AuthorizeOptions<'_>, 243 + local_addr: SocketAddr, 244 + ) -> crate::session::ClientData<'static> { 245 + let redirect_uri = format!("http://{}:{}/oauth/callback", cfg.host, local_addr.port(),); 246 + let redirect = Uri::parse(redirect_uri).unwrap(); 247 + 248 + let scopes = if opts.scopes.is_empty() { 249 + Some(self.registry.client_data.config.scopes.clone()) 159 250 } else { 160 - Err(OAuthError::Callback(CallbackError::Timeout)) 251 + Some(opts.scopes.clone().into_static()) 252 + }; 253 + 254 + crate::session::ClientData { 255 + keyset: self.registry.client_data.keyset.clone(), 256 + config: AtprotoClientMetadata::new_localhost(Some(vec![redirect]), scopes), 161 257 } 258 + .into_static() 162 259 } 163 260 }
+10
crates/jacquard-oauth/src/session.rs
··· 239 239 pub config: AtprotoClientMetadata<'s>, 240 240 } 241 241 242 + impl<'s> IntoStatic for ClientData<'s> { 243 + type Output = ClientData<'static>; 244 + fn into_static(self) -> ClientData<'static> { 245 + ClientData { 246 + keyset: self.keyset, 247 + config: self.config.into_static(), 248 + } 249 + } 250 + } 251 + 242 252 impl<'s> ClientData<'s> { 243 253 pub fn new(keyset: Option<Keyset>, config: AtprotoClientMetadata<'s>) -> Self { 244 254 Self { keyset, config }
+4 -4
crates/jacquard-repo/Cargo.toml
··· 2 2 name = "jacquard-repo" 3 3 description = "AT Protocol repository primitives: MST, commits, CAR I/O" 4 4 edition.workspace = true 5 - version = "0.9.6" 5 + version = "0.10.0" 6 6 authors.workspace = true 7 7 repository.workspace = true 8 8 keywords.workspace = true ··· 16 16 17 17 [dependencies] 18 18 # Internal 19 - jacquard-common = { path = "../jacquard-common", version = "0.9", features = ["crypto-ed25519", "crypto-k256", "crypto-p256"] } 20 - jacquard-derive = { path = "../jacquard-derive", version = "0.9" } 21 - jacquard-api = { path = "../jacquard-api", version = "0.9", features = ["streaming"] } 19 + jacquard-common = { path = "../jacquard-common", version = "0.10", features = ["crypto-ed25519", "crypto-k256", "crypto-p256"] } 20 + jacquard-derive = { path = "../jacquard-derive", version = "0.10" } 21 + jacquard-api = { path = "../jacquard-api", version = "0.10", features = ["streaming"] } 22 22 23 23 # Serialization 24 24 serde.workspace = true
+6 -6
crates/jacquard/Cargo.toml
··· 120 120 required-features = ["api_bluesky", "loopback"] 121 121 122 122 [dependencies] 123 - jacquard-api = { version = "0.9", path = "../jacquard-api" } 124 - jacquard-common = { version = "0.9", path = "../jacquard-common", features = [ 123 + jacquard-api = { version = "0.10", path = "../jacquard-api" } 124 + jacquard-common = { version = "0.10", path = "../jacquard-common", features = [ 125 125 "reqwest-client", 126 126 ] } 127 - jacquard-oauth = { version = "0.9", path = "../jacquard-oauth" } 128 - jacquard-derive = { version = "0.9", path = "../jacquard-derive", optional = true } 129 - jacquard-identity = { version = "0.9", path = "../jacquard-identity" } 127 + jacquard-oauth = { version = "0.10", path = "../jacquard-oauth" } 128 + jacquard-derive = { version = "0.10", path = "../jacquard-derive", optional = true } 129 + jacquard-identity = { version = "0.10", path = "../jacquard-identity" } 130 130 131 131 132 132 ··· 149 149 150 150 151 151 [target.'cfg(not(target_family = "wasm"))'.dependencies] 152 - jacquard-identity = { version = "0.9", path = "../jacquard-identity", features = ["cache"] } 152 + jacquard-identity = { version = "0.10", path = "../jacquard-identity", features = ["cache"] } 153 153 reqwest = { workspace = true, features = [ 154 154 "http2", 155 155 "gzip",
+9 -3
crates/jacquard/src/client.rs
··· 1 1 //! XRPC client implementation for AT Protocol 2 2 //! 3 3 //! This module provides HTTP and XRPC client traits along with session management 4 - //! for both app-password and OAuth authentication. 4 + //! for both app password and OAuth authentication. 5 5 //! 6 6 //! ## Key types 7 7 //! ··· 15 15 //! - [`credential_session`] - App-password session implementation 16 16 //! - [`token`] - Token storage and persistence 17 17 //! - [`vec_update`] - Trait for fetch-modify-put patterns on array endpoints 18 + //! 19 + //! 20 + //! "Agent" in this context is derived from Bluesky's own library usage of the term. 21 + //! It represents a (persistent) user session, and includes a number of helpful 22 + //! methods which are available via the `AgentSessionExt` extension trait 23 + //! on anything that implements `AgentSession` + `IdentityResolver`. 18 24 19 25 //pub mod bff_session; 20 - /// App-password session implementation with auto-refresh 26 + /// App password session implementation with auto-refresh 21 27 pub mod credential_session; 22 28 /// Agent error type 23 29 pub mod error; ··· 801 807 } 802 808 803 809 /// Untyped, freeform record fetcher. 804 - /// Hits [https://slingshot.microcosm.blue] 810 + /// Hits <https://slingshot.microcosm.blue> 805 811 fn fetch_record_slingshot( 806 812 &self, 807 813 uri: &AtUri<'_>,
+1 -1
crates/jacquard/src/lib.rs
··· 62 62 //! let session = oauth 63 63 //! .login_with_local_server( 64 64 //! args.input.clone(), 65 - //! Default::default(), 65 + //! AuthorizeOptions::default(), 66 66 //! LoopbackConfig::default(), 67 67 //! ) 68 68 //! .await?;
+3 -3
crates/jacquard/src/moderation.rs
··· 2 2 //! 3 3 //! This is an attempt to semi-generalize the Bluesky moderation system. It avoids 4 4 //! depending on their lexicons as much as reasonably possible. This works via a 5 - //! trait, [`Labeled`][crate::moderation::Labeled], which represents things that have labels for moderation 5 + //! trait, [`Labeled`], which represents things that have labels for moderation 6 6 //! applied to them. This way the moderation application functions can operate 7 7 //! primarily via the trait, and are thus generic over lexicon types, and are 8 8 //! easy to use with your own types. 9 9 //! 10 10 //! For more complex types which might have labels applied to components, 11 - //! there is the [`Moderateable`][crate::moderation::Moderateable] trait. A mostly complete implementation for 11 + //! there is the [`Moderateable`] trait. A mostly complete implementation for 12 12 //! `FeedViewPost` is available for reference. The trait method outputs a `Vec` 13 13 //! of tuples, where the first element is a string tag and the second is the 14 14 //! moderation decision for the tagged element. This lets application developers ··· 16 16 //! mostly match Bluesky behaviour (respecting "!hide", and such) by default. 17 17 //! 18 18 //! I've taken the time to go through the generated API bindings and implement 19 - //! the [`Labeled`][crate::moderation::Labeled] trait for a number of types. It's a fairly easy trait to 19 + //! the [`Labeled`] trait for a number of types. It's a fairly easy trait to 20 20 //! implement, just not really automatable. 21 21 //! 22 22 //!
+2 -2
crates/lazy-collections/src/io.rs
··· 137 137 /// 138 138 /// All bytes read from this source will be appended to the specified buffer 139 139 /// `buf`. This function will continuously call [`read()`] to append more data to 140 - /// `buf` until [`read()`] returns either [`Ok(0)`] or an error of 140 + /// `buf` until [`read()`] returns either \[`Ok(0)`\] or an error of 141 141 /// non-[`ErrorKind::Interrupted`] kind. 142 142 /// 143 143 /// If successful, this function will return the total number of bytes read. ··· 323 323 /// Creates an adaptor which will read at most `limit` bytes from it. 324 324 /// 325 325 /// This function returns a new instance of `Read` which will read at most 326 - /// `limit` bytes, after which it will always return EOF ([`Ok(0)`]). Any 326 + /// `limit` bytes, after which it will always return EOF (\[`Ok(0)`\]). Any 327 327 /// read errors will not count towards the number of bytes read and future 328 328 /// calls to [`read()`](Self::read) may succeed. 329 329 fn take(self, limit: u64) -> Take<Self>
+1 -1
crates/lazy-collections/src/lib.rs
··· 1 1 #![cfg_attr(target_os = "none", no_std)] 2 - 2 + #![allow(unused)] 3 3 #[cfg(all(not(feature = "std"), feature = "alloc"))] 4 4 extern crate alloc; 5 5