cnb 0.2.2

CNB (cnb.cool) API client for Rust — typed, async, production-ready
Documentation
//! Shared HTTP transport. All resource modules call into [`HttpInner::execute`]
//! or [`HttpInner::execute_with_body`].
//!
//! Centralising the transport here means the (de)serialisation, error body
//! extraction, retry loop, `Retry-After` handling, and tracing instrumentation
//! all live in one place — the generated resource modules stay tiny.

use std::{sync::Arc, time::Duration};

use reqwest::{Client, Method, Response, StatusCode};
use serde::{de::DeserializeOwned, Serialize};
use url::Url;

use crate::{
    error::{ApiError, ErrorEnvelope, Result},
    retry::{is_idempotent_method, RetryConfig},
};

/// Internal, refcounted shared state used by every resource client.
///
/// `HttpInner` is cheap to clone (it's an [`Arc`] under the hood — we expose it
/// directly because the indirection is intentional and lets callers freely move
/// `ApiClient` across tasks).
///
/// # Advanced usage
///
/// For endpoints that return non-JSON payloads (file downloads, build logs) or
/// require streaming uploads, use [`Self::reqwest_client()`] and
/// [`Self::url()`] to construct raw requests while reusing the SDK's connection
/// pool, auth headers, and base URL logic.
#[derive(Debug, Clone)]
pub struct HttpInner {
    pub(crate) base_url: Arc<Url>,
    pub(crate) client: Client,
    pub(crate) retry: RetryConfig,
}

impl HttpInner {
    pub(crate) fn new(base_url: Url, client: Client, retry: RetryConfig) -> Self {
        Self {
            base_url: Arc::new(base_url),
            client,
            retry,
        }
    }

    /// Returns a reference to the underlying [`reqwest::Client`].
    ///
    /// Use this for non-JSON requests (file uploads/downloads, streaming
    /// responses) while reusing the SDK's connection pool and auth headers.
    pub fn reqwest_client(&self) -> &Client {
        &self.client
    }

    /// Returns the base URL configured for this client.
    pub fn base_url(&self) -> &Url {
        &self.base_url
    }

    /// Build a fully-qualified request URL by joining `path` onto the base.
    /// The leading `/` of `path` is significant — paths in the OpenAPI spec
    /// always start with `/` and we preserve that.
    pub fn url(&self, path: &str) -> Result<Url> {
        // Url::join treats absolute paths correctly; we just need to make sure
        // base_url ends with `/` so segments don't get dropped. We normalise it
        // once at construction time in `ClientBuilder::build`, so this is safe.
        let joined = self
            .base_url
            .join(path.trim_start_matches('/'))
            .map_err(ApiError::from)?;
        Ok(joined)
    }

    /// Execute a request that has no body and decode the response into `T`.
    /// `T` may be `serde_json::Value` for endpoints where the spec did not pin
    /// a schema.
    pub async fn execute<T: DeserializeOwned>(&self, method: Method, url: Url) -> Result<T> {
        self.execute_inner::<T, ()>(method, url, None).await
    }

    /// Execute a request whose body is a `Serialize`-able value.
    pub async fn execute_with_body<T, B>(&self, method: Method, url: Url, body: &B) -> Result<T>
    where
        T: DeserializeOwned,
        B: Serialize + ?Sized,
    {
        self.execute_inner::<T, B>(method, url, Some(body)).await
    }

    async fn execute_inner<T, B>(&self, method: Method, url: Url, body: Option<&B>) -> Result<T>
    where
        T: DeserializeOwned,
        B: Serialize + ?Sized,
    {
        let max_attempts = if is_idempotent_method(&method) {
            self.retry.max_attempts.max(1)
        } else {
            1
        };

        let mut last_err: Option<ApiError> = None;

        for attempt in 1..=max_attempts {
            #[cfg(feature = "tracing")]
            let started = std::time::Instant::now();

            let mut req = self.client.request(method.clone(), url.clone());
            if let Some(b) = body {
                req = req.json(b);
            }

            let send_result = req.send().await;
            let response = match send_result {
                Ok(r) => r,
                Err(e) => {
                    let err = ApiError::from(e);
                    if attempt < max_attempts && err.is_retryable() {
                        let delay = self.retry.backoff_for(attempt);
                        #[cfg(feature = "tracing")]
                        tracing::warn!(
                            attempt,
                            next_delay_ms = delay.as_millis() as u64,
                            error = %err,
                            "transport error, retrying"
                        );
                        tokio::time::sleep(delay).await;
                        last_err = Some(err);
                        continue;
                    }
                    return Err(err);
                }
            };

            let status = response.status();

            #[cfg(feature = "tracing")]
            tracing::debug!(
                method = %method,
                path = %url.path(),
                status = status.as_u16(),
                elapsed_ms = started.elapsed().as_millis() as u64,
                "cnb api call"
            );

            if status.is_success() {
                return decode_response::<T>(response, status).await;
            }

            // Non-success: try to extract a structured body for the error.
            let retry_after = parse_retry_after(&response);
            let api_err = build_http_error(response, status).await;

            // Honour Retry-After even before checking is_retryable, because the
            // server explicitly told us to wait.
            if attempt < max_attempts && api_err.is_retryable() {
                let delay = retry_after
                    .unwrap_or_else(|| self.retry.backoff_for(attempt))
                    .min(self.retry.max_delay.max(Duration::from_secs(60)));
                #[cfg(feature = "tracing")]
                tracing::warn!(
                    attempt,
                    status = status.as_u16(),
                    next_delay_ms = delay.as_millis() as u64,
                    "retryable HTTP error, backing off"
                );
                tokio::time::sleep(delay).await;
                last_err = Some(api_err);
                continue;
            }

            return Err(api_err);
        }

        // The loop only exits via `return` or by exhausting all attempts; in
        // the latter case `last_err` is always populated.
        Err(last_err.unwrap_or_else(|| ApiError::EnvVar("retry loop exited unexpectedly".into())))
    }
}

/// Decode the success body. We treat empty 2xx bodies (e.g. 204) as
/// `serde_json::Value::Null` for `Value` callers, and as a JSON parse error for
/// strongly-typed callers (which is the correct behaviour — the caller asked
/// for a typed payload that the server did not provide).
async fn decode_response<T: DeserializeOwned>(response: Response, status: StatusCode) -> Result<T> {
    let bytes = response.bytes().await?;
    if bytes.is_empty() {
        // Try to deserialise from `null` — works for `Option<_>` and `Value`,
        // fails for everything else with a clear JSON error.
        return serde_json::from_slice::<T>(b"null").map_err(|e| {
            // Wrap into a more descriptive error: the server told us 2xx but
            // gave us nothing.
            let _ = status;
            ApiError::from(e)
        });
    }
    serde_json::from_slice::<T>(&bytes).map_err(ApiError::from)
}

/// Build an [`ApiError::Http`] from a non-2xx response. Always preserves the
/// raw body, even when JSON parsing fails.
async fn build_http_error(response: Response, status: StatusCode) -> ApiError {
    // Preserve `X-Request-Id` if present in headers — useful even when body is
    // empty or non-JSON.
    let header_request_id = response
        .headers()
        .get("x-request-id")
        .and_then(|v| v.to_str().ok())
        .map(|s| s.to_string());

    let body: String = response.text().await.unwrap_or_default();

    let envelope = if body.is_empty() {
        ErrorEnvelope::default()
    } else {
        ErrorEnvelope::parse(&body)
    };

    let message = envelope
        .message
        .clone()
        .unwrap_or_else(|| status.canonical_reason().unwrap_or("error").to_string());

    let request_id = envelope.request_id.clone().or(header_request_id);

    ApiError::Http {
        status: status.as_u16(),
        body,
        code: envelope.code,
        message,
        request_id,
    }
}

/// Parse a `Retry-After` header. Supports the `delta-seconds` form only —
/// the HTTP-date form is uncommon for CNB and would require a full date parser.
fn parse_retry_after(response: &Response) -> Option<Duration> {
    response
        .headers()
        .get(reqwest::header::RETRY_AFTER)
        .and_then(|v| v.to_str().ok())
        .and_then(|s| s.trim().parse::<u64>().ok())
        .map(Duration::from_secs)
}