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;