atproto-client 0.4.1

HTTP client for AT Protocol services with OAuth and identity integration
Documentation
//! HTTP client operations with DPoP authentication support.
//!
//! Provides authenticated and unauthenticated HTTP request functions for JSON APIs,
//! with support for DPoP (Demonstration of Proof-of-Possession) authentication.

use crate::errors::{ClientError, DPoPError};
use anyhow::Result;
use atproto_identity::key::KeyData;
use atproto_oauth::dpop::{request_dpop, DpopRetry};
use reqwest_chain::ChainMiddleware;
use reqwest_middleware::ClientBuilder;
use tracing::Instrument;

/// DPoP authentication credentials for authenticated HTTP requests.
///
/// Contains the private key for DPoP proof generation and OAuth access token
/// for Authorization header.
pub struct DPoPAuth {
    /// Private key data for generating DPoP proof tokens
    pub dpop_private_key_data: KeyData,
    /// OAuth access token for the Authorization header
    pub oauth_access_token: String,
    /// OAuth issuer identifier for the access token
    pub oauth_issuer: String,
}

/// Performs an unauthenticated HTTP GET request and parses the response as JSON.
///
/// # Arguments
///
/// * `http_client` - The HTTP client to use for the request
/// * `url` - The URL to request
///
/// # Returns
///
/// The parsed JSON response as a `serde_json::Value`
///
/// # Errors
///
/// Returns `ClientError::HttpRequestFailed` if the HTTP request fails,
/// or `ClientError::JsonParseFailed` if JSON parsing fails.
pub async fn get_json(http_client: &reqwest::Client, url: &str) -> Result<serde_json::Value> {
    let http_response =
        http_client
            .get(url)
            .send()
            .await
            .map_err(|error| ClientError::HttpRequestFailed {
                url: url.to_string(),
                error,
            })?;

    let value = http_response
        .json::<serde_json::Value>()
        .await
        .map_err(|error| ClientError::JsonParseFailed {
            url: url.to_string(),
            error,
        })?;

    Ok(value)
}

/// Performs a DPoP-authenticated HTTP GET request and parses the response as JSON.
///
/// # Arguments
///
/// * `http_client` - The HTTP client to use for the request
/// * `dpop_auth` - DPoP authentication credentials
/// * `url` - The URL to request
///
/// # Returns
///
/// The parsed JSON response as a `serde_json::Value`
///
/// # Errors
///
/// Returns `DPoPError::ProofGenerationFailed` if DPoP proof generation fails,
/// `DPoPError::HttpRequestFailed` if the HTTP request fails,
/// or `DPoPError::JsonParseFailed` if JSON parsing fails.
pub async fn get_dpop_json(
    http_client: &reqwest::Client,
    dpop_auth: &DPoPAuth,
    url: &str,
) -> Result<serde_json::Value> {
    let (dpop_proof_token, dpop_proof_header, dpop_proof_claim) = request_dpop(
        &dpop_auth.dpop_private_key_data,
        "GET",
        url,
        &dpop_auth.oauth_issuer,
        &dpop_auth.oauth_access_token,
    )
    .map_err(|error| DPoPError::ProofGenerationFailed { error })?;

    let dpop_retry = DpopRetry::new(
        dpop_proof_header.clone(),
        dpop_proof_claim.clone(),
        dpop_auth.dpop_private_key_data.clone(),
        true,
    );

    let dpop_retry_client = ClientBuilder::new(http_client.clone())
        .with(ChainMiddleware::new(dpop_retry.clone()))
        .build();

    let http_response = dpop_retry_client
        .get(url)
        .header(
            "Authorization",
            &format!("DPoP {}", dpop_auth.oauth_access_token),
        )
        .header("DPoP", &dpop_proof_token)
        .send()
        .instrument(tracing::info_span!("dpop_get_request", url = %url))
        .await
        .map_err(|error| DPoPError::HttpRequestFailed {
            url: url.to_string(),
            error,
        })?;

    let value = http_response
        .json::<serde_json::Value>()
        .await
        .map_err(|error| DPoPError::JsonParseFailed {
            url: url.to_string(),
            error,
        })?;

    Ok(value)
}

/// Performs a DPoP-authenticated HTTP POST request with JSON body and parses the response as JSON.
///
/// # Arguments
///
/// * `http_client` - The HTTP client to use for the request
/// * `dpop_auth` - DPoP authentication credentials
/// * `url` - The URL to request
/// * `record` - The JSON data to send in the request body
///
/// # Returns
///
/// The parsed JSON response as a `serde_json::Value`
///
/// # Errors
///
/// Returns `DPoPError::ProofGenerationFailed` if DPoP proof generation fails,
/// `DPoPError::HttpRequestFailed` if the HTTP request fails,
/// or `DPoPError::JsonParseFailed` if JSON parsing fails.
pub async fn post_dpop_json(
    http_client: &reqwest::Client,
    dpop_auth: &DPoPAuth,
    url: &str,
    record: serde_json::Value,
) -> Result<serde_json::Value> {
    let (dpop_proof_token, dpop_proof_header, dpop_proof_claim) = request_dpop(
        &dpop_auth.dpop_private_key_data,
        "POST",
        url,
        &dpop_auth.oauth_issuer,
        &dpop_auth.oauth_access_token,
    )
    .map_err(|error| DPoPError::ProofGenerationFailed { error })?;

    let dpop_retry = DpopRetry::new(
        dpop_proof_header.clone(),
        dpop_proof_claim.clone(),
        dpop_auth.dpop_private_key_data.clone(),
        true,
    );

    let dpop_retry_client = ClientBuilder::new(http_client.clone())
        .with(ChainMiddleware::new(dpop_retry.clone()))
        .build();

    let http_response = dpop_retry_client
        .post(url)
        .header(
            "Authorization",
            &format!("DPoP {}", dpop_auth.oauth_access_token),
        )
        .header("DPoP", &dpop_proof_token)
        .json(&record)
        .send()
        .instrument(tracing::info_span!("dpop_post_request", url = %url))
        .await
        .map_err(|error| DPoPError::HttpRequestFailed {
            url: url.to_string(),
            error,
        })?;

    let value = http_response
        .json::<serde_json::Value>()
        .await
        .map_err(|error| DPoPError::JsonParseFailed {
            url: url.to_string(),
            error,
        })?;

    Ok(value)
}