coil-runtime 0.1.1

HTTP runtime and request handling for the Coil framework.
Documentation
use super::*;
use crate::{FlashMessage, RuntimeBrowserError};
use coil_cache::{CacheModelError, CachePlan};
use std::collections::{BTreeMap, HashSet};
use thiserror::Error;

pub type RequestFieldMap = BTreeMap<String, Vec<String>>;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestInput {
    pub method: HttpMethod,
    pub host: String,
    pub path: String,
    pub headers: BTreeMap<String, String>,
    pub query_params: RequestFieldMap,
    pub form_fields: RequestFieldMap,
    pub content_type: Option<String>,
    pub raw_body: Vec<u8>,
    pub scheme: String,
    pub forwarded_proto: Option<String>,
    pub request_id: Option<String>,
    pub session_id: Option<String>,
    pub session_cookie: Option<String>,
    pub flash_cookie: Option<String>,
    pub csrf_token: Option<String>,
    pub csrf_action: Option<String>,
    pub maintenance_bypass_token: Option<String>,
    pub principal_id: Option<String>,
    pub principal_kind: RequestPrincipalKind,
    pub granted_capabilities: HashSet<coil_auth::Capability>,
}

impl RequestInput {
    pub fn new(
        method: HttpMethod,
        host: impl Into<String>,
        path: impl Into<String>,
    ) -> Result<Self, RouteBuildError> {
        Ok(Self {
            method,
            host: validate_host(host.into())?,
            path: validate_route_path(path.into())?,
            headers: BTreeMap::new(),
            query_params: RequestFieldMap::new(),
            form_fields: RequestFieldMap::new(),
            content_type: None,
            raw_body: Vec::new(),
            scheme: "https".to_string(),
            forwarded_proto: None,
            request_id: None,
            session_id: None,
            session_cookie: None,
            flash_cookie: None,
            csrf_token: None,
            csrf_action: None,
            maintenance_bypass_token: None,
            principal_id: None,
            principal_kind: RequestPrincipalKind::Anonymous,
            granted_capabilities: HashSet::new(),
        })
    }

    pub fn with_scheme(mut self, scheme: impl Into<String>) -> Self {
        self.scheme = scheme.into();
        self
    }

    pub fn with_forwarded_proto(mut self, proto: impl Into<String>) -> Self {
        self.forwarded_proto = Some(proto.into());
        self
    }

    pub fn with_request_id(mut self, request_id: impl Into<String>) -> Self {
        self.request_id = Some(request_id.into());
        self
    }

    pub fn with_session_id(mut self, session_id: impl Into<String>) -> Self {
        self.session_id = Some(session_id.into());
        self
    }

    pub fn with_session_cookie(mut self, session_cookie: impl Into<String>) -> Self {
        self.session_cookie = Some(session_cookie.into());
        self
    }

    pub fn with_flash_cookie(mut self, flash_cookie: impl Into<String>) -> Self {
        self.flash_cookie = Some(flash_cookie.into());
        self
    }

    pub fn with_csrf_token(mut self, csrf_token: impl Into<String>) -> Self {
        self.csrf_token = Some(csrf_token.into());
        self
    }

    pub fn with_csrf_action(mut self, csrf_action: impl Into<String>) -> Self {
        self.csrf_action = Some(csrf_action.into());
        self
    }

    pub fn with_maintenance_bypass_token(mut self, bypass_token: impl Into<String>) -> Self {
        self.maintenance_bypass_token = Some(bypass_token.into());
        self
    }

    pub fn with_principal(mut self, principal_id: impl Into<String>) -> Self {
        self.principal_id = Some(principal_id.into());
        self.principal_kind = RequestPrincipalKind::User;
        self
    }

    pub fn with_service_account_principal(mut self, principal_id: impl Into<String>) -> Self {
        self.principal_id = Some(principal_id.into());
        self.principal_kind = RequestPrincipalKind::ServiceAccount;
        self
    }

    pub fn with_query_param(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        push_request_field(&mut self.query_params, name.into(), value.into());
        self
    }

    pub fn with_form_field(mut self, name: impl Into<String>, value: impl Into<String>) -> Self {
        push_request_field(&mut self.form_fields, name.into(), value.into());
        self
    }

    pub fn with_query_params(mut self, params: RequestFieldMap) -> Self {
        for (name, values) in params {
            for value in values {
                push_request_field(&mut self.query_params, name.clone(), value);
            }
        }
        self
    }

    pub fn with_form_fields(mut self, fields: RequestFieldMap) -> Self {
        for (name, values) in fields {
            for value in values {
                push_request_field(&mut self.form_fields, name.clone(), value);
            }
        }
        self
    }

    pub fn with_headers(mut self, headers: BTreeMap<String, String>) -> Self {
        self.headers = headers;
        self
    }

    pub fn with_content_type(mut self, content_type: impl Into<String>) -> Self {
        self.content_type = Some(content_type.into());
        self
    }

    pub fn with_raw_body(mut self, raw_body: Vec<u8>) -> Self {
        self.raw_body = raw_body;
        self
    }

    pub fn query_param(&self, name: &str) -> Option<&str> {
        self.query_params
            .get(name)
            .and_then(|values| values.first().map(String::as_str))
    }

    pub fn form_field(&self, name: &str) -> Option<&str> {
        self.form_fields
            .get(name)
            .and_then(|values| values.first().map(String::as_str))
    }

    pub fn grant_capability(mut self, capability: coil_auth::Capability) -> Self {
        self.granted_capabilities.insert(capability);
        self
    }
}

pub(crate) fn push_request_field(fields: &mut RequestFieldMap, name: String, value: String) {
    if !request_field_name_is_valid(&name) || !request_field_value_is_valid(&value) {
        return;
    }

    fields.entry(name).or_default().push(value);
}

fn request_field_name_is_valid(value: &str) -> bool {
    !value.is_empty() && !value.chars().any(|ch| ch.is_control())
}

fn request_field_value_is_valid(value: &str) -> bool {
    !value
        .chars()
        .any(|ch| ch == '\0' || (ch.is_control() && !matches!(ch, '\n' | '\r' | '\t')))
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RequestPrincipalKind {
    Anonymous,
    User,
    ServiceAccount,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestTraceContext {
    pub request_id: String,
    pub transport_scheme: String,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SessionContext {
    pub session_id: Option<String>,
    pub resolved_from_cookie: bool,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PrincipalContext {
    pub principal_id: Option<String>,
    pub principal_kind: RequestPrincipalKind,
    pub granted_capabilities: HashSet<coil_auth::Capability>,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CacheDisposition {
    Public,
    Private,
    Uncacheable,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RequestExecution {
    pub customer_app: String,
    pub site_id: Option<String>,
    pub site_display_name: Option<String>,
    pub brand_name: Option<String>,
    pub method: HttpMethod,
    pub host: String,
    pub path: String,
    pub headers: BTreeMap<String, String>,
    pub query_params: RequestFieldMap,
    pub form_fields: RequestFieldMap,
    pub content_type: Option<String>,
    pub raw_body: Vec<u8>,
    pub route: ResolvedRoute,
    pub route_area: RouteArea,
    pub locale: String,
    pub trace: RequestTraceContext,
    pub session: SessionContext,
    pub principal: PrincipalContext,
    pub cache: CacheDisposition,
    pub cache_plan: ExecutedCachePlan,
    pub middleware: Vec<MiddlewareStage>,
    pub response: HandlerResponse,
    pub flash_messages: Vec<FlashMessage>,
    pub response_cookies: Vec<String>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecutedCachePlan {
    pub plan: CachePlan,
    pub headers: BTreeMap<String, String>,
}

#[derive(Debug, Error, PartialEq, Eq)]
pub enum RequestExecutionError {
    #[error("no route matches {method:?} {host}{path}")]
    RouteNotFound {
        method: HttpMethod,
        host: String,
        path: String,
    },
    #[error("route `{route}` requires a resolved session")]
    SessionRequired { route: String },
    #[error("route `{route}` requires capability `{capability}`")]
    CapabilityRequired {
        route: String,
        capability: coil_auth::Capability,
    },
    #[error("route `{route}` requires a CSRF token")]
    MissingCsrfToken { route: String },
    #[error("route `{route}` requires a session before CSRF can be validated")]
    MissingSessionForCsrf { route: String },
    #[error("route `{route}` supplied an invalid CSRF token")]
    InvalidCsrfToken { route: String },
    #[error("session cookie failed validation: {0}")]
    InvalidSessionCookie(String),
    #[error("flash cookie failed validation: {0}")]
    InvalidFlashCookie(String),
    #[error("session `{session_id}` is not present in the server-side store")]
    UnknownSession { session_id: String },
    #[error("session `{session_id}` has expired")]
    ExpiredSession { session_id: String },
    #[error("session `{session_id}` has been revoked")]
    RevokedSession { session_id: String },
    #[error("route `{route}` is disabled by maintenance mode")]
    MaintenanceMode { route: String },
    #[error("route `{route}` is disabled because feature flag `{feature_flag}` is not enabled")]
    FeatureFlagDisabled { route: String, feature_flag: String },
    #[error("route `{route}` has no registered handler")]
    HandlerNotRegistered { route: String },
    #[error(transparent)]
    Cache(#[from] CacheModelError),
}

impl RequestExecutionError {
    pub(crate) fn from_browser_error(error: RuntimeBrowserError) -> Self {
        match error {
            RuntimeBrowserError::InvalidSessionCookie { reason } => {
                Self::InvalidSessionCookie(reason)
            }
            RuntimeBrowserError::InvalidFlashCookie { reason } => Self::InvalidFlashCookie(reason),
            RuntimeBrowserError::UnknownSession { session_id } => {
                Self::UnknownSession { session_id }
            }
            RuntimeBrowserError::ExpiredSession { session_id } => {
                Self::ExpiredSession { session_id }
            }
            RuntimeBrowserError::RevokedSession { session_id } => {
                Self::RevokedSession { session_id }
            }
            other => Self::InvalidFlashCookie(other.to_string()),
        }
    }
}