coil-runtime 0.1.0

HTTP runtime and request handling for the Coil framework.
Documentation
use super::graph::LiveJsonResponseGraph;
use super::*;
use axum::http::StatusCode;
use std::collections::BTreeMap;

#[derive(Debug, Clone, Default)]
pub(crate) struct LiveExecutionReceipts {
    request_surface: Option<ExecutionReceipt>,
    render_hooks: Vec<ExecutionReceipt>,
    admin_widgets: Vec<ExecutionReceipt>,
}

impl LiveExecutionReceipts {
    pub(crate) fn collect(
        plan: &RuntimePlan,
        wasm_host: &WasmHost,
        execution: &RequestExecution,
    ) -> Result<Self, RuntimeServerError> {
        let request_surface = wasm_host.execute_request_surface(execution)?;

        let render_hooks = if matches!(
            execution.response,
            HandlerResponse::Page(_) | HandlerResponse::Fragment(_)
        ) {
            let mut receipts = Vec::new();
            for slot in render_hook_slots_for_execution(plan, execution) {
                receipts.extend(wasm_host.execute_render_hook_slot(slot.as_str(), execution)?);
            }
            receipts
        } else {
            Vec::new()
        };

        let admin_widgets = if execution.route_area == RouteArea::Admin {
            let mut receipts = Vec::new();
            for slot in admin_widget_slots_for_execution(plan, execution) {
                receipts.extend(wasm_host.execute_admin_widget_slot(slot.as_str(), execution)?);
            }
            receipts
        } else {
            Vec::new()
        };

        Ok(Self {
            request_surface,
            render_hooks,
            admin_widgets,
        })
    }

    pub(crate) fn request_surface_output(&self) -> Option<&TypedExecutionOutput> {
        self.request_surface
            .as_ref()
            .and_then(|receipt| receipt.typed_output.as_ref())
    }

    pub(crate) fn response_status(&self, base: StatusCode) -> StatusCode {
        self.request_surface_output()
            .and_then(|output| StatusCode::from_u16(output.status).ok())
            .unwrap_or(base)
    }

    pub(crate) fn merged_metadata(&self) -> Option<TypedMetadata> {
        let mut merged: Option<TypedMetadata> = None;
        for output in self.typed_outputs() {
            if let Some(metadata) = &mut merged {
                metadata.merge_from(&output.metadata);
            } else {
                merged = Some(output.metadata.clone());
            }
        }
        merged
    }

    pub(crate) fn merged_cache_hint(&self) -> Option<TypedCacheHint> {
        let mut merged: Option<TypedCacheHint> = None;
        for output in self.typed_outputs() {
            if let Some(cache_hint) = &output.cache_hint {
                if let Some(existing) = &mut merged {
                    existing.merge_from(cache_hint);
                } else {
                    merged = Some(cache_hint.clone());
                }
            }
        }
        merged
    }

    pub(crate) fn as_annotations(&self) -> LiveResponseAnnotations {
        LiveResponseAnnotations::default()
            .request_surface(self.request_surface.clone())
            .render_hooks(self.render_hooks.clone())
            .admin_widgets(self.admin_widgets.clone())
            .metadata(self.merged_metadata())
            .cache_hint(self.merged_cache_hint())
    }

    pub(crate) fn compose_response(
        &self,
        plan: &RuntimePlan,
        execution: &RequestExecution,
    ) -> Result<LiveResponseComposition, RuntimeServerError> {
        let metadata = self.merged_metadata();
        let cache_hint = self.merged_cache_hint();
        let annotations = self
            .as_annotations()
            .cache_headers(LiveCacheHeaders::from_parts(
                execution.cache_plan.headers.clone(),
                cache_hint.as_ref(),
            ))
            .route(execution.route.route_name.clone())
            .locale(execution.locale.clone());

        let mut response = match &execution.response {
            HandlerResponse::Page(page) => {
                let html = plan.render_page_response(execution, page, metadata.as_ref())?;
                let status = self
                    .response_status(StatusCode::from_u16(page.status).unwrap_or(StatusCode::OK));
                LiveResponseComposition::html(status, self.compose_html_graph(html))
            }
            HandlerResponse::Fragment(fragment) => {
                let html = plan.render_fragment_response(execution, fragment)?;
                let status = self.response_status(StatusCode::OK);
                LiveResponseComposition::html(status, self.compose_html_graph(html))
            }
            HandlerResponse::Redirect(redirect) => {
                let status = StatusCode::from_u16(redirect.status).unwrap_or(StatusCode::SEE_OTHER);
                LiveResponseComposition::redirect(status, redirect.location.clone())
            }
            HandlerResponse::Json(json) => {
                let payload = self.compose_json_payload(json.payload.clone());
                let status = self
                    .response_status(StatusCode::from_u16(json.status).unwrap_or(StatusCode::OK));
                LiveResponseComposition::json(status, payload)
            }
            HandlerResponse::File(file) => LiveResponseComposition::file(
                StatusCode::OK,
                file.logical_path.clone(),
                file.content_type.clone(),
                file.delivery_mode,
            ),
        };

        response = response.with_annotation(annotations);

        for cookie in execution.response_cookies.clone() {
            response = response.with_cookie(cookie);
        }

        Ok(response)
    }

    fn compose_html_graph(&self, html: String) -> LiveHtmlResponseGraph {
        let mut graph = LiveHtmlResponseGraph::new(html);
        graph = graph.with_request_surface(self.request_surface_output());
        for receipt in &self.render_hooks {
            graph = graph.with_render_hook(
                receipt.handler_id.to_string(),
                receipt.typed_output.as_ref(),
            );
        }
        for receipt in &self.admin_widgets {
            graph = graph.with_admin_widget(
                receipt.handler_id.to_string(),
                receipt.typed_output.as_ref(),
            );
        }
        graph
    }

    fn compose_json_payload(&self, json: BTreeMap<String, String>) -> BTreeMap<String, String> {
        let graph =
            LiveJsonResponseGraph::new(json).with_request_surface(self.request_surface_output());
        graph.render()
    }

    fn typed_outputs(&self) -> Vec<&TypedExecutionOutput> {
        let mut outputs = Vec::new();
        if let Some(receipt) = self.request_surface_output() {
            outputs.push(receipt);
        }
        for receipt in &self.render_hooks {
            if let Some(output) = receipt.typed_output.as_ref() {
                outputs.push(output);
            }
        }
        for receipt in &self.admin_widgets {
            if let Some(output) = receipt.typed_output.as_ref() {
                outputs.push(output);
            }
        }
        outputs
    }
}

fn render_hook_slots_for_execution(
    plan: &RuntimePlan,
    execution: &RequestExecution,
) -> Vec<String> {
    let module = plan
        .http
        .routes
        .iter()
        .find(|route| route.name == execution.route.route_name)
        .and_then(|route| route.module.as_deref());

    plan.registered_extension_slots
        .iter()
        .filter(|slot| {
            slot.kind == ExtensionPointKind::RenderHook && Some(slot.module.as_str()) == module
        })
        .map(|slot| slot.surface.clone())
        .collect()
}

fn admin_widget_slots_for_execution(
    plan: &RuntimePlan,
    execution: &RequestExecution,
) -> Vec<String> {
    let module = plan
        .http
        .routes
        .iter()
        .find(|route| route.name == execution.route.route_name)
        .and_then(|route| route.module.as_deref());

    plan.registered_extension_slots
        .iter()
        .filter(|slot| {
            slot.kind == ExtensionPointKind::AdminWidget && Some(slot.module.as_str()) == module
        })
        .map(|slot| slot.surface.clone())
        .collect()
}