atproto_client/client.rs
1//! HTTP client operations with DPoP authentication support.
2//!
3//! Authenticated and unauthenticated HTTP requests for JSON APIs
4//! with DPoP (Demonstration of Proof-of-Possession) support.
5
6use crate::errors::{ClientError, DPoPError};
7use anyhow::Result;
8use atproto_identity::key::KeyData;
9use atproto_oauth::dpop::{DpopRetry, request_dpop};
10use bytes::Bytes;
11use reqwest::header::HeaderMap;
12use reqwest_chain::ChainMiddleware;
13use reqwest_middleware::ClientBuilder;
14use tracing::Instrument;
15
16/// DPoP authentication credentials for authenticated HTTP requests.
17///
18/// Contains the private key for DPoP proof generation and OAuth access token
19/// for Authorization header.
20pub struct DPoPAuth {
21 /// Private key data for generating DPoP proof tokens
22 pub dpop_private_key_data: KeyData,
23 /// OAuth access token for the Authorization header
24 pub oauth_access_token: String,
25}
26
27/// App password authentication credentials for authenticated HTTP requests.
28///
29/// Contains the JWT access token for Bearer token authentication.
30pub struct AppPasswordAuth {
31 /// JWT access token for the Authorization header
32 pub access_token: String,
33}
34
35/// Authentication method for AT Protocol XRPC requests.
36///
37/// Supports multiple authentication schemes including unauthenticated requests,
38/// DPoP (Demonstration of Proof-of-Possession) tokens, and app password bearer tokens.
39pub enum Auth {
40 /// No authentication - for public endpoints that don't require authentication
41 None,
42 /// DPoP authentication with proof-of-possession tokens and OAuth access token
43 DPoP(DPoPAuth),
44 /// App password authentication using JWT bearer tokens
45 AppPassword(AppPasswordAuth),
46}
47
48/// Performs an unauthenticated HTTP GET request and parses the response as JSON.
49///
50/// # Arguments
51///
52/// * `http_client` - The HTTP client to use for the request
53/// * `url` - The URL to request
54///
55/// # Returns
56///
57/// The parsed JSON response as a `serde_json::Value`
58///
59/// # Errors
60///
61/// Returns `ClientError::HttpRequestFailed` if the HTTP request fails,
62/// or `ClientError::JsonParseFailed` if JSON parsing fails.
63pub async fn get_json(http_client: &reqwest::Client, url: &str) -> Result<serde_json::Value> {
64 let empty = HeaderMap::default();
65 get_json_with_headers(http_client, url, &empty).await
66}
67
68/// Performs an unauthenticated HTTP GET request with additional headers and parses the response as JSON.
69///
70/// # Arguments
71///
72/// * `http_client` - The HTTP client to use for the request
73/// * `url` - The URL to request
74/// * `additional_headers` - Additional HTTP headers to include in the request
75///
76/// # Returns
77///
78/// The parsed JSON response as a `serde_json::Value`
79///
80/// # Errors
81///
82/// Returns `ClientError::HttpRequestFailed` if the HTTP request fails,
83/// or `ClientError::JsonParseFailed` if JSON parsing fails.
84pub async fn get_json_with_headers(
85 http_client: &reqwest::Client,
86 url: &str,
87 additional_headers: &HeaderMap,
88) -> Result<serde_json::Value> {
89 let http_response = http_client
90 .get(url)
91 .headers(additional_headers.clone())
92 .send()
93 .await
94 .map_err(|error| ClientError::HttpRequestFailed {
95 url: url.to_string(),
96 error,
97 })?;
98
99 let value = http_response
100 .json::<serde_json::Value>()
101 .await
102 .map_err(|error| ClientError::JsonParseFailed {
103 url: url.to_string(),
104 error,
105 })?;
106
107 Ok(value)
108}
109
110/// Performs an unauthenticated HTTP GET request and returns the response as bytes.
111///
112/// # Arguments
113///
114/// * `http_client` - The HTTP client to use for the request
115/// * `url` - The URL to request
116///
117/// # Returns
118///
119/// The response body as bytes
120///
121/// # Errors
122///
123/// Returns `ClientError::HttpRequestFailed` if the HTTP request fails,
124/// or an error if streaming the response bytes fails.
125pub async fn get_bytes(http_client: &reqwest::Client, url: &str) -> Result<Bytes> {
126 let empty = HeaderMap::default();
127 get_bytes_with_headers(http_client, url, &empty).await
128}
129
130/// Performs an unauthenticated HTTP GET request with additional headers and returns the response as bytes.
131///
132/// # Arguments
133///
134/// * `http_client` - The HTTP client to use for the request
135/// * `url` - The URL to request
136/// * `additional_headers` - Additional HTTP headers to include in the request
137///
138/// # Returns
139///
140/// The response body as bytes
141///
142/// # Errors
143///
144/// Returns `ClientError::HttpRequestFailed` if the HTTP request fails,
145/// or an error if streaming the response bytes fails.
146pub async fn get_bytes_with_headers(
147 http_client: &reqwest::Client,
148 url: &str,
149 additional_headers: &HeaderMap,
150) -> Result<Bytes> {
151 let http_response = http_client
152 .get(url)
153 .headers(additional_headers.clone())
154 .send()
155 .await
156 .map_err(|error| ClientError::HttpRequestFailed {
157 url: url.to_string(),
158 error,
159 })?;
160 Ok(http_response
161 .bytes()
162 .await
163 .map_err(|error| ClientError::ByteStreamFailed {
164 url: url.to_string(),
165 error,
166 })?)
167}
168
169/// Performs a DPoP-authenticated HTTP GET request and parses the response as JSON.
170///
171/// # Arguments
172///
173/// * `http_client` - The HTTP client to use for the request
174/// * `dpop_auth` - DPoP authentication credentials
175/// * `url` - The URL to request
176///
177/// # Returns
178///
179/// The parsed JSON response as a `serde_json::Value`
180///
181/// # Errors
182///
183/// Returns `DPoPError::ProofGenerationFailed` if DPoP proof generation fails,
184/// `DPoPError::HttpRequestFailed` if the HTTP request fails,
185/// or `DPoPError::JsonParseFailed` if JSON parsing fails.
186pub async fn get_dpop_json(
187 http_client: &reqwest::Client,
188 dpop_auth: &DPoPAuth,
189 url: &str,
190) -> Result<serde_json::Value> {
191 let empty = HeaderMap::default();
192 get_dpop_json_with_headers(http_client, dpop_auth, url, &empty).await
193}
194
195/// Performs a DPoP-authenticated HTTP GET request with additional headers and parses the response as JSON.
196///
197/// # Arguments
198///
199/// * `http_client` - The HTTP client to use for the request
200/// * `dpop_auth` - DPoP authentication credentials
201/// * `url` - The URL to request
202/// * `additional_headers` - Additional HTTP headers to include in the request
203///
204/// # Returns
205///
206/// The parsed JSON response as a `serde_json::Value`
207///
208/// # Errors
209///
210/// Returns `DPoPError::ProofGenerationFailed` if DPoP proof generation fails,
211/// `DPoPError::HttpRequestFailed` if the HTTP request fails,
212/// or `DPoPError::JsonParseFailed` if JSON parsing fails.
213pub async fn get_dpop_json_with_headers(
214 http_client: &reqwest::Client,
215 dpop_auth: &DPoPAuth,
216 url: &str,
217 additional_headers: &HeaderMap,
218) -> Result<serde_json::Value> {
219 let (dpop_proof_token, dpop_proof_header, dpop_proof_claim) = request_dpop(
220 &dpop_auth.dpop_private_key_data,
221 "GET",
222 url,
223 &dpop_auth.oauth_access_token,
224 )
225 .map_err(|error| DPoPError::ProofGenerationFailed { error })?;
226
227 let dpop_retry = DpopRetry::new(
228 dpop_proof_header.clone(),
229 dpop_proof_claim.clone(),
230 dpop_auth.dpop_private_key_data.clone(),
231 true,
232 );
233
234 let dpop_retry_client = ClientBuilder::new(http_client.clone())
235 .with(ChainMiddleware::new(dpop_retry.clone()))
236 .build();
237
238 let http_response = dpop_retry_client
239 .get(url)
240 .headers(additional_headers.clone())
241 .header(
242 "Authorization",
243 &format!("DPoP {}", dpop_auth.oauth_access_token),
244 )
245 .header("DPoP", &dpop_proof_token)
246 .send()
247 .await
248 .map_err(|error| DPoPError::HttpRequestFailed {
249 url: url.to_string(),
250 error,
251 })?;
252
253 let value = http_response
254 .json::<serde_json::Value>()
255 .await
256 .map_err(|error| DPoPError::JsonParseFailed {
257 url: url.to_string(),
258 error,
259 })?;
260
261 Ok(value)
262}
263
264/// Performs a DPoP-authenticated HTTP POST request with JSON body and parses the response as JSON.
265///
266/// # Arguments
267///
268/// * `http_client` - The HTTP client to use for the request
269/// * `dpop_auth` - DPoP authentication credentials
270/// * `url` - The URL to request
271/// * `record` - The JSON data to send in the request body
272///
273/// # Returns
274///
275/// The parsed JSON response as a `serde_json::Value`
276///
277/// # Errors
278///
279/// Returns `DPoPError::ProofGenerationFailed` if DPoP proof generation fails,
280/// `DPoPError::HttpRequestFailed` if the HTTP request fails,
281/// or `DPoPError::JsonParseFailed` if JSON parsing fails.
282pub async fn post_dpop_json(
283 http_client: &reqwest::Client,
284 dpop_auth: &DPoPAuth,
285 url: &str,
286 record: serde_json::Value,
287) -> Result<serde_json::Value> {
288 let empty = HeaderMap::default();
289 post_dpop_json_with_headers(http_client, dpop_auth, url, record, &empty).await
290}
291
292/// Performs a DPoP-authenticated HTTP POST request with JSON body and additional headers, and parses the response as JSON.
293///
294/// This function extends `post_dpop_json` by allowing custom headers to be included
295/// in the request. Useful for adding custom content types, user agents, or other
296/// protocol-specific headers while maintaining DPoP authentication.
297///
298/// # Arguments
299///
300/// * `http_client` - The HTTP client to use for the request
301/// * `dpop_auth` - DPoP authentication credentials
302/// * `url` - The URL to request
303/// * `record` - The JSON data to send in the request body
304/// * `additional_headers` - Additional HTTP headers to include in the request
305///
306/// # Returns
307///
308/// The parsed JSON response as a `serde_json::Value`
309///
310/// # Errors
311///
312/// Returns `DPoPError::ProofGenerationFailed` if DPoP proof generation fails,
313/// `DPoPError::HttpRequestFailed` if the HTTP request fails,
314/// or `DPoPError::JsonParseFailed` if JSON parsing fails.
315///
316/// # Example
317///
318/// ```no_run
319/// use atproto_client::client::{DPoPAuth, post_dpop_json_with_headers};
320/// use atproto_identity::key::identify_key;
321/// use reqwest::{Client, header::{HeaderMap, USER_AGENT}};
322/// use serde_json::json;
323///
324/// # async fn example() -> anyhow::Result<()> {
325/// let client = Client::new();
326/// let dpop_auth = DPoPAuth {
327/// dpop_private_key_data: identify_key("did:key:zQ3sh...")?,
328/// oauth_access_token: "access_token".to_string(),
329/// };
330///
331/// let mut headers = HeaderMap::new();
332/// headers.insert(USER_AGENT, "my-app/1.0".parse()?);
333///
334/// let response = post_dpop_json_with_headers(
335/// &client,
336/// &dpop_auth,
337/// "https://pds.example.com/xrpc/com.atproto.repo.createRecord",
338/// json!({"$type": "app.bsky.feed.post", "text": "Hello!"}),
339/// &headers
340/// ).await?;
341/// # Ok(())
342/// # }
343/// ```
344pub async fn post_dpop_json_with_headers(
345 http_client: &reqwest::Client,
346 dpop_auth: &DPoPAuth,
347 url: &str,
348 record: serde_json::Value,
349 additional_headers: &HeaderMap,
350) -> Result<serde_json::Value> {
351 let (dpop_proof_token, dpop_proof_header, dpop_proof_claim) = request_dpop(
352 &dpop_auth.dpop_private_key_data,
353 "POST",
354 url,
355 &dpop_auth.oauth_access_token,
356 )
357 .map_err(|error| DPoPError::ProofGenerationFailed { error })?;
358
359 let dpop_retry = DpopRetry::new(
360 dpop_proof_header.clone(),
361 dpop_proof_claim.clone(),
362 dpop_auth.dpop_private_key_data.clone(),
363 true,
364 );
365
366 let dpop_retry_client = ClientBuilder::new(http_client.clone())
367 .with(ChainMiddleware::new(dpop_retry.clone()))
368 .build();
369
370 let http_response = dpop_retry_client
371 .post(url)
372 .headers(additional_headers.clone())
373 .header(
374 "Authorization",
375 &format!("DPoP {}", dpop_auth.oauth_access_token),
376 )
377 .header("DPoP", &dpop_proof_token)
378 .json(&record)
379 .send()
380 .await
381 .map_err(|error| DPoPError::HttpRequestFailed {
382 url: url.to_string(),
383 error,
384 })?;
385
386 let value = http_response
387 .json::<serde_json::Value>()
388 .await
389 .map_err(|error| DPoPError::JsonParseFailed {
390 url: url.to_string(),
391 error,
392 })?;
393
394 Ok(value)
395}
396
397/// Performs an unauthenticated HTTP POST request with JSON body and parses the response as JSON.
398///
399/// # Arguments
400///
401/// * `http_client` - The HTTP client to use for the request
402/// * `url` - The URL to request
403/// * `data` - The JSON data to send in the request body
404///
405/// # Returns
406///
407/// The parsed JSON response as a `serde_json::Value`
408///
409/// # Errors
410///
411/// Returns `ClientError::HttpRequestFailed` if the HTTP request fails,
412/// or `ClientError::JsonParseFailed` if JSON parsing fails.
413pub async fn post_json(
414 http_client: &reqwest::Client,
415 url: &str,
416 data: serde_json::Value,
417) -> Result<serde_json::Value> {
418 let empty = HeaderMap::default();
419 post_json_with_headers(http_client, url, data, &empty).await
420}
421
422/// Performs an unauthenticated HTTP POST request with JSON body and additional headers, and parses the response as JSON.
423///
424/// # Arguments
425///
426/// * `http_client` - The HTTP client to use for the request
427/// * `url` - The URL to request
428/// * `data` - The JSON data to send in the request body
429/// * `additional_headers` - Additional HTTP headers to include in the request
430///
431/// # Returns
432///
433/// The parsed JSON response as a `serde_json::Value`
434///
435/// # Errors
436///
437/// Returns `ClientError::HttpRequestFailed` if the HTTP request fails,
438/// or `ClientError::JsonParseFailed` if JSON parsing fails.
439pub async fn post_json_with_headers(
440 http_client: &reqwest::Client,
441 url: &str,
442 data: serde_json::Value,
443 additional_headers: &HeaderMap,
444) -> Result<serde_json::Value> {
445 let http_response = http_client
446 .post(url)
447 .headers(additional_headers.clone())
448 .json(&data)
449 .send()
450 .await
451 .map_err(|error| ClientError::HttpRequestFailed {
452 url: url.to_string(),
453 error,
454 })?;
455
456 let value = http_response
457 .json::<serde_json::Value>()
458 .await
459 .map_err(|error| ClientError::JsonParseFailed {
460 url: url.to_string(),
461 error,
462 })?;
463
464 Ok(value)
465}
466
467/// Performs an app password-authenticated HTTP GET request and parses the response as JSON.
468///
469/// # Arguments
470///
471/// * `http_client` - The HTTP client to use for the request
472/// * `app_auth` - App password authentication credentials
473/// * `url` - The URL to request
474///
475/// # Returns
476///
477/// The parsed JSON response as a `serde_json::Value`
478///
479/// # Errors
480///
481/// Returns `ClientError::HttpRequestFailed` if the HTTP request fails,
482/// or `ClientError::JsonParseFailed` if JSON parsing fails.
483pub async fn get_apppassword_json(
484 http_client: &reqwest::Client,
485 app_auth: &AppPasswordAuth,
486 url: &str,
487) -> Result<serde_json::Value> {
488 let empty = HeaderMap::default();
489 get_apppassword_json_with_headers(http_client, app_auth, url, &empty).await
490}
491
492/// Performs an app password-authenticated HTTP GET request with additional headers and parses the response as JSON.
493///
494/// # Arguments
495///
496/// * `http_client` - The HTTP client to use for the request
497/// * `app_auth` - App password authentication credentials
498/// * `url` - The URL to request
499/// * `additional_headers` - Additional HTTP headers to include in the request
500///
501/// # Returns
502///
503/// The parsed JSON response as a `serde_json::Value`
504///
505/// # Errors
506///
507/// Returns `ClientError::HttpRequestFailed` if the HTTP request fails,
508/// or `ClientError::JsonParseFailed` if JSON parsing fails.
509pub async fn get_apppassword_json_with_headers(
510 http_client: &reqwest::Client,
511 app_auth: &AppPasswordAuth,
512 url: &str,
513 additional_headers: &HeaderMap,
514) -> Result<serde_json::Value> {
515 let mut headers = additional_headers.clone();
516 headers.insert(
517 reqwest::header::AUTHORIZATION,
518 reqwest::header::HeaderValue::from_str(&format!("Bearer {}", app_auth.access_token))?,
519 );
520
521 let http_response = http_client
522 .get(url)
523 .headers(headers)
524 .send()
525 .await
526 .map_err(|error| ClientError::HttpRequestFailed {
527 url: url.to_string(),
528 error,
529 })?;
530
531 let value = http_response
532 .json::<serde_json::Value>()
533 .await
534 .map_err(|error| ClientError::JsonParseFailed {
535 url: url.to_string(),
536 error,
537 })?;
538
539 Ok(value)
540}
541
542/// Performs an app password-authenticated HTTP POST request with JSON body and parses the response as JSON.
543///
544/// # Arguments
545///
546/// * `http_client` - The HTTP client to use for the request
547/// * `app_auth` - App password authentication credentials
548/// * `url` - The URL to request
549/// * `data` - The JSON data to send in the request body
550///
551/// # Returns
552///
553/// The parsed JSON response as a `serde_json::Value`
554///
555/// # Errors
556///
557/// Returns `ClientError::HttpRequestFailed` if the HTTP request fails,
558/// or `ClientError::JsonParseFailed` if JSON parsing fails.
559pub async fn post_apppassword_json(
560 http_client: &reqwest::Client,
561 app_auth: &AppPasswordAuth,
562 url: &str,
563 data: serde_json::Value,
564) -> Result<serde_json::Value> {
565 let empty = HeaderMap::default();
566 post_apppassword_json_with_headers(http_client, app_auth, url, data, &empty).await
567}
568
569/// Performs an app password-authenticated HTTP POST request with JSON body and additional headers, and parses the response as JSON.
570///
571/// # Arguments
572///
573/// * `http_client` - The HTTP client to use for the request
574/// * `app_auth` - App password authentication credentials
575/// * `url` - The URL to request
576/// * `data` - The JSON data to send in the request body
577/// * `additional_headers` - Additional HTTP headers to include in the request
578///
579/// # Returns
580///
581/// The parsed JSON response as a `serde_json::Value`
582///
583/// # Errors
584///
585/// Returns `ClientError::HttpRequestFailed` if the HTTP request fails,
586/// or `ClientError::JsonParseFailed` if JSON parsing fails.
587pub async fn post_apppassword_json_with_headers(
588 http_client: &reqwest::Client,
589 app_auth: &AppPasswordAuth,
590 url: &str,
591 data: serde_json::Value,
592 additional_headers: &HeaderMap,
593) -> Result<serde_json::Value> {
594 let mut headers = additional_headers.clone();
595 headers.insert(
596 reqwest::header::AUTHORIZATION,
597 reqwest::header::HeaderValue::from_str(&format!("Bearer {}", app_auth.access_token))?,
598 );
599
600 let http_response = http_client
601 .post(url)
602 .headers(headers)
603 .json(&data)
604 .send()
605 .await
606 .map_err(|error| ClientError::HttpRequestFailed {
607 url: url.to_string(),
608 error,
609 })?;
610
611 let value = http_response
612 .json::<serde_json::Value>()
613 .await
614 .map_err(|error| ClientError::JsonParseFailed {
615 url: url.to_string(),
616 error,
617 })?;
618
619 Ok(value)
620}
621
622/// Performs an app password-authenticated HTTP GET request and returns the response as bytes.
623///
624/// # Arguments
625///
626/// * `http_client` - The HTTP client to use for the request
627/// * `app_auth` - App password authentication credentials
628/// * `url` - The URL to request
629/// * `additional_headers` - Additional HTTP headers to include in the request
630///
631/// # Returns
632///
633/// The response body as bytes
634///
635/// # Errors
636///
637/// Returns `ClientError::HttpRequestFailed` if the HTTP request fails,
638/// or an error if streaming the response bytes fails.
639pub async fn get_apppassword_bytes_with_headers(
640 http_client: &reqwest::Client,
641 app_auth: &AppPasswordAuth,
642 url: &str,
643 additional_headers: &HeaderMap,
644) -> Result<Bytes> {
645 let mut headers = additional_headers.clone();
646 headers.insert(
647 reqwest::header::AUTHORIZATION,
648 reqwest::header::HeaderValue::from_str(&format!("Bearer {}", app_auth.access_token))?,
649 );
650 let http_response = http_client
651 .get(url)
652 .headers(headers)
653 .send()
654 .await
655 .map_err(|error| ClientError::HttpRequestFailed {
656 url: url.to_string(),
657 error,
658 })?;
659 Ok(http_response
660 .bytes()
661 .await
662 .map_err(|error| ClientError::ByteStreamFailed {
663 url: url.to_string(),
664 error,
665 })?)
666}
667
668/// Performs an app password-authenticated HTTP POST request with JSON body and returns the response as bytes.
669///
670/// This is useful when the server returns binary data such as images, CAR files,
671/// or other non-JSON content in response to authenticated POST requests.
672///
673/// # Arguments
674///
675/// * `http_client` - The HTTP client to use for the request
676/// * `app_auth` - App password authentication credentials
677/// * `url` - The URL to request
678/// * `record` - The JSON data to send in the request body
679/// * `additional_headers` - Additional HTTP headers to include in the request
680///
681/// # Returns
682///
683/// The response body as bytes
684///
685/// # Errors
686///
687/// Returns `ClientError::HttpRequestFailed` if the HTTP request fails,
688/// or an error if streaming the response bytes fails.
689pub async fn post_apppassword_bytes_with_headers(
690 http_client: &reqwest::Client,
691 app_auth: &AppPasswordAuth,
692 url: &str,
693 record: serde_json::Value,
694 additional_headers: &HeaderMap,
695) -> Result<Bytes> {
696 let mut headers = additional_headers.clone();
697 headers.insert(
698 reqwest::header::AUTHORIZATION,
699 reqwest::header::HeaderValue::from_str(&format!("Bearer {}", app_auth.access_token))?,
700 );
701 let http_response = http_client
702 .post(url)
703 .headers(headers)
704 .json(&record)
705 .send()
706 .instrument(tracing::info_span!("get_apppassword_bytes_with_headers", url = %url))
707 .await
708 .map_err(|error| ClientError::HttpRequestFailed {
709 url: url.to_string(),
710 error,
711 })?;
712 Ok(http_response
713 .bytes()
714 .await
715 .map_err(|error| ClientError::ByteStreamFailed {
716 url: url.to_string(),
717 error,
718 })?)
719}