secret_store 0.1.1

A unified, async secret store interface for Azure Key Vault, AWS Secrets Manager, GCP Secret Manager, and generic HTTP endpoints
Documentation
//! Generic HTTP SDK abstraction and real adapter.

use async_trait::async_trait;
use serde::{Deserialize, Serialize};

use super::types::map_http_error;
use crate::common::{Error, Result, error::StringError};

// ─────────────────────────────────────────────────────────────────────────────
// SDK abstraction trait
// ─────────────────────────────────────────────────────────────────────────────

/// Low-level HTTP secret store operations.
///
/// The real implementation calls a REST API; unit tests inject a
/// `MockHttpOps` generated by `mockall`.
#[cfg_attr(test, mockall::automock)]
#[async_trait]
pub trait HttpOps: Send + Sync {
    /// Minimal label used by `Display` (just the base URL).
    fn display_name(&self) -> String;
    /// Verbose info used by `Debug` (base URL, auth type, namespace, provider tag).
    fn debug_info(&self) -> String;

    async fn get(&self, name: &str) -> Result<String>;
    async fn set(&self, name: &str, value: &str) -> Result<()>;
    async fn delete(&self, name: &str) -> Result<()>;
    async fn list(&self, prefix: Option<String>) -> Result<Vec<String>>;
}

// ─────────────────────────────────────────────────────────────────────────────
// REST payload types
// ─────────────────────────────────────────────────────────────────────────────

#[derive(Deserialize)]
struct GetResponse {
    value: String,
}

#[derive(Serialize)]
struct SetRequest<'a> {
    value: &'a str,
}

#[derive(Deserialize)]
struct ListResponse {
    keys: Option<Vec<String>>,
}

// ─────────────────────────────────────────────────────────────────────────────
// Real HTTP adapter
// ─────────────────────────────────────────────────────────────────────────────

/// Calls a generic REST secret API using `reqwest`.
pub(super) struct ReqwestHttpClient {
    pub base_url: String,
    pub auth_token: Option<String>,
    pub namespace: Option<String>,
    pub http: reqwest::Client,
}

impl ReqwestHttpClient {
    fn url_for(&self, name: &str) -> String {
        format!("{}/{}", self.base_url, name)
    }

    fn apply_auth(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
        let req = match &self.auth_token {
            Some(token) => req.bearer_auth(token),
            None => req,
        };
        match &self.namespace {
            Some(ns) => req.header("X-Vault-Namespace", ns),
            None => req,
        }
    }
}

#[async_trait]
impl HttpOps for ReqwestHttpClient {
    fn display_name(&self) -> String {
        self.base_url.clone()
    }

    fn debug_info(&self) -> String {
        format!(
            "base_url={base_url}, auth={auth}, namespace={ns}, provider=HttpSecretStore",
            base_url = self.base_url,
            auth = if self.auth_token.is_some() {
                "Bearer"
            } else {
                "none"
            },
            ns = self.namespace.as_deref().unwrap_or("none"),
        )
    }

    async fn get(&self, name: &str) -> Result<String> {
        let resp = self
            .apply_auth(self.http.get(self.url_for(name)))
            .send()
            .await
            .map_err(|e| Error::Generic {
                store: "HttpSecretStore",
                source: Box::new(e),
            })?;

        let status = resp.status().as_u16();
        if !resp.status().is_success() {
            let body = resp.text().await.unwrap_or_default();
            return Err(map_http_error(name, status, StringError(body)));
        }

        resp.json::<GetResponse>()
            .await
            .map(|r| r.value)
            .map_err(|e| Error::Generic {
                store: "HttpSecretStore",
                source: Box::new(e),
            })
    }

    async fn set(&self, name: &str, value: &str) -> Result<()> {
        let resp = self
            .apply_auth(self.http.post(self.url_for(name)))
            .json(&SetRequest { value })
            .send()
            .await
            .map_err(|e| Error::Generic {
                store: "HttpSecretStore",
                source: Box::new(e),
            })?;

        let status = resp.status().as_u16();
        if !resp.status().is_success() {
            let body = resp.text().await.unwrap_or_default();
            return Err(map_http_error(name, status, StringError(body)));
        }
        Ok(())
    }

    async fn delete(&self, name: &str) -> Result<()> {
        let resp = self
            .apply_auth(self.http.delete(self.url_for(name)))
            .send()
            .await
            .map_err(|e| Error::Generic {
                store: "HttpSecretStore",
                source: Box::new(e),
            })?;

        let status = resp.status().as_u16();
        if !resp.status().is_success() {
            let body = resp.text().await.unwrap_or_default();
            return Err(map_http_error(name, status, StringError(body)));
        }
        Ok(())
    }

    async fn list(&self, prefix: Option<String>) -> Result<Vec<String>> {
        let url = match &prefix {
            Some(p) => format!("{}/{}", self.base_url, p),
            None => self.base_url.clone(),
        };

        let resp = self
            .apply_auth(self.http.get(&url).query(&[("list", "true")]))
            .send()
            .await
            .map_err(|e| Error::Generic {
                store: "HttpSecretStore",
                source: Box::new(e),
            })?;

        let status = resp.status().as_u16();
        if !resp.status().is_success() {
            let body = resp.text().await.unwrap_or_default();
            return Err(map_http_error("(list)", status, StringError(body)));
        }

        let list_resp: ListResponse = resp.json().await.map_err(|e| Error::Generic {
            store: "HttpSecretStore",
            source: Box::new(e),
        })?;

        let prefix_str = prefix.as_deref().unwrap_or("");
        let names = list_resp
            .keys
            .unwrap_or_default()
            .into_iter()
            .map(|k| {
                if prefix_str.is_empty() {
                    k
                } else {
                    format!("{prefix_str}{k}")
                }
            })
            .collect();
        Ok(names)
    }
}