knafeh 1.1.0

QUIC-based RPC library with Python bindings
Documentation
use std::collections::HashMap;

use crate::error::KnafehError;
use crate::rpc::message::Metadata;

// ---------------------------------------------------------------------------
// HTTP/3 ↔ RPC header mapping
// ---------------------------------------------------------------------------

/// The HTTP header prefix for RPC metadata.
pub const RPC_HEADER_PREFIX: &str = "x-rpc-";

/// The header used to carry the RPC status code.
pub const RPC_STATUS_HEADER: &str = "x-rpc-status";

/// The header used to carry the RPC status message.
pub const RPC_STATUS_MESSAGE_HEADER: &str = "x-rpc-status-message";

/// The header indicating the RPC method kind for streaming dispatch.
pub const RPC_METHOD_KIND_HEADER: &str = "x-rpc-method-kind";

/// Raw HTTP/3 header name/value pairs.
pub type RawHeaders = Vec<(Vec<u8>, Vec<u8>)>;

/// Validate a user-supplied RPC metadata key before mapping it to HTTP/3.
///
/// Metadata keys become HTTP field names, so pseudo-headers and Knafeh's
/// reserved control headers must not be user controlled.
pub fn validate_metadata_key(key: &str) -> Result<(), KnafehError> {
    if key.is_empty() {
        return Err(KnafehError::InvalidMessage(
            "metadata key cannot be empty".to_string(),
        ));
    }

    if key.starts_with(':') {
        return Err(KnafehError::InvalidMessage(format!(
            "metadata key '{key}' cannot be an HTTP pseudo-header"
        )));
    }

    if is_reserved_metadata_key(key) {
        return Err(KnafehError::InvalidMessage(format!(
            "metadata key '{key}' is reserved"
        )));
    }

    if !key.bytes().all(is_http_token_byte) {
        return Err(KnafehError::InvalidMessage(format!(
            "metadata key '{key}' contains invalid HTTP header characters"
        )));
    }

    Ok(())
}

fn is_reserved_metadata_key(key: &str) -> bool {
    matches!(
        key,
        RPC_STATUS_HEADER
            | RPC_STATUS_MESSAGE_HEADER
            | RPC_METHOD_KIND_HEADER
            | "status"
            | "status-message"
            | "method-kind"
    )
}

fn is_http_token_byte(byte: u8) -> bool {
    matches!(
        byte,
        b'a'..=b'z'
            | b'A'..=b'Z'
            | b'0'..=b'9'
            | b'!'
            | b'#'
            | b'$'
            | b'%'
            | b'&'
            | b'\''
            | b'*'
            | b'+'
            | b'-'
            | b'.'
            | b'^'
            | b'_'
            | b'`'
            | b'|'
            | b'~'
    )
}

/// Convert RPC metadata to HTTP/3 header list.
///
/// User metadata keys are prefixed with `x-rpc-` to avoid clashes with
/// standard HTTP headers.
pub fn metadata_to_headers(metadata: &Metadata) -> Result<Vec<(String, String)>, KnafehError> {
    metadata
        .iter()
        .map(|(k, v)| {
            validate_metadata_key(k)?;
            if k.starts_with("x-rpc-") {
                Ok((k.clone(), v.clone()))
            } else {
                Ok((format!("{RPC_HEADER_PREFIX}{k}"), v.clone()))
            }
        })
        .collect()
}

/// Extract RPC metadata from HTTP/3 headers.
///
/// Strips the `x-rpc-` prefix from user metadata keys.
pub fn headers_to_metadata(headers: &[(String, String)]) -> Metadata {
    let mut metadata = HashMap::new();
    for (key, value) in headers {
        if key == RPC_STATUS_HEADER || key == RPC_STATUS_MESSAGE_HEADER {
            metadata.insert(key.clone(), value.clone());
        } else if let Some(stripped) = key.strip_prefix(RPC_HEADER_PREFIX) {
            metadata.insert(stripped.to_string(), value.clone());
        }
        // Ignore standard HTTP headers (non x-rpc- prefixed).
    }
    metadata
}

/// Build the HTTP/3 request headers for an RPC call.
pub fn build_request_headers(
    method_path: &str,
    content_type: &str,
    metadata: &Metadata,
    method_kind: &str,
) -> Result<RawHeaders, KnafehError> {
    let mut headers = vec![
        (b":method".to_vec(), b"POST".to_vec()),
        (b":path".to_vec(), format!("/{method_path}").into_bytes()),
        (b":scheme".to_vec(), b"https".to_vec()),
        (b"content-type".to_vec(), content_type.as_bytes().to_vec()),
        (
            RPC_METHOD_KIND_HEADER.as_bytes().to_vec(),
            method_kind.as_bytes().to_vec(),
        ),
    ];

    for (key, value) in metadata {
        validate_metadata_key(key)?;
        let header_key = if key.starts_with("x-rpc-") {
            key.clone()
        } else {
            format!("{RPC_HEADER_PREFIX}{key}")
        };
        headers.push((header_key.into_bytes(), value.clone().into_bytes()));
    }

    Ok(headers)
}

/// Build the HTTP/3 response headers for an RPC response.
pub fn build_response_headers(
    status_code: u8,
    status_message: &str,
    content_type: &str,
    metadata: &Metadata,
) -> Result<RawHeaders, KnafehError> {
    let mut headers = vec![
        (b":status".to_vec(), b"200".to_vec()),
        (b"content-type".to_vec(), content_type.as_bytes().to_vec()),
        (
            RPC_STATUS_HEADER.as_bytes().to_vec(),
            status_code.to_string().into_bytes(),
        ),
        (
            RPC_STATUS_MESSAGE_HEADER.as_bytes().to_vec(),
            status_message.as_bytes().to_vec(),
        ),
    ];

    for (key, value) in metadata {
        validate_metadata_key(key)?;
        let header_key = if key.starts_with("x-rpc-") {
            key.clone()
        } else {
            format!("{RPC_HEADER_PREFIX}{key}")
        };
        headers.push((header_key.into_bytes(), value.clone().into_bytes()));
    }

    Ok(headers)
}

/// Extract the method path from HTTP/3 pseudo-header `:path`.
///
/// Strips the leading `/` — e.g., `/greeter/say_hello` → `greeter/say_hello`.
pub fn extract_method_path(path: &[u8]) -> Result<String, KnafehError> {
    let path_str = std::str::from_utf8(path)
        .map_err(|e| KnafehError::InvalidMessage(format!("invalid UTF-8 in :path: {e}")))?;

    let trimmed = path_str.strip_prefix('/').unwrap_or(path_str);

    if !trimmed.contains('/') {
        return Err(KnafehError::InvalidMessage(format!(
            "invalid RPC path (expected 'service/method'): {trimmed}"
        )));
    }

    Ok(trimmed.to_string())
}