Skip to main content

coil_wasm/output/abi/
mod.rs

1use std::collections::BTreeMap;
2
3use crate::error::WasmModelError;
4use crate::ids::ExtensionPointKind;
5use crate::validation::require_non_empty;
6
7use super::cache::TypedCacheHint;
8use super::metadata::TypedMetadata;
9
10mod codec;
11mod cursor;
12mod mapping;
13
14pub(super) const ABI_MAGIC: [u8; 4] = *b"CLRO";
15pub(super) const ABI_VERSION: u16 = 1;
16
17#[derive(Debug, Clone, PartialEq, Eq)]
18pub enum TypedResponseBody {
19    HtmlDocument(String),
20    HtmlFragment(String),
21    JsonObject(BTreeMap<String, String>),
22}
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub(crate) enum TypedResponseBodyKind {
26    HtmlDocument,
27    HtmlFragment,
28    JsonObject,
29}
30
31impl std::fmt::Display for TypedResponseBodyKind {
32    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
33        match self {
34            Self::HtmlDocument => f.write_str("html_document"),
35            Self::HtmlFragment => f.write_str("html_fragment"),
36            Self::JsonObject => f.write_str("json_object"),
37        }
38    }
39}
40
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub struct TypedExecutionOutput {
43    pub surface: ExtensionPointKind,
44    pub status: u16,
45    pub body: TypedResponseBody,
46    pub metadata: TypedMetadata,
47    pub cache_hint: Option<TypedCacheHint>,
48}
49
50impl TypedExecutionOutput {
51    pub const ABI_EXPORT: &'static str = "__coil_typed_output";
52
53    pub fn page(
54        status: u16,
55        body: impl Into<String>,
56        metadata: TypedMetadata,
57        cache_hint: Option<TypedCacheHint>,
58    ) -> Result<Self, WasmModelError> {
59        Self::new(
60            ExtensionPointKind::Page,
61            status,
62            TypedResponseBody::HtmlDocument(require_non_empty("page_body", body.into())?),
63            metadata,
64            cache_hint,
65        )
66    }
67
68    pub fn api(
69        status: u16,
70        payload: BTreeMap<String, String>,
71        metadata: TypedMetadata,
72        cache_hint: Option<TypedCacheHint>,
73    ) -> Result<Self, WasmModelError> {
74        Self::new(
75            ExtensionPointKind::Api,
76            status,
77            TypedResponseBody::JsonObject(payload),
78            metadata,
79            cache_hint,
80        )
81    }
82
83    pub fn admin_widget(
84        status: u16,
85        fragment: impl Into<String>,
86        metadata: TypedMetadata,
87        cache_hint: Option<TypedCacheHint>,
88    ) -> Result<Self, WasmModelError> {
89        Self::new(
90            ExtensionPointKind::AdminWidget,
91            status,
92            TypedResponseBody::HtmlFragment(require_non_empty(
93                "admin_widget_fragment",
94                fragment.into(),
95            )?),
96            metadata,
97            cache_hint,
98        )
99    }
100
101    pub fn render_hook(
102        status: u16,
103        fragment: impl Into<String>,
104        metadata: TypedMetadata,
105        cache_hint: Option<TypedCacheHint>,
106    ) -> Result<Self, WasmModelError> {
107        Self::new(
108            ExtensionPointKind::RenderHook,
109            status,
110            TypedResponseBody::HtmlFragment(require_non_empty(
111                "render_hook_fragment",
112                fragment.into(),
113            )?),
114            metadata,
115            cache_hint,
116        )
117    }
118
119    pub(crate) fn new(
120        surface: ExtensionPointKind,
121        status: u16,
122        body: TypedResponseBody,
123        metadata: TypedMetadata,
124        cache_hint: Option<TypedCacheHint>,
125    ) -> Result<Self, WasmModelError> {
126        let output = Self {
127            surface,
128            status,
129            body,
130            metadata,
131            cache_hint,
132        };
133        output.validate_for_point(surface)?;
134        Ok(output)
135    }
136
137    pub fn decode_page(bytes: &[u8]) -> Result<Self, WasmModelError> {
138        Self::decode_for_point(bytes, ExtensionPointKind::Page)
139    }
140
141    pub fn decode_api(bytes: &[u8]) -> Result<Self, WasmModelError> {
142        Self::decode_for_point(bytes, ExtensionPointKind::Api)
143    }
144
145    pub fn decode_admin_widget(bytes: &[u8]) -> Result<Self, WasmModelError> {
146        Self::decode_for_point(bytes, ExtensionPointKind::AdminWidget)
147    }
148
149    pub fn decode_render_hook(bytes: &[u8]) -> Result<Self, WasmModelError> {
150        Self::decode_for_point(bytes, ExtensionPointKind::RenderHook)
151    }
152
153    pub(crate) fn decode_for_point(
154        bytes: &[u8],
155        point: ExtensionPointKind,
156    ) -> Result<Self, WasmModelError> {
157        let output = codec::decode_output(bytes)?;
158        output.validate_for_point(point)?;
159        Ok(output)
160    }
161
162    pub(crate) fn validate_for_point(
163        &self,
164        point: ExtensionPointKind,
165    ) -> Result<(), WasmModelError> {
166        if self.surface != point {
167            return Err(WasmModelError::TypedReturnPointMismatch {
168                expected: point,
169                actual: self.surface,
170            });
171        }
172
173        if let Some(expected_body_kind) = mapping::expected_body_kind_for_point(point) {
174            if self.body_kind() != expected_body_kind {
175                return Err(WasmModelError::TypedReturnBodyMismatch {
176                    point,
177                    body: self.body_kind().to_string(),
178                });
179            }
180        } else {
181            return Err(WasmModelError::TypedReturnBodyMismatch {
182                point,
183                body: self.body_kind().to_string(),
184            });
185        }
186
187        self.validate()
188    }
189
190    pub fn encode(&self) -> Result<Vec<u8>, WasmModelError> {
191        self.validate_for_point(self.surface)?;
192        codec::encode_output(self)
193    }
194
195    fn body_kind(&self) -> TypedResponseBodyKind {
196        match &self.body {
197            TypedResponseBody::HtmlDocument(_) => TypedResponseBodyKind::HtmlDocument,
198            TypedResponseBody::HtmlFragment(_) => TypedResponseBodyKind::HtmlFragment,
199            TypedResponseBody::JsonObject(_) => TypedResponseBodyKind::JsonObject,
200        }
201    }
202
203    fn validate(&self) -> Result<(), WasmModelError> {
204        mapping::validate_http_status(self.status)?;
205        match &self.body {
206            TypedResponseBody::HtmlDocument(html) | TypedResponseBody::HtmlFragment(html) => {
207                let _ = require_non_empty("typed_response_body", html.clone())?;
208            }
209            TypedResponseBody::JsonObject(payload) => {
210                for key in payload.keys() {
211                    let _ = require_non_empty("json_object_key", key.clone())?;
212                }
213            }
214        }
215        self.metadata.validate()?;
216        if let Some(cache) = &self.cache_hint {
217            cache.validate()?;
218        }
219        Ok(())
220    }
221}
222
223#[cfg(test)]
224mod tests;