toac 0.1.1

Tower-compatible OpenAPI client runtime. Types and traits that generated code from `toac-build` links against.
Documentation
//! Security / auth scaffolding.
//!
//! Exposes the trait layer every auth scheme plugs into, plus built-in
//! [`SecurityCredential`] implementations for the three schemes the
//! generator currently emits: API key (header / query / cookie),
//! HTTP Bearer, and HTTP Basic.
//!
//! The runtime exposes three pieces:
//!
//! - [`SecurityCredential`] — a single scheme's way of modifying an
//!   outgoing [`Request`]. Per-scheme implementations are generated by
//!   `toac-build`.
//! - [`AuthSelector`] — the dyn-safe bridge between a user-supplied
//!   credential store (the generated `AuthConfig`) and [`crate::ApiClient`].
//!   Picks one satisfiable alternative from an operation's
//!   [`OperationSecurity`] requirements and applies it.
//! - [`OperationSecurity`] — the per-op security requirement attached to
//!   every generated [`Request`] through [`http::Extensions`]. This is
//!   what lets the client find out what the operation needs without
//!   baking the scheme list into `ApiClient`'s type parameters.
//!
//! `async + Result` is kept on both traits even though the three schemes
//! the generator currently emits (API key / Bearer / Basic) resolve in
//! one poll and never error. The cost is zero in the common path, but
//! keeps the trait shape open for OAuth2 / OIDC, which will need to
//! refresh tokens and surface network failures — see `TODO.md` for the
//! roadmap.

use std::{future::Future, pin::Pin, sync::Arc};

use crate::{BoxError, Request};

/// Boxed, send-capable auth future. [`AuthSelector`] returns this so
/// the trait stays dyn-safe (a requirement for `ApiClient` to hold
/// `Arc<dyn AuthSelector>`).
pub type AuthFuture<'a> = Pin<Box<dyn Future<Output = Result<Request, BoxError>> + Send + 'a>>;

/// Per-operation security requirement, attached to each generated
/// [`Request`] through [`http::Extensions`].
///
/// The payload encodes OAS's `security` semantics directly:
/// - outer slice — alternatives (**OR**); satisfying any one is enough
/// - inner slice — **AND** requirements; every named scheme must be applied
/// - empty outer — public endpoint, no auth needed
///
/// Scheme names match the keys under `components.securitySchemes` in
/// the spec. The generator emits the concrete static slices.
#[derive(Debug, Clone, Copy)]
pub struct OperationSecurity(pub &'static [&'static [&'static str]]);

impl OperationSecurity {
    /// Sentinel for public endpoints; equivalent to omitting the
    /// extension entirely.
    pub const PUBLIC: Self = Self(&[]);

    /// Returns `true` when no alternatives are declared, meaning no
    /// credentials are required.
    pub fn is_public(&self) -> bool {
        self.0.is_empty()
    }
}

/// Applies one scheme's credential to an outgoing [`Request`].
///
/// Per-scheme implementations (API key / Bearer / Basic for now) are
/// generated by `toac-build`. The generated impls are effectively
/// synchronous — their future resolves on the first poll — but the
/// trait leaves async + fallible room so OAuth2 / OIDC implementations
/// can refresh tokens and surface network failures without a breaking
/// change.
pub trait SecurityCredential {
    /// Returns `req` with the credential applied (header / query /
    /// cookie set, body tweaked, …).
    ///
    /// # Errors
    ///
    /// Returns a [`BoxError`] when the credential can't be prepared —
    /// e.g. a future OAuth2 implementation that fails to refresh its
    /// token. The simple schemes currently generated never error.
    fn apply(&self, req: Request) -> impl Future<Output = Result<Request, BoxError>> + Send;
}

/// Bridge between a user-supplied credential store and [`crate::ApiClient`].
///
/// The generator emits a concrete implementation on each spec's
/// `AuthConfig` struct; end users rarely implement this trait directly.
/// It's dyn-safe by design (returns [`AuthFuture`] instead of an `impl
/// Future`) so `ApiClient` can hold it as `Arc<dyn AuthSelector>` —
/// that's how `ApiClient` stays single-generic (`ApiClient<S>`)
/// regardless of which auth schemes the spec declares.
pub trait AuthSelector: Send + Sync + 'static {
    /// Picks one alternative from `requirements` that this selector
    /// can satisfy, applies every credential in that alternative to
    /// `req` in order, and returns the modified request.
    ///
    /// Semantics:
    /// - `requirements` empty → return `req` unchanged
    /// - else pick any alternative whose schemes are all configured
    /// - if no alternative is satisfiable → return an error
    ///
    /// # Errors
    ///
    /// Returns [`BoxError`] when no alternative can be satisfied, or
    /// when a selected credential's own [`SecurityCredential::apply`]
    /// fails.
    fn apply_for(
        &self,
        req: Request,
        requirements: &'static [&'static [&'static str]],
    ) -> AuthFuture<'_>;
}

/// Default [`AuthSelector`] used when [`crate::ApiClient`] is built
/// without `with_auth`.
///
/// Public endpoints (empty requirements) pass through untouched; any
/// non-empty requirement returns an error so forgotten `with_auth`
/// calls fail loudly instead of silently sending unauthenticated
/// requests.
#[derive(Debug, Default, Clone, Copy)]
pub struct NoAuth;

impl AuthSelector for NoAuth {
    fn apply_for(
        &self,
        req: Request,
        requirements: &'static [&'static [&'static str]],
    ) -> AuthFuture<'_> {
        Box::pin(async move {
            if requirements.is_empty() {
                return Ok(req);
            }
            let schemes: Vec<&'static str> = requirements
                .iter()
                .flat_map(|alt| alt.iter().copied())
                .collect();
            Err(format!(
                "operation requires auth ({schemes:?}) but no credentials were configured; \
                 call ApiClient::with_auth before dispatching"
            )
            .into())
        })
    }
}

/// Singleton used by [`crate::ApiClient::new`] so that constructing a
/// client without `with_auth` doesn't allocate a fresh `NoAuth` each
/// time.
pub(crate) fn default_auth() -> Arc<dyn AuthSelector> {
    // Cheap to construct and Arc::new is one allocation per client —
    // not shared because `dyn` erasure prevents promoting to a const.
    Arc::new(NoAuth)
}

// ---------------------------------------------------------------------------
// Built-in credential types for the simple schemes.
//
// The generator emits per-spec newtypes that dereference or delegate to
// these, so each credential's apply logic lives in exactly one place.
// ---------------------------------------------------------------------------

/// Where an API-key credential ends up on the wire. Mirrors OAS's
/// `in: header | query | cookie` choice.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ApiKeyLocation {
    Header,
    Query,
    Cookie,
}

/// Credential for `type: apiKey` schemes.
///
/// Carries the parameter name (header/query/cookie key) and where to
/// put it, plus the key value itself. Generated per-scheme credentials
/// forward to this type so the three locations share one implementation.
#[derive(Debug, Clone)]
pub struct ApiKeyCredential {
    /// Header/query/cookie name as declared in the spec's `name` field.
    pub name: &'static str,
    /// Where to write the key.
    pub location: ApiKeyLocation,
    /// The secret value.
    pub value: String,
}

impl SecurityCredential for ApiKeyCredential {
    fn apply(&self, req: Request) -> impl Future<Output = Result<Request, BoxError>> + Send {
        let result = match self.location {
            ApiKeyLocation::Header => apply_header(req, self.name, &self.value),
            ApiKeyLocation::Query => apply_query(req, self.name, &self.value),
            ApiKeyLocation::Cookie => apply_cookie(req, self.name, &self.value),
        };
        std::future::ready(result)
    }
}

/// Credential for `type: http, scheme: bearer`.
#[derive(Debug, Clone)]
pub struct BearerCredential {
    /// The bearer token (without the `Bearer ` prefix).
    pub token: String,
}

impl SecurityCredential for BearerCredential {
    fn apply(&self, req: Request) -> impl Future<Output = Result<Request, BoxError>> + Send {
        let header_value = format!("Bearer {}", self.token);
        let result = apply_header(req, http::header::AUTHORIZATION.as_str(), &header_value);
        std::future::ready(result)
    }
}

/// Credential for `type: http, scheme: basic`.
#[derive(Debug, Clone)]
pub struct BasicCredential {
    pub username: String,
    pub password: String,
}

impl SecurityCredential for BasicCredential {
    fn apply(&self, req: Request) -> impl Future<Output = Result<Request, BoxError>> + Send {
        use base64::Engine as _;
        let combined = format!("{}:{}", self.username, self.password);
        let encoded = base64::engine::general_purpose::STANDARD.encode(combined.as_bytes());
        let header_value = format!("Basic {encoded}");
        let result = apply_header(req, http::header::AUTHORIZATION.as_str(), &header_value);
        std::future::ready(result)
    }
}

fn apply_header(mut req: Request, name: &str, value: &str) -> Result<Request, BoxError> {
    let header_name = http::HeaderName::try_from(name)?;
    let header_value = http::HeaderValue::try_from(value)?;
    req.headers_mut().insert(header_name, header_value);
    Ok(req)
}

fn apply_query(req: Request, name: &str, value: &str) -> Result<Request, BoxError> {
    use percent_encoding::{AsciiSet, CONTROLS, utf8_percent_encode};

    // RFC 3986 "reserved" minus the sub-delims we don't emit, plus the
    // extra characters that break a URL when left raw in a query
    // component (`#` terminates the query, `%` begins an escape that we
    // handle ourselves, `+` is ambiguous with form-encoded space).
    // Everything here stays out of the encoded output, everything else
    // is percent-escaped.
    const QUERY_COMPONENT: &AsciiSet = &CONTROLS
        .add(b' ')
        .add(b'"')
        .add(b'#')
        .add(b'%')
        .add(b'&')
        .add(b'+')
        .add(b'/')
        .add(b'<')
        .add(b'=')
        .add(b'>')
        .add(b'?')
        .add(b'@')
        .add(b'`')
        .add(b'{')
        .add(b'|')
        .add(b'}');

    let encoded_name = utf8_percent_encode(name, QUERY_COMPONENT).to_string();
    let encoded_value = utf8_percent_encode(value, QUERY_COMPONENT).to_string();

    let (mut parts, body) = req.into_parts();
    let uri = parts.uri.clone();
    let path = uri.path();
    let existing = uri.query();
    let sep = if existing.is_some() { '&' } else { '?' };
    let mut new_path_and_query = String::with_capacity(
        path.len() + existing.map_or(0, str::len) + encoded_name.len() + encoded_value.len() + 3,
    );
    new_path_and_query.push_str(path);
    if let Some(existing) = existing {
        new_path_and_query.push('?');
        new_path_and_query.push_str(existing);
    }
    new_path_and_query.push(sep);
    new_path_and_query.push_str(&encoded_name);
    new_path_and_query.push('=');
    new_path_and_query.push_str(&encoded_value);

    let mut uri_parts = uri.into_parts();
    uri_parts.path_and_query = Some(new_path_and_query.parse()?);
    parts.uri = http::Uri::from_parts(uri_parts)?;
    Ok(Request::from_parts(parts, body))
}

fn apply_cookie(mut req: Request, name: &str, value: &str) -> Result<Request, BoxError> {
    // Appends to an existing `Cookie` header when present; otherwise sets it.
    let pair = format!("{name}={value}");
    let headers = req.headers_mut();
    let new_value = match headers.get(http::header::COOKIE) {
        Some(existing) => {
            let existing = existing.to_str()?;
            http::HeaderValue::try_from(format!("{existing}; {pair}"))?
        }
        None => http::HeaderValue::try_from(pair)?,
    };
    headers.insert(http::header::COOKIE, new_value);
    Ok(req)
}