rig-http 0.44.0

Rig's transport contracts: the HTTP client and websocket traits a transport implements, and the wasm-compatible bounds they are written with.
Documentation
//! Transport-independent HTTP requests, lazy response bodies, and errors.
//!
//! ```
//! use rig_http::http_client::{HeaderMap, bearer_auth_header};
//!
//! let mut headers = HeaderMap::new();
//! bearer_auth_header(&mut headers, "example-token")?;
//! # Ok::<(), rig_http::http_client::Error>(())
//! ```

use bytes::Bytes;
use http::HeaderName;
pub use http::{
    HeaderMap, HeaderValue, Method, Request, Response, StatusCode, Uri, request::Builder,
};
mod erased;
pub mod framing;
pub mod middleware;
pub mod multipart;
use crate::wasm_compat::*;
pub use erased::DynHttpClient;
pub use middleware::HttpMiddleware;
pub use multipart::MultipartForm;

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("Http error: {0}")]
    Protocol(#[from] http::Error),
    /// Failed HTTP response preserving its status, body, and headers.
    /// Transport failures without a response use [`Self::Instance`] or a
    /// protocol variant.
    #[error("Invalid status code {status} with message: {body}")]
    InvalidStatusCodeWithDetails {
        /// The non-success status.
        status: StatusCode,
        /// The raw response body.
        body: String,
        /// The failed response's headers, verbatim.
        headers: http::HeaderMap,
    },
    #[error("Header value outside of legal range: {0}")]
    InvalidHeaderValue(#[from] http::header::InvalidHeaderValue),
    #[error("Request in error state, cannot access headers")]
    NoHeaders,
    #[error("Stream ended")]
    StreamEnded,
    #[error("Invalid content type was returned: {0:?}")]
    InvalidContentType(HeaderValue),
    #[cfg(not(target_family = "wasm"))]
    #[error("Http client error: {0}")]
    Instance(#[from] Box<dyn std::error::Error + Send + Sync + 'static>),

    #[cfg(target_family = "wasm")]
    #[error("Http client error: {0}")]
    Instance(#[from] Box<dyn std::error::Error + 'static>),
}

impl Error {
    /// Preserved HTTP failure status, or `None` for other error variants.
    pub fn non_success_status(&self) -> Option<StatusCode> {
        match self {
            Self::InvalidStatusCodeWithDetails { status, .. } => Some(*status),
            _ => None,
        }
    }

    /// The response body this error preserved, when it has one. Companion to
    /// [`Self::non_success_status`] and [`Self::non_success_headers`].
    pub fn non_success_body(&self) -> Option<&str> {
        match self {
            Self::InvalidStatusCodeWithDetails { body, .. } => Some(body.as_str()),
            _ => None,
        }
    }

    /// Constructs a failure from its HTTP status, headers, and body without
    /// validating that the status is non-successful.
    pub fn non_success_with_details(status: StatusCode, headers: HeaderMap, body: String) -> Self {
        Self::InvalidStatusCodeWithDetails {
            status,
            body,
            headers,
        }
    }

    /// Returns the failed response's headers, when this error preserved them.
    ///
    /// The following example reads the seconds form of `Retry-After`:
    ///
    /// ```
    /// # use rig_http::http_client::Error;
    /// # use std::time::Duration;
    /// fn retry_after(error: &Error) -> Option<Duration> {
    ///     let seconds = error
    ///         .non_success_headers()?
    ///         .get(http::header::RETRY_AFTER)?
    ///         .to_str()
    ///         .ok()?
    ///         .parse()
    ///         .ok()?;
    ///     Some(Duration::from_secs(seconds))
    /// }
    /// ```
    ///
    /// Returns `None` for a response-less failure: every non-success error
    /// carries its headers.
    pub fn non_success_headers(&self) -> Option<&HeaderMap> {
        match self {
            Self::InvalidStatusCodeWithDetails { headers, .. } => Some(headers),
            _ => None,
        }
    }
}

pub type Result<T> = std::result::Result<T, Error>;

impl Error {
    /// Wrap a transport's native error as [`Error::Instance`]. Transports use
    /// this for response-less failures (connect, decode, timeout); non-success
    /// responses go through [`Error::non_success_with_details`] instead so the
    /// status stays inspectable.
    #[cfg(not(target_family = "wasm"))]
    pub fn instance<E: std::error::Error + Send + Sync + 'static>(error: E) -> Self {
        Self::Instance(error.into())
    }

    /// Wrap a transport's native error as [`Error::Instance`].
    #[cfg(target_family = "wasm")]
    pub fn instance<E: std::error::Error + 'static>(error: E) -> Self {
        Self::Instance(error.into())
    }
}

pub type LazyBytes = WasmBoxedFuture<'static, Result<Bytes>>;
pub type LazyBody<T> = WasmBoxedFuture<'static, Result<T>>;

/// The body of a streaming response: the transport's own chunks, boxed.
pub type BoxedStream = std::pin::Pin<Box<dyn WasmCompatSendStream<InnerItem = Result<Bytes>>>>;

pub type StreamingResponse = Response<BoxedStream>;

#[derive(Debug, Clone, Copy)]
pub struct NoBody;

impl From<NoBody> for Bytes {
    fn from(_: NoBody) -> Self {
        Bytes::new()
    }
}

pub async fn text(response: Response<LazyBody<Vec<u8>>>) -> Result<String> {
    let text = response.into_body().await?;
    Ok(String::from(String::from_utf8_lossy(&text)))
}

pub fn make_auth_header(key: impl AsRef<str>) -> Result<(HeaderName, HeaderValue)> {
    Ok((
        http::header::AUTHORIZATION,
        HeaderValue::from_str(&format!("Bearer {}", key.as_ref()))?,
    ))
}

pub fn bearer_auth_header(headers: &mut HeaderMap, key: impl AsRef<str>) -> Result<()> {
    let (k, v) = make_auth_header(key)?;

    headers.insert(k, v);

    Ok(())
}

/// An HTTP client Rig sends through: unary, multipart and streaming requests.
///
/// `rig-reqwest`'s `ReqwestClient` is the bundled implementation; a provider
/// configuration takes any implementation with `with_http(client)`.
#[diagnostic::on_unimplemented(
    message = "`{Self}` is not an HTTP client Rig can send through",
    label = "not an `HttpClientExt`",
    note = "use `rig_reqwest::ReqwestClient` (wrap a configured `reqwest::Client` with `ReqwestClient::from(client)`), erase a client with `DynHttpClient::new(client)`, or implement `HttpClientExt`; a provider configuration takes it with `.with_http(client)`"
)]
pub trait HttpClientExt: WasmCompatSend + WasmCompatSync {
    /// Sends a request and returns headers with a lazy body converted from bytes to `U`.
    fn send<T, U>(
        &self,
        req: Request<T>,
    ) -> impl Future<Output = Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
    where
        T: Into<Bytes>,
        T: WasmCompatSend,
        U: From<Bytes>,
        U: WasmCompatSend + 'static;

    /// Sends a multipart request and returns headers with a lazy body converted to `U`.
    fn send_multipart<U>(
        &self,
        req: Request<MultipartForm>,
    ) -> impl Future<Output = Result<Response<LazyBody<U>>>> + WasmCompatSend + 'static
    where
        U: From<Bytes>,
        U: WasmCompatSend + 'static;

    /// Sends a request and returns headers with a stream of transport byte chunks.
    fn send_streaming<T>(
        &self,
        req: Request<T>,
    ) -> impl Future<Output = Result<StreamingResponse>> + WasmCompatSend
    where
        T: Into<Bytes> + WasmCompatSend;
}

#[cfg(test)]
mod non_success_header_tests;