pmcp_server_toolkit/workbook/
render_resource.rs1#![cfg_attr(
31 not(test),
32 deny(clippy::unwrap_used, clippy::expect_used, clippy::panic)
33)]
34
35use std::sync::Arc;
36
37use async_trait::async_trait;
38use base64::Engine;
39use pmcp::types::{Content, ListResourcesResult, ReadResourceResult, ResourceInfo};
40use pmcp::ResourceHandler;
41
42use pmcp_workbook_runtime::render::render_xlsx;
43
44use super::input::validate_input;
45use super::render_uri::{self, WORKBOOK_XLSX_MIME};
46use super::WorkbookBundle;
47
48pub const RENDER_RESOURCE_LIST_URI: &str = render_uri::RENDER_URI_PREFIX;
55
56pub struct RenderWorkbookResource {
61 bundle: Arc<WorkbookBundle>,
62}
63
64impl RenderWorkbookResource {
65 #[must_use]
67 pub fn new(bundle: Arc<WorkbookBundle>) -> Self {
68 Self { bundle }
69 }
70
71 fn list_entry(&self) -> ResourceInfo {
73 ResourceInfo::new(RENDER_RESOURCE_LIST_URI, "Rendered workbook (.xlsx)")
74 .with_description(
75 "Download the computed workbook as an .xlsx. Read a workbook://render/<...> \
76 URI minted by the render_workbook tool; the spreadsheet is regenerated \
77 statelessly from the URI on each read.",
78 )
79 .with_mime_type(WORKBOOK_XLSX_MIME)
80 }
81
82 fn regenerate(&self, uri: &str) -> Result<String, RegenError> {
86 let decoded = render_uri::decode(uri).map_err(|e| RegenError::BadUri(e.reason))?;
88 let lock = &self.bundle.stamp;
91 if decoded.provenance.bundle_id != lock.bundle_id
92 || decoded.provenance.version != lock.version
93 || decoded.provenance.combined_hash != lock.combined
94 {
95 return Err(RegenError::CrossProvenance);
96 }
97 let validated = validate_input(decoded.dto, &self.bundle.manifest, &self.bundle.cell_map)
99 .map_err(|e| RegenError::Invalid(e.reason))?;
100 let run = super::handler::run_bundle(&self.bundle, validated.seeds)
102 .map_err(|e| RegenError::Invalid(e.reason))?;
103 let bytes = render_xlsx(&self.bundle.layout, &run)
104 .map_err(|e| RegenError::Render(e.to_string()))?;
105 Ok(base64::engine::general_purpose::STANDARD.encode(bytes))
108 }
109}
110
111#[derive(Debug)]
116enum RegenError {
117 BadUri(String),
119 CrossProvenance,
121 Invalid(String),
123 Render(String),
125}
126
127impl RegenError {
128 fn into_protocol(self) -> pmcp::Error {
130 match self {
131 RegenError::BadUri(r) => pmcp::Error::protocol(
132 pmcp::ErrorCode::INVALID_PARAMS,
133 format!("invalid workbook:// resource URI: {r}"),
134 ),
135 RegenError::CrossProvenance => pmcp::Error::protocol(
136 pmcp::ErrorCode::INVALID_PARAMS,
137 "workbook:// URI provenance does not match the served bundle".to_string(),
138 ),
139 RegenError::Invalid(r) => pmcp::Error::protocol(
140 pmcp::ErrorCode::INVALID_PARAMS,
141 format!("workbook:// URI inputs failed re-validation: {r}"),
142 ),
143 RegenError::Render(r) => pmcp::Error::protocol(
144 pmcp::ErrorCode::INTERNAL_ERROR,
145 format!("workbook render failed: {r}"),
146 ),
147 }
148 }
149}
150
151#[async_trait]
152impl ResourceHandler for RenderWorkbookResource {
153 async fn list(
154 &self,
155 _cursor: Option<String>,
156 _extra: pmcp::RequestHandlerExtra,
157 ) -> pmcp::Result<ListResourcesResult> {
158 Ok(ListResourcesResult::new(vec![self.list_entry()]))
159 }
160
161 async fn read(
162 &self,
163 uri: &str,
164 _extra: pmcp::RequestHandlerExtra,
165 ) -> pmcp::Result<ReadResourceResult> {
166 let b64 = self.regenerate(uri).map_err(RegenError::into_protocol)?;
167 Ok(ReadResourceResult::new(vec![Content::resource_with_text(
170 uri.to_string(),
171 b64,
172 WORKBOOK_XLSX_MIME,
173 )]))
174 }
175}
176
177#[cfg(test)]
178mod tests {
179 use super::super::ProvStamp;
180 use super::*;
181 use std::path::{Path, PathBuf};
182
183 use pmcp_workbook_runtime::{load_bundle, LocalDirSource};
184 use serde_json::json;
185
186 fn golden_dir() -> PathBuf {
187 Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/tax-calc@1.1.0")
188 }
189
190 fn golden_bundle() -> Arc<WorkbookBundle> {
191 let source = LocalDirSource::new(golden_dir());
192 Arc::new(load_bundle(&source).expect("golden bundle boots"))
193 }
194
195 fn valid_uri(bundle: &Arc<WorkbookBundle>, inputs: serde_json::Value) -> String {
197 let validated = validate_input(inputs, &bundle.manifest, &bundle.cell_map)
198 .expect("inputs validate for fixture");
199 render_uri::encode(&validated.canonical_dto, &ProvStamp::from_bundle(bundle))
200 .expect("encode fixture uri")
201 }
202
203 #[test]
204 fn read_returns_base64_xlsx_and_is_byte_identical_across_reads() {
205 let bundle = golden_bundle();
206 let res = RenderWorkbookResource::new(bundle.clone());
207 let uri = valid_uri(
208 &bundle,
209 json!({ "inputs": { "gross_income": 60000.0, "filing_status": "single" } }),
210 );
211
212 let first = res.regenerate(&uri).expect("first read renders");
213 let second = res.regenerate(&uri).expect("second read renders");
214 let bytes = base64::engine::general_purpose::STANDARD
216 .decode(&first)
217 .expect("valid base64 xlsx");
218 assert_eq!(
220 &bytes[..2],
221 b"PK",
222 "rendered payload is an xlsx (ZIP) container"
223 );
224 assert_eq!(first, second, "regen-on-read is byte-identical (stateless)");
226 }
227
228 #[test]
229 fn cross_provenance_uri_errors_before_rendering() {
230 let bundle = golden_bundle();
231 let res = RenderWorkbookResource::new(bundle.clone());
232 let forged = ProvStamp {
234 bundle_id: "tax-calc".to_string(),
235 version: "1.1.0".to_string(),
236 combined_hash: "f".repeat(64), };
238 let dto = json!({ "inputs": { "gross_income": 60000.0, "filing_status": "single" }, "overrides": {} });
239 let uri = render_uri::encode(&dto, &forged).expect("encode forged uri");
240
241 let err = res.regenerate(&uri).expect_err("cross-provenance rejected");
242 assert!(
243 matches!(err, RegenError::CrossProvenance),
244 "rejected as cross-provenance BEFORE rendering, got {err:?}"
245 );
246 }
247
248 #[test]
249 fn out_of_range_decoded_input_errors_via_revalidation_not_render() {
250 let bundle = golden_bundle();
251 let res = RenderWorkbookResource::new(bundle.clone());
252 let dto = json!({ "inputs": { "filing_status": "alien" }, "overrides": {} });
255 let uri = render_uri::encode(&dto, &ProvStamp::from_bundle(&bundle))
256 .expect("encode out-of-range uri");
257
258 let err = res.regenerate(&uri).expect_err("out-of-range rejected");
259 assert!(
260 matches!(err, RegenError::Invalid(_)),
261 "rejected by re-validation (injection guard), not rendered: {err:?}"
262 );
263 }
264
265 #[test]
266 fn oversized_uri_errors_as_bad_uri() {
267 let bundle = golden_bundle();
268 let res = RenderWorkbookResource::new(bundle);
269 let oversized = format!(
270 "{}{}",
271 render_uri::RENDER_URI_PREFIX,
272 "A".repeat(render_uri::MAX_ENCODED_URI_LEN + 1)
273 );
274 let err = res.regenerate(&oversized).expect_err("oversized rejected");
275 assert!(matches!(err, RegenError::BadUri(_)), "size-guard rejection");
276 }
277
278 #[tokio::test]
279 async fn list_returns_the_single_workbook_resource_entry() {
280 let res = RenderWorkbookResource::new(golden_bundle());
281 let extra = pmcp::RequestHandlerExtra::default();
282 let listed = res.list(None, extra).await.expect("list");
283 assert_eq!(listed.resources.len(), 1, "exactly one resource (A3)");
284 assert_eq!(listed.resources[0].uri, RENDER_RESOURCE_LIST_URI);
285 assert_eq!(
286 listed.resources[0].mime_type.as_deref(),
287 Some(WORKBOOK_XLSX_MIME)
288 );
289 }
290
291 #[tokio::test]
292 async fn read_via_trait_returns_resource_content_with_xlsx_mime() {
293 let bundle = golden_bundle();
294 let res = RenderWorkbookResource::new(bundle.clone());
295 let uri = valid_uri(
296 &bundle,
297 json!({ "inputs": { "gross_income": 60000.0, "filing_status": "single" } }),
298 );
299 let extra = pmcp::RequestHandlerExtra::default();
300 let result = res.read(&uri, extra).await.expect("read renders");
301 assert_eq!(result.contents.len(), 1);
302 }
303}