coil-wasm 0.1.1

WASM extension runtime and host APIs for the Coil framework.
Documentation
use std::collections::BTreeMap;

use crate::error::WasmModelError;
use crate::ids::ExtensionPointKind;
use crate::validation::require_non_empty;

use super::cache::TypedCacheHint;
use super::metadata::TypedMetadata;

mod codec;
mod cursor;
mod mapping;

pub(super) const ABI_MAGIC: [u8; 4] = *b"CLRO";
pub(super) const ABI_VERSION: u16 = 1;

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TypedResponseBody {
    HtmlDocument(String),
    HtmlFragment(String),
    JsonObject(BTreeMap<String, String>),
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum TypedResponseBodyKind {
    HtmlDocument,
    HtmlFragment,
    JsonObject,
}

impl std::fmt::Display for TypedResponseBodyKind {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::HtmlDocument => f.write_str("html_document"),
            Self::HtmlFragment => f.write_str("html_fragment"),
            Self::JsonObject => f.write_str("json_object"),
        }
    }
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TypedExecutionOutput {
    pub surface: ExtensionPointKind,
    pub status: u16,
    pub body: TypedResponseBody,
    pub metadata: TypedMetadata,
    pub cache_hint: Option<TypedCacheHint>,
}

impl TypedExecutionOutput {
    pub const ABI_EXPORT: &'static str = "__coil_typed_output";

    pub fn page(
        status: u16,
        body: impl Into<String>,
        metadata: TypedMetadata,
        cache_hint: Option<TypedCacheHint>,
    ) -> Result<Self, WasmModelError> {
        Self::new(
            ExtensionPointKind::Page,
            status,
            TypedResponseBody::HtmlDocument(require_non_empty("page_body", body.into())?),
            metadata,
            cache_hint,
        )
    }

    pub fn api(
        status: u16,
        payload: BTreeMap<String, String>,
        metadata: TypedMetadata,
        cache_hint: Option<TypedCacheHint>,
    ) -> Result<Self, WasmModelError> {
        Self::new(
            ExtensionPointKind::Api,
            status,
            TypedResponseBody::JsonObject(payload),
            metadata,
            cache_hint,
        )
    }

    pub fn admin_widget(
        status: u16,
        fragment: impl Into<String>,
        metadata: TypedMetadata,
        cache_hint: Option<TypedCacheHint>,
    ) -> Result<Self, WasmModelError> {
        Self::new(
            ExtensionPointKind::AdminWidget,
            status,
            TypedResponseBody::HtmlFragment(require_non_empty(
                "admin_widget_fragment",
                fragment.into(),
            )?),
            metadata,
            cache_hint,
        )
    }

    pub fn render_hook(
        status: u16,
        fragment: impl Into<String>,
        metadata: TypedMetadata,
        cache_hint: Option<TypedCacheHint>,
    ) -> Result<Self, WasmModelError> {
        Self::new(
            ExtensionPointKind::RenderHook,
            status,
            TypedResponseBody::HtmlFragment(require_non_empty(
                "render_hook_fragment",
                fragment.into(),
            )?),
            metadata,
            cache_hint,
        )
    }

    pub(crate) fn new(
        surface: ExtensionPointKind,
        status: u16,
        body: TypedResponseBody,
        metadata: TypedMetadata,
        cache_hint: Option<TypedCacheHint>,
    ) -> Result<Self, WasmModelError> {
        let output = Self {
            surface,
            status,
            body,
            metadata,
            cache_hint,
        };
        output.validate_for_point(surface)?;
        Ok(output)
    }

    pub fn decode_page(bytes: &[u8]) -> Result<Self, WasmModelError> {
        Self::decode_for_point(bytes, ExtensionPointKind::Page)
    }

    pub fn decode_api(bytes: &[u8]) -> Result<Self, WasmModelError> {
        Self::decode_for_point(bytes, ExtensionPointKind::Api)
    }

    pub fn decode_admin_widget(bytes: &[u8]) -> Result<Self, WasmModelError> {
        Self::decode_for_point(bytes, ExtensionPointKind::AdminWidget)
    }

    pub fn decode_render_hook(bytes: &[u8]) -> Result<Self, WasmModelError> {
        Self::decode_for_point(bytes, ExtensionPointKind::RenderHook)
    }

    pub(crate) fn decode_for_point(
        bytes: &[u8],
        point: ExtensionPointKind,
    ) -> Result<Self, WasmModelError> {
        let output = codec::decode_output(bytes)?;
        output.validate_for_point(point)?;
        Ok(output)
    }

    pub(crate) fn validate_for_point(
        &self,
        point: ExtensionPointKind,
    ) -> Result<(), WasmModelError> {
        if self.surface != point {
            return Err(WasmModelError::TypedReturnPointMismatch {
                expected: point,
                actual: self.surface,
            });
        }

        if let Some(expected_body_kind) = mapping::expected_body_kind_for_point(point) {
            if self.body_kind() != expected_body_kind {
                return Err(WasmModelError::TypedReturnBodyMismatch {
                    point,
                    body: self.body_kind().to_string(),
                });
            }
        } else {
            return Err(WasmModelError::TypedReturnBodyMismatch {
                point,
                body: self.body_kind().to_string(),
            });
        }

        self.validate()
    }

    pub fn encode(&self) -> Result<Vec<u8>, WasmModelError> {
        self.validate_for_point(self.surface)?;
        codec::encode_output(self)
    }

    fn body_kind(&self) -> TypedResponseBodyKind {
        match &self.body {
            TypedResponseBody::HtmlDocument(_) => TypedResponseBodyKind::HtmlDocument,
            TypedResponseBody::HtmlFragment(_) => TypedResponseBodyKind::HtmlFragment,
            TypedResponseBody::JsonObject(_) => TypedResponseBodyKind::JsonObject,
        }
    }

    fn validate(&self) -> Result<(), WasmModelError> {
        mapping::validate_http_status(self.status)?;
        match &self.body {
            TypedResponseBody::HtmlDocument(html) | TypedResponseBody::HtmlFragment(html) => {
                let _ = require_non_empty("typed_response_body", html.clone())?;
            }
            TypedResponseBody::JsonObject(payload) => {
                for key in payload.keys() {
                    let _ = require_non_empty("json_object_key", key.clone())?;
                }
            }
        }
        self.metadata.validate()?;
        if let Some(cache) = &self.cache_hint {
            cache.validate()?;
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests;