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