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}