openlatch-client 0.1.7

The open-source security layer for AI agents — client forwarder
//! Cloud envelope helpers — header construction for cloud API requests.
//!
//! With the CloudEvents v1.0.2 migration the outbound wire format is
//! `application/cloudevents-batch+json` — a bare JSON array of
//! EventEnvelope objects. The `CloudEventPayload` wrapper that used to
//! bundle envelope + raw_event is gone; the raw payload now lives under
//! `envelope.data` and every OpenLatch metadata field is a CloudEvents
//! extension attribute on the envelope itself.

// Re-export generated cloud types for use by the worker.
pub use crate::generated::types::CloudIngestionRequest;

use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};

/// CloudEvents structured-mode Content-Type for a batch of events.
pub const CLOUDEVENTS_BATCH_CONTENT_TYPE: &str = "application/cloudevents-batch+json";

/// Build HTTP request headers for a cloud API POST.
///
/// # Headers set
/// - `Authorization: Bearer <key>` — derived from the secret (only call point for `.expose_secret()`)
/// - `Content-Type: application/cloudevents-batch+json`
/// - `X-Client-Version: <crate version>`
/// - `X-Request-ID: <request_id>` (caller provides a UUIDv7 string)
///
/// # Security
/// The API key is exposed only here, in the `Authorization` header value.
/// It is NEVER passed to tracing macros or other functions.
pub fn build_cloud_headers(
    api_key: &secrecy::SecretString,
    request_id: &str,
) -> reqwest::header::HeaderMap {
    use secrecy::ExposeSecret;

    let mut headers = HeaderMap::new();

    // Authorization: Bearer <key>
    let auth_value = format!("Bearer {}", api_key.expose_secret());
    if let Ok(val) = HeaderValue::from_str(&auth_value) {
        headers.insert(AUTHORIZATION, val);
    }

    // Content-Type: application/cloudevents-batch+json
    headers.insert(
        CONTENT_TYPE,
        HeaderValue::from_static(CLOUDEVENTS_BATCH_CONTENT_TYPE),
    );

    // X-Client-Version: <crate version>
    if let Ok(val) = HeaderValue::from_str(env!("CARGO_PKG_VERSION")) {
        headers.insert("X-Client-Version", val);
    }

    // X-Request-ID: <UUIDv7>
    if let Ok(val) = HeaderValue::from_str(request_id) {
        headers.insert("X-Request-ID", val);
    }

    headers
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use secrecy::SecretString;

    #[test]
    fn test_build_cloud_headers_sets_authorization_bearer() {
        let key = SecretString::from("test-api-key".to_string());
        let headers = build_cloud_headers(&key, "req-id-123");

        let auth = headers
            .get("Authorization")
            .expect("Authorization header must be present");
        let auth_str = auth.to_str().unwrap();
        assert!(
            auth_str.starts_with("Bearer "),
            "Authorization must start with 'Bearer ': {auth_str}"
        );
        assert!(
            auth_str.contains("test-api-key"),
            "Authorization must contain the API key"
        );
    }

    #[test]
    fn test_build_cloud_headers_sets_content_type_cloudevents_batch() {
        let key = SecretString::from("key".to_string());
        let headers = build_cloud_headers(&key, "req-id-456");
        let ct = headers
            .get("Content-Type")
            .expect("Content-Type must be present");
        assert_eq!(ct.to_str().unwrap(), "application/cloudevents-batch+json");
    }

    #[test]
    fn test_build_cloud_headers_sets_x_client_version() {
        let key = SecretString::from("key".to_string());
        let headers = build_cloud_headers(&key, "req-id-789");
        let ver = headers
            .get("X-Client-Version")
            .expect("X-Client-Version must be present");
        assert!(!ver.to_str().unwrap().is_empty());
    }

    #[test]
    fn test_build_cloud_headers_sets_x_request_id() {
        let key = SecretString::from("key".to_string());
        let request_id = "01234567-89ab-7cde-f012-3456789abcde";
        let headers = build_cloud_headers(&key, request_id);
        let rid = headers
            .get("X-Request-ID")
            .expect("X-Request-ID must be present");
        assert_eq!(rid.to_str().unwrap(), request_id);
    }

    #[test]
    fn test_cloud_ingestion_request_is_transparent_array_of_envelopes() {
        // CloudIngestionRequest is now a transparent newtype over
        // Vec<EventEnvelope>. Its wire form is a bare JSON array.
        let batch = CloudIngestionRequest(Vec::new());
        let json = serde_json::to_value(&batch).unwrap();
        assert!(json.is_array(), "batch must serialise to a JSON array");
        assert_eq!(json.as_array().unwrap().len(), 0);
    }
}