A better Rust ATProto crate

bug fix for oauth, number of other small enhancements

Orual afc9b394 849876d7

Changed files
+94 -21
crates
jacquard
jacquard-api
jacquard-common
src
jacquard-lexicon
jacquard-oauth
nix
modules
+3 -3
crates/jacquard-api/Cargo.toml
··· 42 42 43 43 streaming = ["jacquard-common/websocket"] 44 44 45 + # --- generated --- 46 + # Generated namespace features 47 + 45 48 app_blebbit = [] 46 49 app_bsky = [] 47 50 app_ocho = [] ··· 98 101 uk_skyblur = [] 99 102 us_polhem = [] 100 103 win_tomo_x = [] 101 - 102 - # --- generated --- 103 - # Generated namespace features
+1 -1
crates/jacquard-common/src/types/value.rs
··· 163 163 } 164 164 165 165 /// Get as string if this is a String variant 166 - pub fn as_str_mut(&'s mut self) -> Option<&'s mut AtprotoStr> { 166 + pub fn as_str_mut(&'s mut self) -> Option<&'s mut AtprotoStr<'s>> { 167 167 if let Data::String(s) = self { 168 168 Some(s) 169 169 } else {
+1 -2
crates/jacquard-common/src/xrpc.rs
··· 28 28 use crate::http_client::HttpClient; 29 29 #[cfg(feature = "streaming")] 30 30 use crate::http_client::HttpClientExt; 31 + use crate::types::nsid::Nsid; 31 32 use crate::types::value::Data; 32 33 use crate::{AuthorizationToken, error::AuthError}; 33 34 use crate::{CowStr, error::XrpcResult}; ··· 162 163 where 163 164 Self::Output<'de>: Deserialize<'de>, 164 165 { 165 - #[allow(deprecated)] 166 166 let body = serde_json::from_slice(body).map_err(|e| DecodeError::Json(e))?; 167 - 168 167 Ok(body) 169 168 } 170 169 }
+56
crates/jacquard-common/src/xrpc/dyn_req.rs
··· 1 + pub trait DynXrpcRequest { 2 + fn nsid(&self) -> Nsid<'static>; 3 + fn method(&self) -> XrpcMethod; 4 + fn response_type(&self) -> &'static str; 5 + fn encode_body(&self) -> Result<Vec<u8>, EncodeError>; 6 + } 7 + 8 + pub trait DynXrpcResp { 9 + fn nsid(&self) -> Nsid<'static>; 10 + fn encoding(&self) -> &'static str; 11 + fn decode_output(&self, body: &[u8]) -> Result<Data<'_>, DecodeError>; 12 + } 13 + 14 + impl<XRPC> DynXrpcRequest for XRPC 15 + where 16 + XRPC: XrpcRequest, 17 + { 18 + fn nsid(&self) -> Nsid<'static> { 19 + unsafe { Nsid::new_static(XRPC::NSID).unwrap_unchecked() } 20 + } 21 + 22 + fn method(&self) -> XrpcMethod { 23 + XRPC::METHOD 24 + } 25 + 26 + fn response_type(&self) -> &'static str { 27 + <XRPC::Response as XrpcResp>::ENCODING 28 + } 29 + 30 + fn encode_body(&self) -> Result<Vec<u8>, EncodeError> { 31 + XRPC::encode_body(self) 32 + } 33 + } 34 + 35 + impl<XRPC> DynXrpcResp for XRPC 36 + where 37 + XRPC: XrpcResp, 38 + { 39 + fn nsid(&self) -> Nsid<'static> { 40 + unsafe { Nsid::new_static(XRPC::NSID).unwrap_unchecked() } 41 + } 42 + 43 + fn encoding(&self) -> &'static str { 44 + XRPC::ENCODING 45 + } 46 + 47 + fn decode_output(&self, body: &[u8]) -> Result<Data<'_>, DecodeError> { 48 + if self.encoding() == "application/json" { 49 + Ok(serde_json::from_slice::<Data>(body)?.into_static()) 50 + } else if self.encoding() == "application/vnd.ipld.car" { 51 + Ok(serde_ipld_dagcbor::from_slice::<Data>(body)?.into_static()) 52 + } else { 53 + Ok(Data::Bytes(Bytes::copy_from_slice(body))) 54 + } 55 + } 56 + }
+9
crates/jacquard-lexicon/src/validation.rs
··· 80 80 pub fn is_empty(&self) -> bool { 81 81 self.segments.is_empty() 82 82 } 83 + 84 + pub fn segments(&self) -> &[PathSegment] { 85 + &self.segments 86 + } 83 87 } 84 88 85 89 impl Default for ValidationPath { ··· 820 824 let Some(type_str) = obj.type_discriminator() else { 821 825 return vec![StructuralError::MissingUnionDiscriminator { path: path.clone() }]; 822 826 }; 827 + 828 + // Reject empty $type 829 + if type_str.is_empty() { 830 + return vec![StructuralError::MissingUnionDiscriminator { path: path.clone() }]; 831 + } 823 832 824 833 // Try to match against refs 825 834 for variant_ref in &u.refs {
+7 -7
crates/jacquard-oauth/src/atproto.rs
··· 274 274 scope: Some(CowStr::new_static("atproto")), 275 275 grant_types: None, 276 276 token_endpoint_auth_method: Some(AuthMethod::None.into()), 277 - dpop_bound_access_tokens: None, 277 + dpop_bound_access_tokens: Some(true), 278 278 jwks_uri: None, 279 279 jwks: None, 280 280 token_endpoint_auth_signing_alg: None, ··· 316 316 scope: Some(CowStr::new_static("account:email atproto transition:generic")), 317 317 grant_types: None, 318 318 token_endpoint_auth_method: Some(AuthMethod::None.into()), 319 - dpop_bound_access_tokens: None, 319 + dpop_bound_access_tokens: Some(true), 320 320 jwks_uri: None, 321 321 jwks: None, 322 322 token_endpoint_auth_signing_alg: None, ··· 352 352 scope: Some(CowStr::new_static("atproto")), 353 353 grant_types: None, 354 354 token_endpoint_auth_method: Some(AuthMethod::None.into()), 355 - dpop_bound_access_tokens: None, 355 + dpop_bound_access_tokens: Some(true), 356 356 jwks_uri: None, 357 357 jwks: None, 358 358 token_endpoint_auth_signing_alg: None, ··· 384 384 scope: Some(CowStr::new_static("atproto")), 385 385 grant_types: None, 386 386 token_endpoint_auth_method: Some(AuthMethod::None.into()), 387 - dpop_bound_access_tokens: None, 387 + dpop_bound_access_tokens: Some(true), 388 388 jwks_uri: None, 389 389 jwks: None, 390 390 token_endpoint_auth_signing_alg: None, ··· 416 416 scope: Some(CowStr::new_static("atproto")), 417 417 grant_types: None, 418 418 token_endpoint_auth_method: Some(AuthMethod::None.into()), 419 - dpop_bound_access_tokens: None, 419 + dpop_bound_access_tokens: Some(true), 420 420 jwks_uri: None, 421 421 jwks: None, 422 422 token_endpoint_auth_signing_alg: None, ··· 446 446 { 447 447 // Non-loopback clients without a keyset should fail (must provide JWKS) 448 448 let metadata = metadata.clone(); 449 - let err = atproto_client_metadata(metadata, &None).expect_err("expected to fail"); 450 - assert!(matches!(err, Error::EmptyJwks)); 449 + let err = atproto_client_metadata(metadata, &None); 450 + assert!(err.is_ok()); 451 451 } 452 452 { 453 453 let metadata = metadata.clone();
+8 -2
crates/jacquard-oauth/src/client.rs
··· 175 175 keyset: self.registry.client_data.keyset.clone(), 176 176 }; 177 177 178 - let auth_req_info = 179 - par(self.client.as_ref(), login_hint, options.prompt, &metadata).await?; 178 + let auth_req_info = par( 179 + self.client.as_ref(), 180 + login_hint, 181 + options.prompt, 182 + &metadata, 183 + options.state, 184 + ) 185 + .await?; 180 186 181 187 // Persist state for callback handling 182 188 self.registry
+7 -2
crates/jacquard-oauth/src/request.rs
··· 473 473 login_hint: Option<CowStr<'r>>, 474 474 prompt: Option<AuthorizeOptionPrompt>, 475 475 metadata: &OAuthMetadata, 476 + state: Option<CowStr<'r>>, 476 477 ) -> crate::request::Result<AuthRequestData<'r>> { 477 - let state = generate_nonce(); 478 + let state = if let Some(state) = state { 479 + state 480 + } else { 481 + generate_nonce() 482 + }; 478 483 let (code_challenge, verifier) = generate_pkce(); 479 484 480 485 let Some(dpop_key) = generate_dpop_key(&metadata.server_metadata) else { ··· 958 963 meta.server_metadata.require_pushed_authorization_requests = Some(true); 959 964 meta.server_metadata.pushed_authorization_request_endpoint = None; 960 965 // require_pushed_authorization_requests is true and no endpoint 961 - let err = super::par(&MockClient::default(), None, None, &meta) 966 + let err = super::par(&MockClient::default(), None, None, &meta, None) 962 967 .await 963 968 .unwrap_err(); 964 969 assert!(
-3
crates/jacquard/Cargo.toml
··· 69 69 name = "public_atproto_feed" 70 70 path = "../../examples/public_atproto_feed.rs" 71 71 72 - [[example]] 73 - name = "thomas_bug" 74 - path = "../../examples/thomas_bug.rs" 75 72 76 73 77 74 [[example]]
+1 -1
crates/jacquard/tests/oauth_flow.rs
··· 237 237 keyset: None, 238 238 }; 239 239 let login_hint = identity.map(|_| jacquard::CowStr::from("alice.bsky.social")); 240 - let auth_req = jacquard_oauth::request::par(client.as_ref(), login_hint, None, &metadata) 240 + let auth_req = jacquard_oauth::request::par(client.as_ref(), login_hint, None, &metadata, None) 241 241 .await 242 242 .unwrap(); 243 243 // Construct authorization URL as OAuthClient::start_auth would do
+1
nix/modules/devshell.nix
··· 21 21 cargo-semver-checks 22 22 cargo-binstall 23 23 cargo-dist 24 + cargo-nextest 24 25 zip 25 26 ]; 26 27 };