From 680bbff0ce192c693da4fbd0256666d751737195 Mon Sep 17 00:00:00 2001 From: ajianaz Date: Thu, 17 Sep 2026 16:02:47 +0700 Subject: [PATCH] feat(api): multi-slide rendering (response_format=json + slide_index) POST /api/render previously always rendered slide 0 and returned raw PNG. Now: - response_format: "json" renders every slide and returns a JSON envelope {template, slides, width, height, data: [{index, png_base64}...]} - slide_index: N renders a specific slide with the default png response - empty slides array and out-of-range slide_index are rejected with 400 Docs: endpoints.md updated with field table + multi-slide example. Tests: 4 new integration tests (json multi-slide, slide_index png, out-of-range 400, empty slides 400) - 26/26 api_server suite passes, clippy -D warnings clean. --- CHANGELOG.md | 5 ++ docs/api/endpoints.md | 37 ++++++++++- src/server.rs | 148 ++++++++++++++++++++++++++++++++++++------ tests/api_server.rs | 108 ++++++++++++++++++++++++++++++ 4 files changed, 276 insertions(+), 22 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0baba40..f417b5c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Multi-slide HTTP API** — `POST /api/render` now accepts + `response_format: "json"` to render every slide of a carousel and return them + as base64 PNG entries with template dimensions, and `slide_index` to pick a + specific slide with the default binary PNG response. Empty slide arrays and + out-of-range indices are rejected with a 400. - **Inline text markup** — `*bold*`, `_italic_`, and `*color:#hex*...*color*` in markup-enabled text fields (opt-in via schema `options: ["markup"]`), rendered as styled `` runs with markup-aware line wrapping. Bundled Inter Italic, diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md index 9d3b775..2813dcf 100644 --- a/docs/api/endpoints.md +++ b/docs/api/endpoints.md @@ -44,6 +44,8 @@ Renders an image from a template. | `template` | string | Yes | Template name (e.g. `"stat-card"`) | | `data` | object | Yes | Template input data (brand + slides) | | `scale` | float | No | Scale factor (default: `1.0`) | +| `slide_index` | int | No | Zero-based slide to render with the default `png` format (default: `0`) | +| `response_format` | string | No | `"png"` (default, binary image) or `"json"` (all slides as base64 entries) | **Example:** @@ -71,11 +73,44 @@ Renders an image from a template. } ``` +**Multi-slide example** — render every slide of a carousel as base64 PNGs: + +```json +{ + "template": "carousel-default", + "response_format": "json", + "scale": 0.5, + "data": { + "brand": {"brand_name": "CodeCora"}, + "slides": [ + {"eyebrow": "s1", "headline": "Slide One", "body": "first"}, + {"eyebrow": "s2", "headline": "Slide Two", "body": "second"} + ] + } +} +``` + +The JSON response contains per-slide base64 PNGs: + +```json +{ + "template": "carousel-default", + "slides": 2, + "width": 540, + "height": 675, + "data": [ + {"index": 0, "png_base64": "iVBORw0KGgo..."}, + {"index": 1, "png_base64": "iVBORw0KGgo..."} + ] +} +``` + ### Responses #### 200 OK -Returns the rendered PNG image. +`response_format: "png"` (default) returns the rendered PNG image. With a +`slide_index`, that specific slide is rendered; without one, the first slide. | Header | Value | |--------|-------| diff --git a/src/server.rs b/src/server.rs index 726ac23..1f67728 100644 --- a/src/server.rs +++ b/src/server.rs @@ -41,12 +41,52 @@ pub struct RenderRequest { /// Scale factor (default 2.0). #[serde(default = "default_scale")] pub scale: f32, + /// Zero-based slide to render when responding with `image/png`. + /// Defaults to the first slide. Ignored by the `json` response format, + /// which renders every slide. + #[serde(default)] + pub slide_index: Option, + /// Response format: `png` (default, binary image) or `json` (rendered + /// slides as base64 PNG entries with metadata). + #[serde(default)] + pub response_format: Option, +} + +/// Response format for POST /api/render. +#[derive(Debug, Clone, Copy, Deserialize, PartialEq)] +#[serde(rename_all = "lowercase")] +pub enum ResponseFormat { + /// Raw PNG bytes (`image/png`). + Png, + /// JSON envelope with per-slide base64 PNGs (`application/json`). + Json, } fn default_scale() -> f32 { 2.0 } +/// One rendered slide in a JSON response. +#[derive(Debug, Serialize)] +pub struct RenderedSlide { + /// Zero-based slide index. + pub index: usize, + /// Base64-encoded PNG bytes. + pub png_base64: String, +} + +/// JSON response envelope for `response_format: "json"`. +#[derive(Debug, Serialize)] +pub struct RenderResponse { + pub template: String, + /// Rendered slide count. + pub slides: usize, + /// Dimensions of the template canvas (scale applied). + pub width: u32, + pub height: u32, + pub data: Vec, +} + /// Response for GET /api/health. #[derive(Debug, Serialize)] pub struct HealthResponse { @@ -185,13 +225,22 @@ async fn render_handler( State(state): State>, Json(req): Json, ) -> Response { + let format = req.response_format.unwrap_or(ResponseFormat::Png); log::info!( - "Render request: template={}, slides={}, scale={}", + "Render request: template={}, slides={}, scale={}, format={:?}", req.template, req.data.slides.len(), - req.scale + req.scale, + format ); + if req.data.slides.is_empty() { + return error_response( + StatusCode::BAD_REQUEST, + "Input data must contain at least one slide".into(), + ); + } + // Load template definition let tmpl = match template::load_template(&req.template) { Ok(t) => t, @@ -208,32 +257,89 @@ async fn render_handler( } }; - // Render first slide (API returns single PNG for simplicity). + // Which slides to render: all for json format, one for png format. + let slide_indices: Vec = match format { + ResponseFormat::Json => (0..req.data.slides.len()).collect(), + ResponseFormat::Png => { + let idx = req.slide_index.unwrap_or(0); + if idx >= req.data.slides.len() { + return error_response( + StatusCode::BAD_REQUEST, + format!( + "slide_index {} out of range (input has {} slide(s))", + idx, + req.data.slides.len() + ), + ); + } + vec![idx] + } + }; + // Blocking work (template IO, resvg, possibly remote image fetches) runs // on the blocking thread pool so the async runtime is never blocked. + let font_db = state.font_db.clone(); + let image_policy = state.image_policy; + let scale = req.scale; + let data = req.data; + let template_id = tmpl.id.clone(); + let dims = tmpl.dimensions.clone(); let render_result = tokio::task::spawn_blocking(move || { - render::render_slide_to_png( - &tmpl, - &template_dir, - &req.data, - 0, - req.scale, - &state.font_db, - state.image_policy, - ) + slide_indices + .into_iter() + .map(|i| { + let png = render::render_slide_to_png( + &tmpl, + &template_dir, + &data, + i, + scale, + &font_db, + image_policy, + )?; + Ok((i, png)) + }) + .collect::)>>>() }) .await; match render_result { - Ok(Ok(png_bytes)) => { - log::info!("Rendered {} bytes of PNG", png_bytes.len()); - ( - StatusCode::OK, - [(header::CONTENT_TYPE, "image/png")], - png_bytes, - ) - .into_response() - } + Ok(Ok(rendered)) => match format { + ResponseFormat::Png => { + let (_, png_bytes) = rendered.into_iter().next().expect("one slide rendered"); + log::info!("Rendered {} bytes of PNG", png_bytes.len()); + ( + StatusCode::OK, + [(header::CONTENT_TYPE, "image/png")], + png_bytes, + ) + .into_response() + } + ResponseFormat::Json => { + let slides_json: Vec = rendered + .into_iter() + .map(|(i, png)| RenderedSlide { + index: i, + png_base64: base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + &png, + ), + }) + .collect(); + log::info!("Rendered {} slide(s) as JSON", slides_json.len()); + ( + StatusCode::OK, + Json(RenderResponse { + template: template_id, + slides: slides_json.len(), + width: (dims.width as f32 * scale) as u32, + height: (dims.height as f32 * scale) as u32, + data: slides_json, + }), + ) + .into_response() + } + }, Ok(Err(e)) => error_response( StatusCode::INTERNAL_SERVER_ERROR, format!("Render error: {e:#}"), diff --git a/tests/api_server.rs b/tests/api_server.rs index d754e66..6ef441b 100644 --- a/tests/api_server.rs +++ b/tests/api_server.rs @@ -221,6 +221,114 @@ fn test_render_default_scale() { assert!(bytes.len() > 5000, "2x render should be substantial"); } +#[test] +fn test_render_json_multi_slide() { + let url = start_server(); + // response_format=json renders ALL slides and returns a JSON envelope + let body = serde_json::json!({ + "template": "carousel-default", + "response_format": "json", + "scale": 0.5, + "data": { + "brand": {"brand_name": "Multi Test"}, + "slides": [ + {"eyebrow": "s1", "headline": "Slide One", "body": "first"}, + {"eyebrow": "s2", "headline": "Slide Two", "body": "second"}, + {"eyebrow": "s3", "headline": "Slide Three", "body": "third"} + ] + } + }); + let resp = http_client() + .post(format!("{}/api/render", url)) + .json(&body) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + let json: serde_json::Value = resp.json().unwrap(); + assert_eq!(json["template"], "carousel-default"); + assert_eq!(json["slides"], 3); + assert_eq!(json["data"].as_array().unwrap().len(), 3); + for (i, slide) in json["data"].as_array().unwrap().iter().enumerate() { + assert_eq!(slide["index"], i); + let b64 = slide["png_base64"].as_str().unwrap(); + assert!(b64.len() > 1000, "slide {} png should be substantial", i); + } + // Slide dimensions: 1080x1350 at 0.5 scale = 540x675 + assert_eq!(json["width"], 540); + assert_eq!(json["height"], 675); +} + +#[test] +fn test_render_slide_index_png() { + let url = start_server(); + // slide_index picks a specific slide with png format (default) + let body = serde_json::json!({ + "template": "carousel-default", + "slide_index": 1, + "scale": 0.5, + "data": { + "brand": {"brand_name": "Index Test"}, + "slides": [ + {"eyebrow": "s1", "headline": "Slide One", "body": "first"}, + {"eyebrow": "s2", "headline": "Slide Two", "body": "second"} + ] + } + }); + let resp = http_client() + .post(format!("{}/api/render", url)) + .json(&body) + .send() + .unwrap(); + assert_eq!(resp.status(), 200); + assert_eq!(resp.headers()["content-type"], "image/png"); + let bytes = resp.bytes().unwrap(); + assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n"); +} + +#[test] +fn test_render_slide_index_out_of_range() { + let url = start_server(); + let body = serde_json::json!({ + "template": "carousel-default", + "slide_index": 5, + "scale": 0.5, + "data": { + "brand": {"brand_name": "Range Test"}, + "slides": [ + {"eyebrow": "s1", "headline": "Only", "body": "one"} + ] + } + }); + let resp = http_client() + .post(format!("{}/api/render", url)) + .json(&body) + .send() + .unwrap(); + assert_eq!(resp.status(), 400); + let json: serde_json::Value = resp.json().unwrap(); + assert!(json["error"].as_str().unwrap().contains("out of range")); +} + +#[test] +fn test_render_empty_slides_rejected() { + let url = start_server(); + let body = serde_json::json!({ + "template": "carousel-default", + "data": {"brand": {"brand_name": "Empty"}, "slides": []} + }); + let resp = http_client() + .post(format!("{}/api/render", url)) + .json(&body) + .send() + .unwrap(); + assert_eq!(resp.status(), 400); + let json: serde_json::Value = resp.json().unwrap(); + assert!(json["error"] + .as_str() + .unwrap() + .contains("at least one slide")); +} + #[test] fn test_render_nonexistent_template() { let url = start_server();