A library for ATProtocol identities.

refactor: atproto-client-dpop supports query and procedure methods

Signed-off-by: Nick Gerakines <nick.gerakines@gmail.com>

Changed files
+135 -14
crates
atproto-client
+70 -14
crates/atproto-client/src/bin/atproto-client-dpop.rs
··· 4 4 //! subjects to DID documents and constructing authenticated requests. 5 5 6 6 use anyhow::Result; 7 - use atproto_client::client::{DPoPAuth, get_dpop_json_with_headers}; 7 + use atproto_client::client::{DPoPAuth, get_dpop_json_with_headers, post_dpop_json_with_headers}; 8 8 use atproto_identity::{ 9 9 config::{CertificateBundles, DnsNameservers, default_env, optional_env, version}, 10 10 key::identify_key, ··· 20 20 println!(); 21 21 println!("Usage:"); 22 22 println!( 23 - " atproto-client-dpop <subject> <private_dpop_key> <access_token> <xrpc_path> [key=value]..." 23 + " atproto-client-dpop <subject> <private_dpop_key> <access_token> <xrpc_path> [args...]" 24 24 ); 25 25 println!(); 26 26 println!("Arguments:"); 27 27 println!(" subject Subject identifier to resolve"); 28 28 println!(" private_dpop_key Private DPoP key for authentication"); 29 29 println!(" access_token OAuth access token"); 30 - println!(" xrpc_path XRPC path (e.g., com.atproto.repo.listRecords)"); 30 + println!(" xrpc_path XRPC path with optional prefix:"); 31 + println!(" - query:<path> for GET requests (default)"); 32 + println!(" - procedure:<path> for POST requests"); 33 + println!(" - <path> defaults to GET request"); 31 34 println!( 32 - " key=value Additional query parameters 33 - header=name=value Additional HTTP headers" 35 + " key=value Additional query parameters (for GET requests) 36 + header=name=value Additional HTTP headers 37 + <file_path> JSON file path (required for procedure: prefix)" 34 38 ); 35 39 println!(); 36 - println!("Example:"); 40 + println!("Examples:"); 37 41 println!( 38 - " atproto-client-dpop alice.bsky.social dpop.pem token123 com.atproto.repo.listRecords repo=alice.bsky.social collection=app.bsky.feed.post header=X-Custom-Header=value" 42 + " # GET request (default behavior without prefix)" 43 + ); 44 + println!( 45 + " atproto-client-dpop alice.bsky.social dpop.pem token123 com.atproto.repo.listRecords repo=alice.bsky.social collection=app.bsky.feed.post" 46 + ); 47 + println!( 48 + " # GET request (explicit query: prefix)" 49 + ); 50 + println!( 51 + " atproto-client-dpop alice.bsky.social dpop.pem token123 query:com.atproto.repo.listRecords repo=alice.bsky.social collection=app.bsky.feed.post" 52 + ); 53 + println!( 54 + " # POST request (requires procedure: prefix and JSON file)" 55 + ); 56 + println!( 57 + " atproto-client-dpop alice.bsky.social dpop.pem token123 procedure:com.atproto.repo.createRecord data.json" 39 58 ); 40 59 } 41 60 ··· 52 71 let subject = &args[0]; 53 72 let private_dpop_key = &args[1]; 54 73 let access_token = &args[2]; 55 - let xrpc_path = &args[3]; 74 + let xrpc_path_with_prefix = &args[3]; 56 75 57 - // Parse additional key=value arguments and header=name=value arguments 76 + // Parse the xrpc_path prefix (optional, defaults to query:) 77 + let (is_procedure, xrpc_path) = if let Some(path) = xrpc_path_with_prefix.strip_prefix("query:") { 78 + (false, path) 79 + } else if let Some(path) = xrpc_path_with_prefix.strip_prefix("procedure:") { 80 + (true, path) 81 + } else { 82 + // Default to query if no prefix is provided 83 + (false, xrpc_path_with_prefix.as_str()) 84 + }; 85 + 86 + // Parse additional arguments based on request type 58 87 let mut query_params = HashMap::new(); 59 88 let mut header_params = HashMap::new(); 60 - for arg in &args[4..] { 89 + let mut json_data: Option<serde_json::Value> = None; 90 + let mut arg_index = 4; 91 + 92 + // For procedure calls, expect the next argument to be a file path 93 + if is_procedure { 94 + if arg_index >= args.len() { 95 + anyhow::bail!("procedure: prefix requires a JSON file path as the next argument"); 96 + } 97 + let file_path = &args[arg_index]; 98 + let file_content = std::fs::read_to_string(file_path) 99 + .map_err(|e| anyhow::anyhow!("Failed to read file '{}': {}", file_path, e))?; 100 + json_data = Some(serde_json::from_str(&file_content) 101 + .map_err(|e| anyhow::anyhow!("Failed to parse JSON from file '{}': {}", file_path, e))?); 102 + arg_index += 1; 103 + } 104 + 105 + // Parse remaining key=value arguments and header=name=value arguments 106 + for arg in &args[arg_index..] { 61 107 if let Some((key, value)) = arg.split_once('=') { 62 108 if key == "header" { 63 109 // Parse header=name=value format ··· 67 113 eprintln!("Warning: Ignoring invalid header format: {}", arg); 68 114 eprintln!("Expected format: header=name=value"); 69 115 } 116 + } else if is_procedure { 117 + eprintln!("Warning: Query parameters are not supported for procedure calls. Ignoring: {}", arg); 70 118 } else { 71 119 query_params.insert(key.to_string(), value.to_string()); 72 120 } ··· 78 126 79 127 println!("Making DPoP authenticated XRPC call"); 80 128 println!("Subject: {}", subject); 129 + println!("Request Type: {}", if is_procedure { "POST (procedure)" } else { "GET (query)" }); 81 130 println!("XRPC Path: {}", xrpc_path); 82 131 if !query_params.is_empty() { 83 132 println!("Query Parameters: {:?}", query_params); 84 133 } 85 134 if !header_params.is_empty() { 86 135 println!("Additional Headers: {:?}", header_params); 136 + } 137 + if let Some(ref data) = json_data { 138 + println!("JSON Data: {}", serde_json::to_string_pretty(data)?); 87 139 } 88 140 89 141 // Set up HTTP client configuration ··· 140 192 // Construct the URL 141 193 let mut url = format!("{}/xrpc/{}", pds_endpoint, xrpc_path); 142 194 143 - // Add query parameters if any 144 - if !query_params.is_empty() { 195 + // Add query parameters if any (only for GET requests) 196 + if !is_procedure && !query_params.is_empty() { 145 197 let query_string: Vec<String> = query_params 146 198 .iter() 147 199 .map(|(k, v)| format!("{}={}", urlencoding::encode(k), urlencoding::encode(v))) ··· 175 227 // Make the authenticated request 176 228 println!("Making DPoP authenticated request..."); 177 229 178 - let response = 179 - get_dpop_json_with_headers(&http_client, &dpop_auth, &url, &additional_headers).await?; 230 + let response = if is_procedure { 231 + let data = json_data.ok_or_else(|| anyhow::anyhow!("No JSON data provided for procedure call"))?; 232 + post_dpop_json_with_headers(&http_client, &dpop_auth, &url, data, &additional_headers).await? 233 + } else { 234 + get_dpop_json_with_headers(&http_client, &dpop_auth, &url, &additional_headers).await? 235 + }; 180 236 181 237 // Print the response 182 238 println!("Response:");
+65
crates/atproto-client/src/client.rs
··· 195 195 url: &str, 196 196 record: serde_json::Value, 197 197 ) -> Result<serde_json::Value> { 198 + let empty = HeaderMap::default(); 199 + post_dpop_json_with_headers(http_client, dpop_auth, url, record, &empty).await 200 + } 201 + 202 + /// Performs a DPoP-authenticated HTTP POST request with JSON body and additional headers, and parses the response as JSON. 203 + /// 204 + /// This function extends `post_dpop_json` by allowing custom headers to be included 205 + /// in the request. Useful for adding custom content types, user agents, or other 206 + /// protocol-specific headers while maintaining DPoP authentication. 207 + /// 208 + /// # Arguments 209 + /// 210 + /// * `http_client` - The HTTP client to use for the request 211 + /// * `dpop_auth` - DPoP authentication credentials 212 + /// * `url` - The URL to request 213 + /// * `record` - The JSON data to send in the request body 214 + /// * `additional_headers` - Additional HTTP headers to include in the request 215 + /// 216 + /// # Returns 217 + /// 218 + /// The parsed JSON response as a `serde_json::Value` 219 + /// 220 + /// # Errors 221 + /// 222 + /// Returns `DPoPError::ProofGenerationFailed` if DPoP proof generation fails, 223 + /// `DPoPError::HttpRequestFailed` if the HTTP request fails, 224 + /// or `DPoPError::JsonParseFailed` if JSON parsing fails. 225 + /// 226 + /// # Example 227 + /// 228 + /// ```no_run 229 + /// use atproto_client::client::{DPoPAuth, post_dpop_json_with_headers}; 230 + /// use atproto_identity::key::identify_key; 231 + /// use reqwest::{Client, header::{HeaderMap, USER_AGENT}}; 232 + /// use serde_json::json; 233 + /// 234 + /// # async fn example() -> anyhow::Result<()> { 235 + /// let client = Client::new(); 236 + /// let dpop_auth = DPoPAuth { 237 + /// dpop_private_key_data: identify_key("did:key:zQ3sh...")?, 238 + /// oauth_access_token: "access_token".to_string(), 239 + /// oauth_issuer: "did:plc:issuer123".to_string(), 240 + /// }; 241 + /// 242 + /// let mut headers = HeaderMap::new(); 243 + /// headers.insert(USER_AGENT, "my-app/1.0".parse()?); 244 + /// 245 + /// let response = post_dpop_json_with_headers( 246 + /// &client, 247 + /// &dpop_auth, 248 + /// "https://pds.example.com/xrpc/com.atproto.repo.createRecord", 249 + /// json!({"$type": "app.bsky.feed.post", "text": "Hello!"}), 250 + /// &headers 251 + /// ).await?; 252 + /// # Ok(()) 253 + /// # } 254 + /// ``` 255 + pub async fn post_dpop_json_with_headers( 256 + http_client: &reqwest::Client, 257 + dpop_auth: &DPoPAuth, 258 + url: &str, 259 + record: serde_json::Value, 260 + additional_headers: &HeaderMap, 261 + ) -> Result<serde_json::Value> { 198 262 let (dpop_proof_token, dpop_proof_header, dpop_proof_claim) = request_dpop( 199 263 &dpop_auth.dpop_private_key_data, 200 264 "POST", ··· 217 281 218 282 let http_response = dpop_retry_client 219 283 .post(url) 284 + .headers(additional_headers.clone()) 220 285 .header( 221 286 "Authorization", 222 287 &format!("DPoP {}", dpop_auth.oauth_access_token),