+70
-14
crates/atproto-client/src/bin/atproto-client-dpop.rs
+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
+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),