coil-runtime 0.1.1

HTTP runtime and request handling for the Coil framework.
Documentation
use super::*;

impl RuntimePlan {
    pub fn execute_request(
        &self,
        request: RequestInput,
        cookie_secret: &[u8],
        csrf_secret: &[u8],
    ) -> Result<RequestExecution, RequestExecutionError> {
        let matched = self
            .http
            .resolve_match(&self.config, request.method, &request.host, &request.path)
            .ok_or_else(|| RequestExecutionError::RouteNotFound {
                method: request.method,
                host: request.host.clone(),
                path: request.path.clone(),
            })?;

        let trace = RequestTraceContext {
            request_id: request.request_id.clone().unwrap_or_else(|| {
                format!(
                    "req:{:?}:{}:{}",
                    request.method, request.host, matched.resolved.route_name
                )
            }),
            transport_scheme: request
                .forwarded_proto
                .clone()
                .unwrap_or_else(|| request.scheme.clone()),
        };

        let mut resolved_from_cookie = false;
        let session_id = if let Some(session_id) = request.session_id.clone() {
            Some(session_id)
        } else if let Some(cookie) = request.session_cookie.as_deref() {
            resolved_from_cookie = true;
            Some(self.verify_session_cookie(cookie_secret, cookie)?)
        } else {
            None
        };

        let session = SessionContext {
            session_id: session_id.clone(),
            resolved_from_cookie,
        };
        let principal = PrincipalContext {
            principal_id: request.principal_id.clone(),
            principal_kind: request.principal_kind,
            granted_capabilities: request.granted_capabilities.clone(),
        };
        let csrf_token = request.csrf_token.clone().or_else(|| {
            request
                .form_field(self.browser.csrf.field_name.as_str())
                .map(str::to_string)
        });

        self.enforce_maintenance_mode(&matched.route, request.method, &request)?;
        self.enforce_feature_flags(&matched.route, &matched.resolved)?;
        self.enforce_route_auth(&matched.resolved, &session, &principal)?;
        self.enforce_browser_policy(
            &matched.route,
            &matched.resolved,
            request.method,
            request.csrf_action.as_deref(),
            csrf_token.as_deref(),
            &session,
            csrf_secret,
        )?;
        let response = self
            .handlers
            .get(&matched.resolved.route_name)
            .cloned()
            .map(|handler| handler.response)
            .ok_or_else(|| RequestExecutionError::HandlerNotRegistered {
                route: matched.resolved.route_name.clone(),
            })?;
        let cache = cache_disposition_for_route(request.method, &matched.resolved.auth, &session);
        let cache_plan = build_execution_cache_plan(
            self,
            &request,
            &matched.route,
            &matched.resolved,
            &session,
            &principal,
            cache,
        )?;

        Ok(RequestExecution {
            customer_app: self.config.app.name.clone(),
            site_id: matched.resolved.site_id.clone(),
            site_display_name: matched
                .resolved
                .site_id
                .as_deref()
                .and_then(|site_id| self.config.site_for_id(site_id))
                .map(|site| site.display_name.clone()),
            brand_name: matched
                .resolved
                .site_id
                .as_deref()
                .and_then(|site_id| self.config.site_for_id(site_id))
                .and_then(|site| site.brand_name.clone()),
            method: request.method,
            host: request.host,
            path: request.path,
            headers: request.headers,
            query_params: request.query_params,
            form_fields: request.form_fields,
            content_type: request.content_type,
            raw_body: request.raw_body,
            route: matched.resolved.clone(),
            route_area: matched.route.area,
            locale: matched.resolved.locale.clone().unwrap_or_else(|| {
                matched
                    .resolved
                    .site_id
                    .as_deref()
                    .and_then(|site_id| self.config.site_for_id(site_id))
                    .map(|site| site.default_locale.clone())
                    .unwrap_or_else(|| self.config.i18n.default_locale.clone())
            }),
            trace,
            session: session.clone(),
            principal,
            cache,
            cache_plan,
            middleware: self.http.middleware.clone(),
            response,
            flash_messages: Vec::new(),
            response_cookies: Vec::new(),
        })
    }

    pub fn execute_browser_request(
        &self,
        browser: &mut BrowserHost,
        mut request: RequestInput,
        cookie_secret: &[u8],
        csrf_secret: &[u8],
        now: BrowserInstant,
    ) -> Result<RequestExecution, RequestExecutionError> {
        let resolved = browser
            .resolve_request(&request, cookie_secret, now)
            .map_err(RequestExecutionError::from_browser_error)?;

        request.session_id = resolved.session.session_id.clone();
        request.session_cookie = None;
        request.flash_cookie = None;

        if request.principal_id.is_none() {
            request.principal_id = resolved.principal_id.clone();
            if request.principal_id.is_some() {
                request.principal_kind = RequestPrincipalKind::User;
            }
        }

        let mut execution = self.execute_request(request, cookie_secret, csrf_secret)?;
        execution.session = resolved.session;
        if execution.principal.principal_id.is_none() {
            execution.principal.principal_id = resolved.principal_id;
            if execution.principal.principal_id.is_some() {
                execution.principal.principal_kind = RequestPrincipalKind::User;
            }
        }
        execution.flash_messages = resolved.flash_messages;
        execution.response_cookies = resolved.response_cookies;
        Ok(execution)
    }

    fn verify_session_cookie(
        &self,
        cookie_secret: &[u8],
        cookie: &str,
    ) -> Result<String, RequestExecutionError> {
        self.browser
            .sessions
            .session_cookie
            .unprotect(cookie_secret, cookie)
            .map_err(|error| RequestExecutionError::InvalidSessionCookie(error.to_string()))
    }

    fn enforce_route_auth(
        &self,
        route: &ResolvedRoute,
        session: &SessionContext,
        principal: &PrincipalContext,
    ) -> Result<(), RequestExecutionError> {
        match route.auth {
            RouteAuthGate::Public => Ok(()),
            RouteAuthGate::Session => {
                if session.session_id.is_some() {
                    Ok(())
                } else {
                    Err(RequestExecutionError::SessionRequired {
                        route: route.route_name.clone(),
                    })
                }
            }
            RouteAuthGate::Capability(capability) => {
                if session.session_id.is_none() {
                    return Err(RequestExecutionError::SessionRequired {
                        route: route.route_name.clone(),
                    });
                }

                if principal.granted_capabilities.contains(&capability) {
                    Ok(())
                } else {
                    Err(RequestExecutionError::CapabilityRequired {
                        route: route.route_name.clone(),
                        capability,
                    })
                }
            }
        }
    }

    fn enforce_feature_flags(
        &self,
        route: &RouteDefinition,
        resolved: &ResolvedRoute,
    ) -> Result<(), RequestExecutionError> {
        let Some(feature_flag) = route.feature_flag.as_deref() else {
            return Ok(());
        };

        let Some(feature_flag_id) = FeatureFlagId::new(feature_flag.to_string()).ok() else {
            return Err(RequestExecutionError::FeatureFlagDisabled {
                route: route.name.clone(),
                feature_flag: feature_flag.to_string(),
            });
        };
        let context = FeatureFlagContext {
            environment: self.config.app.environment,
            customer_app: CustomerAppId::new(self.config.app.name.clone()).ok(),
            site: resolved
                .site_id
                .as_deref()
                .and_then(|site| SiteId::new(site.to_string()).ok()),
            brand: resolved
                .site_id
                .as_deref()
                .and_then(|site_id| self.config.site_for_id(site_id))
                .and_then(|site| site.brand_name.as_deref())
                .and_then(|brand| BrandId::new(brand.to_string()).ok()),
            cohorts: BTreeSet::new(),
        };

        match self.observability.flags.get(&feature_flag_id) {
            Some(flag) if flag.enabled_for(&context) => Ok(()),
            _ => Err(RequestExecutionError::FeatureFlagDisabled {
                route: route.name.clone(),
                feature_flag: feature_flag.to_string(),
            }),
        }
    }

    fn enforce_maintenance_mode(
        &self,
        route: &RouteDefinition,
        method: HttpMethod,
        request: &RequestInput,
    ) -> Result<(), RequestExecutionError> {
        let customer_app = CustomerAppId::new(self.config.app.name.clone()).ok();
        let blocked = self.observability.maintenance.blocks_request(
            customer_app.as_ref(),
            method.is_state_changing(),
            request.maintenance_bypass_token.as_deref(),
        );

        if blocked {
            Err(RequestExecutionError::MaintenanceMode {
                route: route.name.clone(),
            })
        } else {
            Ok(())
        }
    }

    fn enforce_browser_policy(
        &self,
        route: &RouteDefinition,
        resolved: &ResolvedRoute,
        method: HttpMethod,
        csrf_action: Option<&str>,
        csrf_token: Option<&str>,
        session: &SessionContext,
        csrf_secret: &[u8],
    ) -> Result<(), RequestExecutionError> {
        let requires_csrf = method.is_state_changing()
            && route.area != RouteArea::Api
            && self.browser.csrf.enabled
            && !matches!(resolved.auth, RouteAuthGate::Public);

        if !requires_csrf {
            return Ok(());
        }

        let session_id = session.session_id.as_deref().ok_or_else(|| {
            RequestExecutionError::MissingSessionForCsrf {
                route: resolved.route_name.clone(),
            }
        })?;
        let token = csrf_token.ok_or_else(|| RequestExecutionError::MissingCsrfToken {
            route: resolved.route_name.clone(),
        })?;
        let action = csrf_action.unwrap_or(&resolved.route_name);
        let valid = self
            .browser
            .csrf
            .verify_token(csrf_secret, session_id, action, token)
            .map_err(|_| RequestExecutionError::InvalidCsrfToken {
                route: resolved.route_name.clone(),
            })?;

        if valid {
            Ok(())
        } else {
            Err(RequestExecutionError::InvalidCsrfToken {
                route: resolved.route_name.clone(),
            })
        }
    }
}