A better Rust ATProto crate

serde bytes helpers for bytes fields in json

Orual 76e7ad63 cbd55399

Changed files
+363 -40
crates
+78
Cargo.lock
··· 215 ] 216 217 [[package]] 218 name = "atomic-waker" 219 version = "1.1.2" 220 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 745 ] 746 747 [[package]] 748 name = "color_quant" 749 version = "1.1.0" 750 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 881 ] 882 883 [[package]] 884 name = "crossbeam-channel" 885 version = "0.5.15" 886 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1232 dependencies = [ 1233 "serde", 1234 ] 1235 1236 [[package]] 1237 name = "encode_unicode" ··· 1722 ] 1723 1724 [[package]] 1725 name = "hashbrown" 1726 version = "0.12.3" 1727 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1738 version = "0.16.0" 1739 source = "registry+https://github.com/rust-lang/crates.io-index" 1740 checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" 1741 1742 [[package]] 1743 name = "heck" ··· 2307 "miette", 2308 "rustversion", 2309 "serde", 2310 "serde_ipld_dagcbor", 2311 "thiserror 2.0.17", 2312 "unicode-segmentation", ··· 2367 "n0-future", 2368 "ouroboros", 2369 "p256", 2370 "rand 0.9.2", 2371 "regex", 2372 "regex-lite", 2373 "reqwest", 2374 "serde", 2375 "serde_html_form", 2376 "serde_ipld_dagcbor", 2377 "serde_json", ··· 3487 ] 3488 3489 [[package]] 3490 name = "potential_utf" 3491 version = "0.1.3" 3492 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4585 version = "0.9.8" 4586 source = "registry+https://github.com/rust-lang/crates.io-index" 4587 checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 4588 4589 [[package]] 4590 name = "spin"
··· 215 ] 216 217 [[package]] 218 + name = "atomic-polyfill" 219 + version = "1.0.3" 220 + source = "registry+https://github.com/rust-lang/crates.io-index" 221 + checksum = "8cf2bce30dfe09ef0bfaef228b9d414faaf7e563035494d7fe092dba54b300f4" 222 + dependencies = [ 223 + "critical-section", 224 + ] 225 + 226 + [[package]] 227 name = "atomic-waker" 228 version = "1.1.2" 229 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 754 ] 755 756 [[package]] 757 + name = "cobs" 758 + version = "0.3.0" 759 + source = "registry+https://github.com/rust-lang/crates.io-index" 760 + checksum = "0fa961b519f0b462e3a3b4a34b64d119eeaca1d59af726fe450bbba07a9fc0a1" 761 + dependencies = [ 762 + "thiserror 2.0.17", 763 + ] 764 + 765 + [[package]] 766 name = "color_quant" 767 version = "1.1.0" 768 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 899 ] 900 901 [[package]] 902 + name = "critical-section" 903 + version = "1.2.0" 904 + source = "registry+https://github.com/rust-lang/crates.io-index" 905 + checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" 906 + 907 + [[package]] 908 name = "crossbeam-channel" 909 version = "0.5.15" 910 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1256 dependencies = [ 1257 "serde", 1258 ] 1259 + 1260 + [[package]] 1261 + name = "embedded-io" 1262 + version = "0.4.0" 1263 + source = "registry+https://github.com/rust-lang/crates.io-index" 1264 + checksum = "ef1a6892d9eef45c8fa6b9e0086428a2cca8491aca8f787c534a3d6d0bcb3ced" 1265 + 1266 + [[package]] 1267 + name = "embedded-io" 1268 + version = "0.6.1" 1269 + source = "registry+https://github.com/rust-lang/crates.io-index" 1270 + checksum = "edd0f118536f44f5ccd48bcb8b111bdc3de888b58c74639dfb034a357d0f206d" 1271 1272 [[package]] 1273 name = "encode_unicode" ··· 1758 ] 1759 1760 [[package]] 1761 + name = "hash32" 1762 + version = "0.2.1" 1763 + source = "registry+https://github.com/rust-lang/crates.io-index" 1764 + checksum = "b0c35f58762feb77d74ebe43bdbc3210f09be9fe6742234d573bacc26ed92b67" 1765 + dependencies = [ 1766 + "byteorder", 1767 + ] 1768 + 1769 + [[package]] 1770 name = "hashbrown" 1771 version = "0.12.3" 1772 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1783 version = "0.16.0" 1784 source = "registry+https://github.com/rust-lang/crates.io-index" 1785 checksum = "5419bdc4f6a9207fbeba6d11b604d481addf78ecd10c11ad51e76c2f6482748d" 1786 + 1787 + [[package]] 1788 + name = "heapless" 1789 + version = "0.7.17" 1790 + source = "registry+https://github.com/rust-lang/crates.io-index" 1791 + checksum = "cdc6457c0eb62c71aac4bc17216026d8410337c4126773b9c5daba343f17964f" 1792 + dependencies = [ 1793 + "atomic-polyfill", 1794 + "hash32", 1795 + "rustc_version", 1796 + "serde", 1797 + "spin 0.9.8", 1798 + "stable_deref_trait", 1799 + ] 1800 1801 [[package]] 1802 name = "heck" ··· 2366 "miette", 2367 "rustversion", 2368 "serde", 2369 + "serde_bytes", 2370 "serde_ipld_dagcbor", 2371 "thiserror 2.0.17", 2372 "unicode-segmentation", ··· 2427 "n0-future", 2428 "ouroboros", 2429 "p256", 2430 + "postcard", 2431 "rand 0.9.2", 2432 "regex", 2433 "regex-lite", 2434 "reqwest", 2435 "serde", 2436 + "serde_bytes", 2437 "serde_html_form", 2438 "serde_ipld_dagcbor", 2439 "serde_json", ··· 3549 ] 3550 3551 [[package]] 3552 + name = "postcard" 3553 + version = "1.1.3" 3554 + source = "registry+https://github.com/rust-lang/crates.io-index" 3555 + checksum = "6764c3b5dd454e283a30e6dfe78e9b31096d9e32036b5d1eaac7a6119ccb9a24" 3556 + dependencies = [ 3557 + "cobs", 3558 + "embedded-io 0.4.0", 3559 + "embedded-io 0.6.1", 3560 + "heapless", 3561 + "serde", 3562 + ] 3563 + 3564 + [[package]] 3565 name = "potential_utf" 3566 version = "0.1.3" 3567 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 4660 version = "0.9.8" 4661 source = "registry+https://github.com/rust-lang/crates.io-index" 4662 checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" 4663 + dependencies = [ 4664 + "lock_api", 4665 + ] 4666 4667 [[package]] 4668 name = "spin"
+1
crates/jacquard-api/Cargo.toml
··· 26 serde_ipld_dagcbor.workspace = true 27 thiserror.workspace = true 28 unicode-segmentation = "1.12" 29 30 31 [lints.rust]
··· 26 serde_ipld_dagcbor.workspace = true 27 thiserror.workspace = true 28 unicode-segmentation = "1.12" 29 + serde_bytes = "0.11" 30 31 32 [lints.rust]
+3
crates/jacquard-common/Cargo.toml
··· 45 thiserror.workspace = true 46 url.workspace = true 47 http.workspace = true 48 49 reqwest = { workspace = true, optional = true, features = ["json", "charset", "gzip", "stream"] } 50 serde_ipld_dagcbor.workspace = true ··· 58 tokio-tungstenite-wasm = { version = "0.4", features = ["rustls-tls-native-roots"], optional = true } 59 ciborium = {version = "0.2.0", optional = true } 60 zstd = { version = "0.13", optional = true } 61 62 [target.'cfg(target_family = "wasm")'.dependencies] 63 getrandom = { version = "0.3.4", features = ["wasm_js"] }
··· 45 thiserror.workspace = true 46 url.workspace = true 47 http.workspace = true 48 + serde_bytes = "0.11" 49 + 50 51 reqwest = { workspace = true, optional = true, features = ["json", "charset", "gzip", "stream"] } 52 serde_ipld_dagcbor.workspace = true ··· 60 tokio-tungstenite-wasm = { version = "0.4", features = ["rustls-tls-native-roots"], optional = true } 61 ciborium = {version = "0.2.0", optional = true } 62 zstd = { version = "0.13", optional = true } 63 + postcard = { version = "1.1.3", features = ["use-std"] } 64 65 [target.'cfg(target_family = "wasm")'.dependencies] 66 getrandom = { version = "0.3.4", features = ["wasm_js"] }
+6 -4
crates/jacquard-common/src/lib.rs
··· 219 /// Baseline fundamental AT Protocol data types. 220 pub mod types; 221 // XRPC protocol types and traits 222 - pub mod xrpc; 223 /// Stream abstractions for HTTP request/response bodies. 224 #[cfg(feature = "streaming")] 225 pub mod stream; 226 227 #[cfg(feature = "streaming")] 228 - pub use stream::{ByteStream, ByteSink, StreamError, StreamErrorKind}; 229 230 #[cfg(feature = "streaming")] 231 pub use xrpc::StreamingResponse; ··· 238 239 #[cfg(feature = "websocket")] 240 pub use websocket::{ 241 - tungstenite_client::TungsteniteClient, CloseCode, CloseFrame, WebSocketClient, 242 - WebSocketConnection, WsMessage, WsSink, WsStream, WsText, 243 }; 244 245 pub use types::value::*;
··· 219 /// Baseline fundamental AT Protocol data types. 220 pub mod types; 221 // XRPC protocol types and traits 222 + pub mod opt_serde_bytes_helper; 223 + pub mod serde_bytes_helper; 224 /// Stream abstractions for HTTP request/response bodies. 225 #[cfg(feature = "streaming")] 226 pub mod stream; 227 + pub mod xrpc; 228 229 #[cfg(feature = "streaming")] 230 + pub use stream::{ByteSink, ByteStream, StreamError, StreamErrorKind}; 231 232 #[cfg(feature = "streaming")] 233 pub use xrpc::StreamingResponse; ··· 240 241 #[cfg(feature = "websocket")] 242 pub use websocket::{ 243 + CloseCode, CloseFrame, WebSocketClient, WebSocketConnection, WsMessage, WsSink, WsStream, 244 + WsText, tungstenite_client::TungsteniteClient, 245 }; 246 247 pub use types::value::*;
+25
crates/jacquard-common/src/opt_serde_bytes_helper.rs
···
··· 1 + //! Custom serde helpers for bytes::Bytes using serde_bytes 2 + 3 + use bytes::Bytes; 4 + use serde::{Deserializer, Serializer}; 5 + 6 + /// Serialize Bytes as a CBOR byte string 7 + pub fn serialize<S>(bytes: &Option<Bytes>, serializer: S) -> Result<S::Ok, S::Error> 8 + where 9 + S: Serializer, 10 + { 11 + if let Some(bytes) = bytes { 12 + serde_bytes::serialize(bytes.as_ref(), serializer) 13 + } else { 14 + serializer.serialize_none() 15 + } 16 + } 17 + 18 + /// Deserialize Bytes from a CBOR byte string 19 + pub fn deserialize<'de, D>(deserializer: D) -> Result<Option<Bytes>, D::Error> 20 + where 21 + D: Deserializer<'de>, 22 + { 23 + let vec: Option<Vec<u8>> = serde_bytes::deserialize(deserializer)?; 24 + Ok(vec.map(Bytes::from)) 25 + }
+21
crates/jacquard-common/src/serde_bytes_helper.rs
···
··· 1 + //! Custom serde helpers for bytes::Bytes using serde_bytes 2 + 3 + use bytes::Bytes; 4 + use serde::{Deserializer, Serializer}; 5 + 6 + /// Serialize Bytes as a CBOR byte string 7 + pub fn serialize<S>(bytes: &Bytes, serializer: S) -> Result<S::Ok, S::Error> 8 + where 9 + S: Serializer, 10 + { 11 + serde_bytes::serialize(bytes.as_ref(), serializer) 12 + } 13 + 14 + /// Deserialize Bytes from a CBOR byte string 15 + pub fn deserialize<'de, D>(deserializer: D) -> Result<Bytes, D::Error> 16 + where 17 + D: Deserializer<'de>, 18 + { 19 + let vec: Vec<u8> = serde_bytes::deserialize(deserializer)?; 20 + Ok(Bytes::from(vec)) 21 + }
+1
crates/jacquard-common/src/types/language.rs
··· 1 use langtag::InvalidLangTag; 2 use serde::{Deserialize, Deserializer, Serialize, de::Error}; 3 use smol_str::{SmolStr, ToSmolStr};
··· 1 + #[allow(unused)] 2 use langtag::InvalidLangTag; 3 use serde::{Deserialize, Deserializer, Serialize, de::Error}; 4 use smol_str::{SmolStr, ToSmolStr};
+23 -1
crates/jacquard-common/src/types/value.rs
··· 5 use bytes::Bytes; 6 use ipld_core::ipld::Ipld; 7 use smol_str::{SmolStr, ToSmolStr}; 8 - use std::collections::BTreeMap; 9 10 /// Conversion utilities for Data types 11 pub mod convert; ··· 854 T: serde::Deserialize<'de> + IntoStatic, 855 { 856 T::deserialize(json).map(IntoStatic::into_static) 857 } 858 859 /// Deserialize a typed value from a `RawData` value
··· 5 use bytes::Bytes; 6 use ipld_core::ipld::Ipld; 7 use smol_str::{SmolStr, ToSmolStr}; 8 + use std::{collections::BTreeMap, convert::Infallible}; 9 10 /// Conversion utilities for Data types 11 pub mod convert; ··· 854 T: serde::Deserialize<'de> + IntoStatic, 855 { 856 T::deserialize(json).map(IntoStatic::into_static) 857 + } 858 + 859 + /// Deserialize a typed value from cbor bytes 860 + /// 861 + /// Returns an owned version, will allocate 862 + pub fn from_cbor<'de, T>( 863 + cbor: &'de [u8], 864 + ) -> Result<<T as IntoStatic>::Output, serde_ipld_dagcbor::DecodeError<Infallible>> 865 + where 866 + T: serde::Deserialize<'de> + IntoStatic, 867 + { 868 + serde_ipld_dagcbor::from_slice::<T>(cbor).map(|d| d.into_static()) 869 + } 870 + 871 + /// Deserialize a typed value from postcard bytes 872 + /// 873 + /// Returns an owned version, will allocate 874 + pub fn from_postcard<'de, T>(bytes: &'de [u8]) -> Result<<T as IntoStatic>::Output, postcard::Error> 875 + where 876 + T: serde::Deserialize<'de> + IntoStatic, 877 + { 878 + postcard::from_bytes::<T>(bytes).map(|d| d.into_static()) 879 } 880 881 /// Deserialize a typed value from a `RawData` value
+2 -2
crates/jacquard-identity/Cargo.toml
··· 15 [features] 16 dns = ["dep:hickory-resolver"] 17 tracing = ["dep:tracing"] 18 - streaming = ["jacquard-common/streaming", "dep:n0-future"] 19 cache = ["dep:mini-moka"] 20 21 [dependencies] ··· 36 serde_html_form.workspace = true 37 urlencoding.workspace = true 38 tracing = { workspace = true, optional = true } 39 - n0-future = { workspace = true, optional = true } 40 mini-moka = { version = "0.10", path = "../mini-moka-vendored", optional = true } 41 # mini-moka = { version = "0.10", optional = true } 42
··· 15 [features] 16 dns = ["dep:hickory-resolver"] 17 tracing = ["dep:tracing"] 18 + streaming = ["jacquard-common/streaming"] 19 cache = ["dep:mini-moka"] 20 21 [dependencies] ··· 36 serde_html_form.workspace = true 37 urlencoding.workspace = true 38 tracing = { workspace = true, optional = true } 39 + n0-future.workspace = true 40 mini-moka = { version = "0.10", path = "../mini-moka-vendored", optional = true } 41 # mini-moka = { version = "0.10", optional = true } 42
+64 -12
crates/jacquard-identity/src/lib.rs
··· 390 self 391 } 392 393 #[cfg(feature = "cache")] 394 /// Enable caching with default configuration 395 pub fn with_cache(mut self) -> Self { ··· 849 } 850 851 impl HttpClient for JacquardResolver { 852 async fn send_http( 853 &self, 854 request: http::Request<Vec<u8>>, 855 ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> { 856 - self.http.send_http(request).await 857 } 858 - 859 - type Error = reqwest::Error; 860 } 861 862 #[cfg(feature = "streaming")] 863 impl jacquard_common::http_client::HttpClientExt for JacquardResolver { 864 /// Send HTTP request and return streaming response 865 - fn send_http_streaming( 866 &self, 867 request: http::Request<Vec<u8>>, 868 - ) -> impl Future<Output = Result<http::Response<ByteStream>, Self::Error>> { 869 - self.http.send_http_streaming(request) 870 } 871 872 /// Send HTTP request with streaming body and receive streaming response 873 #[cfg(not(target_arch = "wasm32"))] 874 - fn send_http_bidirectional<S>( 875 &self, 876 parts: http::request::Parts, 877 body: S, 878 - ) -> impl Future<Output = Result<http::Response<ByteStream>, Self::Error>> 879 where 880 S: n0_future::Stream<Item = Result<bytes::Bytes, jacquard_common::StreamError>> 881 + Send 882 + 'static, 883 { 884 - self.http.send_http_bidirectional(parts, body) 885 } 886 887 /// Send HTTP request with streaming body and receive streaming response (WASM) 888 #[cfg(target_arch = "wasm32")] 889 - fn send_http_bidirectional<S>( 890 &self, 891 parts: http::request::Parts, 892 body: S, 893 - ) -> impl Future<Output = Result<http::Response<ByteStream>, Self::Error>> 894 where 895 S: n0_future::Stream<Item = Result<bytes::Bytes, jacquard_common::StreamError>> + 'static, 896 { 897 - self.http.send_http_bidirectional(parts, body) 898 } 899 } 900
··· 390 self 391 } 392 393 + /// Set the HTTP request timeout. Pass `None` to disable timeout. 394 + pub fn with_request_timeout(mut self, timeout: Option<n0_future::time::Duration>) -> Self { 395 + self.opts.request_timeout = timeout; 396 + self 397 + } 398 + 399 #[cfg(feature = "cache")] 400 /// Enable caching with default configuration 401 pub fn with_cache(mut self) -> Self { ··· 855 } 856 857 impl HttpClient for JacquardResolver { 858 + type Error = IdentityError; 859 + 860 async fn send_http( 861 &self, 862 request: http::Request<Vec<u8>>, 863 ) -> core::result::Result<http::Response<Vec<u8>>, Self::Error> { 864 + match self.opts.request_timeout { 865 + Some(duration) => n0_future::time::timeout(duration, self.http.send_http(request)) 866 + .await 867 + .map_err(|_| IdentityError::timeout())? 868 + .map_err(IdentityError::transport), 869 + None => self 870 + .http 871 + .send_http(request) 872 + .await 873 + .map_err(IdentityError::transport), 874 + } 875 } 876 } 877 878 #[cfg(feature = "streaming")] 879 impl jacquard_common::http_client::HttpClientExt for JacquardResolver { 880 /// Send HTTP request and return streaming response 881 + async fn send_http_streaming( 882 &self, 883 request: http::Request<Vec<u8>>, 884 + ) -> Result<http::Response<ByteStream>, Self::Error> { 885 + match self.opts.request_timeout { 886 + Some(duration) => { 887 + n0_future::time::timeout(duration, self.http.send_http_streaming(request)) 888 + .await 889 + .map_err(|_| IdentityError::timeout())? 890 + .map_err(IdentityError::transport) 891 + } 892 + None => self 893 + .http 894 + .send_http_streaming(request) 895 + .await 896 + .map_err(IdentityError::transport), 897 + } 898 } 899 900 /// Send HTTP request with streaming body and receive streaming response 901 #[cfg(not(target_arch = "wasm32"))] 902 + async fn send_http_bidirectional<S>( 903 &self, 904 parts: http::request::Parts, 905 body: S, 906 + ) -> Result<http::Response<ByteStream>, Self::Error> 907 where 908 S: n0_future::Stream<Item = Result<bytes::Bytes, jacquard_common::StreamError>> 909 + Send 910 + 'static, 911 { 912 + match self.opts.request_timeout { 913 + Some(duration) => { 914 + n0_future::time::timeout(duration, self.http.send_http_bidirectional(parts, body)) 915 + .await 916 + .map_err(|_| IdentityError::timeout())? 917 + .map_err(IdentityError::transport) 918 + } 919 + None => self 920 + .http 921 + .send_http_bidirectional(parts, body) 922 + .await 923 + .map_err(IdentityError::transport), 924 + } 925 } 926 927 /// Send HTTP request with streaming body and receive streaming response (WASM) 928 #[cfg(target_arch = "wasm32")] 929 + async fn send_http_bidirectional<S>( 930 &self, 931 parts: http::request::Parts, 932 body: S, 933 + ) -> Result<http::Response<ByteStream>, Self::Error> 934 where 935 S: n0_future::Stream<Item = Result<bytes::Bytes, jacquard_common::StreamError>> + 'static, 936 { 937 + match self.opts.request_timeout { 938 + Some(duration) => { 939 + n0_future::time::timeout(duration, self.http.send_http_bidirectional(parts, body)) 940 + .await 941 + .map_err(|_| IdentityError::timeout())? 942 + .map_err(IdentityError::transport) 943 + } 944 + None => self 945 + .http 946 + .send_http_bidirectional(parts, body) 947 + .await 948 + .map_err(IdentityError::transport), 949 + } 950 } 951 } 952
+17
crates/jacquard-identity/src/resolver.rs
··· 20 use jacquard_common::types::uri::Uri; 21 use jacquard_common::types::value::{AtDataError, Data}; 22 use jacquard_common::{CowStr, IntoStatic, smol_str}; 23 use smol_str::SmolStr; 24 use std::collections::BTreeMap; 25 use std::marker::Sync; ··· 219 pub validate_doc_id: bool, 220 /// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app 221 pub public_fallback_for_handle: bool, 222 } 223 224 impl Default for ResolverOptions { ··· 250 .did_order(did_order) 251 .validate_doc_id(true) 252 .public_fallback_for_handle(true) 253 .build() 254 } 255 } ··· 538 )] 539 Transport, 540 541 /// HTTP status error 542 #[error("HTTP {0}")] 543 #[diagnostic( ··· 651 /// Create a transport error 652 pub fn transport(source: impl std::error::Error + Send + Sync + 'static) -> Self { 653 Self::new(IdentityErrorKind::Transport, Some(Box::new(source))) 654 } 655 656 /// Create an HTTP status error
··· 20 use jacquard_common::types::uri::Uri; 21 use jacquard_common::types::value::{AtDataError, Data}; 22 use jacquard_common::{CowStr, IntoStatic, smol_str}; 23 + use n0_future::time::Duration; 24 use smol_str::SmolStr; 25 use std::collections::BTreeMap; 26 use std::marker::Sync; ··· 220 pub validate_doc_id: bool, 221 /// Allow public unauthenticated fallback for resolveHandle via public.api.bsky.app 222 pub public_fallback_for_handle: bool, 223 + /// HTTP request timeout. Default: 10 seconds. Set to None to disable. 224 + pub request_timeout: Option<Duration>, 225 } 226 227 impl Default for ResolverOptions { ··· 253 .did_order(did_order) 254 .validate_doc_id(true) 255 .public_fallback_for_handle(true) 256 + .request_timeout(Duration::from_secs(20)) 257 .build() 258 } 259 } ··· 542 )] 543 Transport, 544 545 + /// Request timeout 546 + #[error("request timed out")] 547 + #[diagnostic( 548 + code(jacquard::identity::timeout), 549 + help("the server took too long to respond") 550 + )] 551 + Timeout, 552 + 553 /// HTTP status error 554 #[error("HTTP {0}")] 555 #[diagnostic( ··· 663 /// Create a transport error 664 pub fn transport(source: impl std::error::Error + Send + Sync + 'static) -> Self { 665 Self::new(IdentityErrorKind::Transport, Some(Box::new(source))) 666 + } 667 + 668 + /// Create a timeout error 669 + pub fn timeout() -> Self { 670 + Self::new(IdentityErrorKind::Timeout, None) 671 } 672 673 /// Create an HTTP status error
+9 -1
crates/jacquard-lexicon/src/codegen/structs.rs
··· 323 field_name: &str, 324 field_type: &LexObjectProperty<'static>, 325 is_required: bool, 326 - is_builder: bool, 327 ) -> Result<TokenStream> { 328 if field_name.is_empty() { 329 eprintln!( ··· 368 // Add serde(borrow) to all fields with lifetimes 369 if needs_lifetime { 370 attrs.push(quote! { #[serde(borrow)] }); 371 } 372 373 Ok(quote! {
··· 323 field_name: &str, 324 field_type: &LexObjectProperty<'static>, 325 is_required: bool, 326 + _is_builder: bool, 327 ) -> Result<TokenStream> { 328 if field_name.is_empty() { 329 eprintln!( ··· 368 // Add serde(borrow) to all fields with lifetimes 369 if needs_lifetime { 370 attrs.push(quote! { #[serde(borrow)] }); 371 + } 372 + 373 + if matches!(field_type, LexObjectProperty::Bytes(_)) { 374 + if is_required { 375 + attrs.push(quote! { #[serde(with = "jacquard_common::serde_bytes_helper")] }); 376 + } else { 377 + attrs.push(quote! {#[serde(with = "jacquard_common::opt_serde_bytes_helper")] }); 378 + } 379 } 380 381 Ok(quote! {
+6 -1
crates/jacquard-lexicon/src/error.rs
··· 76 )] 77 Unsupported { 78 /// Description of the unsupported feature 79 feature: String, 80 /// NSID of lexicon containing the feature 81 lexicon_nsid: String, 82 /// Optional suggestion for workaround 83 suggestion: Option<String>, 84 }, 85 ··· 87 #[error("Name collision: {name}")] 88 #[diagnostic( 89 code(lexicon::name_collision), 90 - help("Multiple types would generate the same Rust identifier. Module paths will disambiguate.") 91 )] 92 NameCollision { 93 /// The colliding name
··· 76 )] 77 Unsupported { 78 /// Description of the unsupported feature 79 + #[allow(unused)] 80 feature: String, 81 /// NSID of lexicon containing the feature 82 + #[allow(unused)] 83 lexicon_nsid: String, 84 /// Optional suggestion for workaround 85 + #[allow(unused)] 86 suggestion: Option<String>, 87 }, 88 ··· 90 #[error("Name collision: {name}")] 91 #[diagnostic( 92 code(lexicon::name_collision), 93 + help( 94 + "Multiple types would generate the same Rust identifier. Module paths will disambiguate." 95 + ) 96 )] 97 NameCollision { 98 /// The colliding name
+23 -8
crates/jacquard-oauth/src/atproto.rs
··· 152 #[derive(serde::Serialize)] 153 struct Parameters<'a> { 154 #[serde(skip_serializing_if = "Option::is_none")] 155 - redirect_uri: Option<Vec<CowStr<'a>>>, 156 #[serde(skip_serializing_if = "Option::is_none")] 157 scope: Option<CowStr<'a>>, 158 } 159 let query = serde_html_form::to_string(Parameters { 160 - redirect_uri: redirect_uris.as_ref().map(|u| { 161 - u.iter() 162 - .map(|u| u.as_str().trim_end_matches("/").to_cowstr().into_static()) 163 - .collect() 164 - }), 165 scope: scopes 166 .as_ref() 167 .map(|s| Scope::serialize_multiple(s.as_slice())), ··· 196 keyset: &Option<Keyset>, 197 ) -> Result<OAuthClientMetadata<'m>> { 198 // For non-loopback clients, require a keyset/JWKs. 199 - // let is_loopback = 200 - // metadata.client_id.scheme() == "http" && metadata.client_id.host_str() == Some("localhost"); 201 // if !is_loopback && keyset.is_none() { 202 // return Err(Error::EmptyJwks); 203 // } ··· 234 client_id: client_id.to_cowstr().into_static(), 235 client_uri, 236 redirect_uris, 237 token_endpoint_auth_method: Some(auth_method.into()), 238 grant_types: if keyset.is_some() { 239 Some(metadata.grant_types.into_iter().map(|v| v.into()).collect()) 240 } else { 241 None 242 }, 243 scope: Some(Scope::serialize_multiple(metadata.scopes.as_slice())), 244 dpop_bound_access_tokens: Some(true), 245 jwks_uri, ··· 287 CowStr::new_static("http://127.0.0.1"), 288 CowStr::new_static("http://[::1]"), 289 ], 290 scope: Some(CowStr::new_static("atproto")), 291 grant_types: None, 292 token_endpoint_auth_method: Some(AuthMethod::None.into()), 293 dpop_bound_access_tokens: Some(true), 294 jwks_uri: None, ··· 333 scope: Some(CowStr::new_static( 334 "account:email atproto transition:generic" 335 )), 336 grant_types: None, 337 token_endpoint_auth_method: Some(AuthMethod::None.into()), 338 dpop_bound_access_tokens: Some(true), 339 jwks_uri: None, ··· 365 client_id: CowStr::new_static( 366 "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1" 367 ), 368 client_uri: None, 369 redirect_uris: vec![CowStr::new_static("http://127.0.0.1")], 370 scope: Some(CowStr::new_static("atproto")), 371 grant_types: None, 372 token_endpoint_auth_method: Some(AuthMethod::None.into()), 373 dpop_bound_access_tokens: Some(true), 374 jwks_uri: None, ··· 400 redirect_uris: vec![CowStr::new_static("http://127.0.0.1:8000")], 401 scope: Some(CowStr::new_static("atproto")), 402 grant_types: None, 403 token_endpoint_auth_method: Some(AuthMethod::None.into()), 404 dpop_bound_access_tokens: Some(true), 405 jwks_uri: None, ··· 431 redirect_uris: vec![CowStr::new_static("http://127.0.0.1")], 432 scope: Some(CowStr::new_static("atproto")), 433 grant_types: None, 434 token_endpoint_auth_method: Some(AuthMethod::None.into()), 435 dpop_bound_access_tokens: Some(true), 436 jwks_uri: None, ··· 484 client_id: CowStr::new_static("https://example.com/client_metadata.json"), 485 client_uri: Some(CowStr::new_static("https://example.com")), 486 redirect_uris: vec![CowStr::new_static("https://example.com/callback")], 487 scope: Some(CowStr::new_static("atproto")), 488 grant_types: Some(vec![CowStr::new_static("authorization_code")]), 489 token_endpoint_auth_method: Some(AuthMethod::PrivateKeyJwt.into()), 490 dpop_bound_access_tokens: Some(true), 491 jwks_uri: None, 492 jwks: Some(keyset.public_jwks()), 493 token_endpoint_auth_signing_alg: Some(CowStr::new_static("ES256")),
··· 152 #[derive(serde::Serialize)] 153 struct Parameters<'a> { 154 #[serde(skip_serializing_if = "Option::is_none")] 155 + redirect_uri: Option<Vec<Url>>, 156 #[serde(skip_serializing_if = "Option::is_none")] 157 scope: Option<CowStr<'a>>, 158 } 159 let query = serde_html_form::to_string(Parameters { 160 + redirect_uri: redirect_uris.clone(), 161 scope: scopes 162 .as_ref() 163 .map(|s| Scope::serialize_multiple(s.as_slice())), ··· 192 keyset: &Option<Keyset>, 193 ) -> Result<OAuthClientMetadata<'m>> { 194 // For non-loopback clients, require a keyset/JWKs. 195 + let is_loopback = 196 + metadata.client_id.scheme() == "http" && metadata.client_id.host_str() == Some("localhost"); 197 + let application_type = if is_loopback { 198 + Some(CowStr::new_static("native")) 199 + } else { 200 + Some(CowStr::new_static("web")) 201 + }; 202 // if !is_loopback && keyset.is_none() { 203 // return Err(Error::EmptyJwks); 204 // } ··· 235 client_id: client_id.to_cowstr().into_static(), 236 client_uri, 237 redirect_uris, 238 + application_type, 239 token_endpoint_auth_method: Some(auth_method.into()), 240 grant_types: if keyset.is_some() { 241 Some(metadata.grant_types.into_iter().map(|v| v.into()).collect()) 242 } else { 243 None 244 }, 245 + response_types: vec!["code".to_cowstr()], 246 scope: Some(Scope::serialize_multiple(metadata.scopes.as_slice())), 247 dpop_bound_access_tokens: Some(true), 248 jwks_uri, ··· 290 CowStr::new_static("http://127.0.0.1"), 291 CowStr::new_static("http://[::1]"), 292 ], 293 + application_type: Some(CowStr::new_static("native")), 294 scope: Some(CowStr::new_static("atproto")), 295 grant_types: None, 296 + response_types: vec!["code".to_cowstr()], 297 token_endpoint_auth_method: Some(AuthMethod::None.into()), 298 dpop_bound_access_tokens: Some(true), 299 jwks_uri: None, ··· 338 scope: Some(CowStr::new_static( 339 "account:email atproto transition:generic" 340 )), 341 + application_type: Some(CowStr::new_static("native")), 342 grant_types: None, 343 + response_types: vec!["code".to_cowstr()], 344 token_endpoint_auth_method: Some(AuthMethod::None.into()), 345 dpop_bound_access_tokens: Some(true), 346 jwks_uri: None, ··· 372 client_id: CowStr::new_static( 373 "http://localhost/?redirect_uri=http%3A%2F%2F127.0.0.1" 374 ), 375 + application_type: Some(CowStr::new_static("native")), 376 client_uri: None, 377 redirect_uris: vec![CowStr::new_static("http://127.0.0.1")], 378 scope: Some(CowStr::new_static("atproto")), 379 grant_types: None, 380 + response_types: vec!["code".to_cowstr()], 381 token_endpoint_auth_method: Some(AuthMethod::None.into()), 382 dpop_bound_access_tokens: Some(true), 383 jwks_uri: None, ··· 409 redirect_uris: vec![CowStr::new_static("http://127.0.0.1:8000")], 410 scope: Some(CowStr::new_static("atproto")), 411 grant_types: None, 412 + application_type: Some(CowStr::new_static("native")), 413 + response_types: vec!["code".to_cowstr()], 414 token_endpoint_auth_method: Some(AuthMethod::None.into()), 415 dpop_bound_access_tokens: Some(true), 416 jwks_uri: None, ··· 442 redirect_uris: vec![CowStr::new_static("http://127.0.0.1")], 443 scope: Some(CowStr::new_static("atproto")), 444 grant_types: None, 445 + application_type: Some(CowStr::new_static("native")), 446 + response_types: vec!["code".to_cowstr()], 447 token_endpoint_auth_method: Some(AuthMethod::None.into()), 448 dpop_bound_access_tokens: Some(true), 449 jwks_uri: None, ··· 497 client_id: CowStr::new_static("https://example.com/client_metadata.json"), 498 client_uri: Some(CowStr::new_static("https://example.com")), 499 redirect_uris: vec![CowStr::new_static("https://example.com/callback")], 500 + application_type: Some(CowStr::new_static("web")), 501 scope: Some(CowStr::new_static("atproto")), 502 grant_types: Some(vec![CowStr::new_static("authorization_code")]), 503 token_endpoint_auth_method: Some(AuthMethod::PrivateKeyJwt.into()), 504 dpop_bound_access_tokens: Some(true), 505 + response_types: vec!["code".to_cowstr()], 506 jwks_uri: None, 507 jwks: Some(keyset.public_jwks()), 508 token_endpoint_auth_signing_alg: Some(CowStr::new_static("ES256")),
+11 -3
crates/jacquard-oauth/src/loopback.rs
··· 1 #![cfg(feature = "loopback")] 2 3 use crate::{ 4 authstore::ClientAuthStore, 5 client::OAuthClient, 6 dpop::DpopExt, ··· 121 )) 122 .unwrap(); 123 124 - let mut client_data = self.registry.client_data.clone(); 125 - // Ensure the redirect URI is set correctly for the loopback server 126 - client_data.config.redirect_uris = vec![redirect]; 127 // Build client using store and resolver 128 let flow_client = OAuthClient::new_with_shared( 129 self.registry.store.clone(),
··· 1 #![cfg(feature = "loopback")] 2 3 use crate::{ 4 + atproto::AtprotoClientMetadata, 5 authstore::ClientAuthStore, 6 client::OAuthClient, 7 dpop::DpopExt, ··· 122 )) 123 .unwrap(); 124 125 + let scopes = if opts.scopes.is_empty() { 126 + Some(self.registry.client_data.config.scopes.clone()) 127 + } else { 128 + Some(opts.scopes.clone().into_static()) 129 + }; 130 + 131 + let client_data = crate::session::ClientData { 132 + keyset: self.registry.client_data.keyset.clone(), 133 + config: AtprotoClientMetadata::new_localhost(Some(vec![redirect.clone()]), scopes), 134 + }; 135 // Build client using store and resolver 136 let flow_client = OAuthClient::new_with_shared( 137 self.registry.store.clone(),
+19
crates/jacquard-oauth/src/request.rs
··· 302 pub fn atproto(source: impl std::error::Error + Send + Sync + 'static) -> Self { 303 Self::new(RequestErrorKind::Atproto, Some(Box::new(source))) 304 } 305 } 306 307 // From impls for common error types ··· 939 redirect_uris: vec![CowStr::new_static("https://client/cb")], 940 scope: Some(CowStr::from("atproto")), 941 grant_types: None, 942 token_endpoint_auth_method: Some(CowStr::from("none")), 943 dpop_bound_access_tokens: None, 944 jwks_uri: None,
··· 302 pub fn atproto(source: impl std::error::Error + Send + Sync + 'static) -> Self { 303 Self::new(RequestErrorKind::Atproto, Some(Box::new(source))) 304 } 305 + 306 + /// Returns true if this error indicates permanent auth failure 307 + /// (token revoked, refresh_token expired, etc.) 308 + /// 309 + /// When this returns true, the session should be cleared from storage 310 + /// rather than retried. 311 + pub fn is_permanent(&self) -> bool { 312 + match &self.kind { 313 + RequestErrorKind::NoRefreshToken => true, 314 + RequestErrorKind::HttpStatusWithBody { body, .. } => { 315 + body.get("error") 316 + .and_then(|e| e.as_str()) 317 + .is_some_and(|e| matches!(e, "invalid_grant" | "access_denied")) 318 + } 319 + _ => false, 320 + } 321 + } 322 } 323 324 // From impls for common error types ··· 956 redirect_uris: vec![CowStr::new_static("https://client/cb")], 957 scope: Some(CowStr::from("atproto")), 958 grant_types: None, 959 + response_types: vec![CowStr::new_static("code")], 960 + application_type: Some(CowStr::new_static("web")), 961 token_endpoint_auth_method: Some(CowStr::from("none")), 962 dpop_bound_access_tokens: None, 963 jwks_uri: None,
+1
crates/jacquard-oauth/src/resolver.rs
··· 5 use http::{Request, StatusCode}; 6 use jacquard_common::CowStr; 7 use jacquard_common::IntoStatic; 8 use jacquard_common::cowstr::ToCowStr; 9 use jacquard_common::types::did_doc::DidDocument; 10 use jacquard_common::types::ident::AtIdentifier;
··· 5 use http::{Request, StatusCode}; 6 use jacquard_common::CowStr; 7 use jacquard_common::IntoStatic; 8 + #[allow(unused_imports)] 9 use jacquard_common::cowstr::ToCowStr; 10 use jacquard_common::types::did_doc::DidDocument; 11 use jacquard_common::types::ident::AtIdentifier;
+45 -6
crates/jacquard-oauth/src/session.rs
··· 1 use std::sync::Arc; 2 3 use crate::{ 4 atproto::{AtprotoClientMetadata, atproto_client_metadata}, 5 authstore::ClientAuthStore, ··· 295 #[error("session does not exist")] 296 #[diagnostic(code(jacquard_oauth::session::not_found))] 297 SessionNotFound, 298 } 299 300 pub struct SessionRegistry<T, S> ··· 351 .clone(); 352 let _guard = lock.lock().await; 353 354 - let mut session = self 355 .store 356 .get_session(did, session_id) 357 .await? 358 .ok_or(Error::SessionNotFound)?; 359 if let Some(expires_at) = &session.token_set.expires_at { 360 - if expires_at > &Datetime::now() { 361 return Ok(session); 362 } 363 } 364 let metadata = 365 OAuthMetadata::new(self.client.as_ref(), &self.client_data, &session).await?; 366 - session = refresh(self.client.as_ref(), session, &metadata).await?; 367 - self.store.upsert_session(session.clone()).await?; 368 - 369 - Ok(session) 370 } 371 pub async fn get( 372 &self,
··· 1 use std::sync::Arc; 2 3 + use chrono::TimeDelta; 4 + 5 use crate::{ 6 atproto::{AtprotoClientMetadata, atproto_client_metadata}, 7 authstore::ClientAuthStore, ··· 297 #[error("session does not exist")] 298 #[diagnostic(code(jacquard_oauth::session::not_found))] 299 SessionNotFound, 300 + #[error("session refresh failed permanently")] 301 + #[diagnostic( 302 + code(jacquard_oauth::session::refresh_failed), 303 + help("the session has been cleared - user must re-authenticate") 304 + )] 305 + RefreshFailed(#[source] crate::request::RequestError), 306 + } 307 + 308 + impl Error { 309 + /// Returns true if this error indicates a permanent auth failure 310 + /// where the user needs to re-authenticate. 311 + pub fn is_permanent(&self) -> bool { 312 + match self { 313 + Error::RefreshFailed(_) => true, 314 + Error::SessionNotFound => true, 315 + Error::ServerAgent(e) => e.is_permanent(), 316 + Error::Store(_) => false, 317 + } 318 + } 319 } 320 321 pub struct SessionRegistry<T, S> ··· 372 .clone(); 373 let _guard = lock.lock().await; 374 375 + let session = self 376 .store 377 .get_session(did, session_id) 378 .await? 379 .ok_or(Error::SessionNotFound)?; 380 + 381 + // Check if token is still valid with a 60-second buffer before expiry. 382 + // This triggers proactive refresh before the token actually expires, 383 + // avoiding the race condition where a token expires mid-request. 384 + const EXPIRY_BUFFER_SECS: i64 = 60; 385 if let Some(expires_at) = &session.token_set.expires_at { 386 + let now_with_buffer = Datetime::now() 387 + .as_ref() 388 + .checked_add_signed(TimeDelta::seconds(EXPIRY_BUFFER_SECS)) 389 + .map(Datetime::new) 390 + .unwrap_or_else(Datetime::now); 391 + if expires_at > &now_with_buffer { 392 return Ok(session); 393 } 394 } 395 let metadata = 396 OAuthMetadata::new(self.client.as_ref(), &self.client_data, &session).await?; 397 + match refresh(self.client.as_ref(), session, &metadata).await { 398 + Ok(refreshed) => { 399 + self.store.upsert_session(refreshed.clone()).await?; 400 + Ok(refreshed) 401 + } 402 + Err(e) if e.is_permanent() => { 403 + // Session is permanently dead - clean it up 404 + let _ = self.store.delete_session(did, session_id).await; 405 + Err(Error::RefreshFailed(e)) 406 + } 407 + Err(e) => Err(Error::ServerAgent(e)), 408 + } 409 } 410 pub async fn get( 411 &self,
+1 -1
crates/jacquard-oauth/src/types.rs
··· 47 fn default() -> Self { 48 Self { 49 redirect_uri: None, 50 - scopes: vec![Scope::Atproto], 51 prompt: None, 52 state: None, 53 }
··· 47 fn default() -> Self { 48 Self { 49 redirect_uri: None, 50 + scopes: vec![], 51 prompt: None, 52 state: None, 53 }
+5
crates/jacquard-oauth/src/types/client_metadata.rs
··· 13 #[serde(borrow)] 14 pub scope: Option<CowStr<'c>>, 15 #[serde(skip_serializing_if = "Option::is_none")] 16 pub grant_types: Option<Vec<CowStr<'c>>>, 17 #[serde(skip_serializing_if = "Option::is_none")] 18 pub token_endpoint_auth_method: Option<CowStr<'c>>, 19 // https://datatracker.ietf.org/doc/html/rfc9449#section-5.2 20 #[serde(skip_serializing_if = "Option::is_none")] 21 pub dpop_bound_access_tokens: Option<bool>, ··· 48 client_uri: self.client_uri.into_static(), 49 redirect_uris: self.redirect_uris.into_static(), 50 scope: self.scope.map(|scope| scope.into_static()), 51 grant_types: self.grant_types.map(|types| types.into_static()), 52 token_endpoint_auth_method: self 53 .token_endpoint_auth_method 54 .map(|method| method.into_static()),
··· 13 #[serde(borrow)] 14 pub scope: Option<CowStr<'c>>, 15 #[serde(skip_serializing_if = "Option::is_none")] 16 + pub application_type: Option<CowStr<'c>>, 17 + #[serde(skip_serializing_if = "Option::is_none")] 18 pub grant_types: Option<Vec<CowStr<'c>>>, 19 #[serde(skip_serializing_if = "Option::is_none")] 20 pub token_endpoint_auth_method: Option<CowStr<'c>>, 21 + pub response_types: Vec<CowStr<'c>>, 22 // https://datatracker.ietf.org/doc/html/rfc9449#section-5.2 23 #[serde(skip_serializing_if = "Option::is_none")] 24 pub dpop_bound_access_tokens: Option<bool>, ··· 51 client_uri: self.client_uri.into_static(), 52 redirect_uris: self.redirect_uris.into_static(), 53 scope: self.scope.map(|scope| scope.into_static()), 54 + application_type: self.application_type.map(|app_type| app_type.into_static()), 55 grant_types: self.grant_types.map(|types| types.into_static()), 56 + response_types: self.response_types.into_static(), 57 token_endpoint_auth_method: self 58 .token_endpoint_auth_method 59 .map(|method| method.into_static()),
+2 -1
crates/jacquard/Cargo.toml
··· 12 license.workspace = true 13 14 [features] 15 - default = ["api_full", "dns", "loopback", "derive"] 16 derive = ["dep:jacquard-derive"] 17 # Minimal API bindings 18 api = ["jacquard-api/minimal"] ··· 44 "dep:n0-future", 45 "jacquard-api/streaming", 46 ] 47 websocket = ["jacquard-common/websocket"] 48 zstd = ["jacquard-common/zstd"] 49
··· 12 license.workspace = true 13 14 [features] 15 + default = ["api_full", "dns", "loopback", "derive", "cache"] 16 derive = ["dep:jacquard-derive"] 17 # Minimal API bindings 18 api = ["jacquard-api/minimal"] ··· 44 "dep:n0-future", 45 "jacquard-api/streaming", 46 ] 47 + cache = ["jacquard-identity/cache"] 48 websocket = ["jacquard-common/websocket"] 49 zstd = ["jacquard-common/zstd"] 50