use std::collections::HashMap;
use crate::http::{HttpResponse, Response};
use ferro_json_ui::{
render_layout, render_to_html_with_plugins, resolve_actions, resolve_errors, JsonUiConfig,
JsonUiView, LayoutContext,
};
pub struct JsonUi;
impl JsonUi {
fn resolve(view: &JsonUiView) -> JsonUiView {
let mut resolved = view.clone();
resolve_actions(&mut resolved, |handler| crate::routing::route(handler, &[]));
resolved
}
pub fn render(view: &JsonUiView, data: &serde_json::Value) -> Response {
Self::render_with_config(view, data, &JsonUiConfig::new())
}
pub fn render_with_config(
view: &JsonUiView,
data: &serde_json::Value,
config: &JsonUiConfig,
) -> Response {
let resolved = Self::resolve(view);
Self::build_response(&resolved, data, config)
}
fn build_response(
view: &JsonUiView,
data: &serde_json::Value,
config: &JsonUiConfig,
) -> Response {
let view_json = serde_json::to_string(view).map_err(|e| {
HttpResponse::text(format!("JSON-UI serialization error: {e}")).status(500)
})?;
let data_json = serde_json::to_string(data).map_err(|e| {
HttpResponse::text(format!("JSON-UI data serialization error: {e}")).status(500)
})?;
let title = view.title.as_deref().unwrap_or("Ferro");
let mut head = String::new();
head.push_str(
"<link rel=\"preconnect\" href=\"https://fonts.bunny.net\">\
<link href=\"https://fonts.bunny.net/css?family=inter:300,400,500,600,700&display=swap\" rel=\"stylesheet\">",
);
if config.tailwind_cdn {
head.push_str(
r#"<script src="https://cdn.jsdelivr.net/npm/@tailwindcss/browser@4"></script>"#,
);
}
if let Some(custom) = &config.custom_head {
head.push_str(custom);
}
#[cfg(feature = "theme")]
{
if let Some(theme) = crate::theme::context::current_theme() {
head.push_str(&format!(
"<style type=\"text/tailwindcss\">{}</style>",
theme.css
));
}
}
let result = render_to_html_with_plugins(view, data);
let full_head = if result.css_head.is_empty() {
head
} else {
format!("{}{}", head, result.css_head)
};
let ctx = LayoutContext {
title,
content: &result.html,
head: &full_head,
body_class: &config.body_class,
view_json: &view_json,
data_json: &data_json,
scripts: &result.scripts,
};
let layout_name = view.layout.as_deref();
let html = render_layout(layout_name, &ctx);
Ok(HttpResponse::text(html)
.status(200)
.header("Content-Type", "text/html; charset=utf-8"))
}
pub fn render_json(view: &JsonUiView, data: &serde_json::Value) -> Response {
let view = Self::resolve(view);
let effective_data = if data.is_null() { &view.data } else { data };
let payload = serde_json::json!({
"view": view,
"data": effective_data,
});
Ok(HttpResponse::json(payload))
}
fn resolve_with_errors(view: &JsonUiView, errors: &HashMap<String, Vec<String>>) -> JsonUiView {
let mut resolved = view.clone();
resolve_actions(&mut resolved, |handler| crate::routing::route(handler, &[]));
resolve_errors(&mut resolved, errors);
resolved.errors = Some(errors.clone());
resolved
}
pub fn render_with_errors(
view: &JsonUiView,
data: &serde_json::Value,
errors: &HashMap<String, Vec<String>>,
) -> Response {
Self::render_with_errors_config(view, data, errors, &JsonUiConfig::new())
}
fn render_with_errors_config(
view: &JsonUiView,
data: &serde_json::Value,
errors: &HashMap<String, Vec<String>>,
config: &JsonUiConfig,
) -> Response {
let resolved = Self::resolve_with_errors(view, errors);
Self::build_response(&resolved, data, config)
}
pub fn render_json_with_errors(
view: &JsonUiView,
data: &serde_json::Value,
errors: &HashMap<String, Vec<String>>,
) -> Response {
let view = Self::resolve_with_errors(view, errors);
let effective_data = if data.is_null() { &view.data } else { data };
let payload = serde_json::json!({
"view": view,
"data": effective_data,
});
Ok(HttpResponse::json(payload))
}
pub fn render_validation_error(
view: &JsonUiView,
data: &serde_json::Value,
validation_error: &crate::validation::ValidationError,
) -> Response {
Self::render_with_errors(view, data, validation_error.all())
}
pub fn render_json_validation_error(
view: &JsonUiView,
data: &serde_json::Value,
validation_error: &crate::validation::ValidationError,
) -> Response {
Self::render_json_with_errors(view, data, validation_error.all())
}
}
#[cfg(test)]
fn html_escape(s: &str) -> String {
s.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
use ferro_json_ui::{
Action, ButtonProps, ButtonVariant, CardProps, Component, ComponentNode, HttpMethod, Size,
};
fn ok_response(result: Response) -> HttpResponse {
match result {
Ok(r) => r,
Err(_) => panic!("expected Ok response, got Err"),
}
}
fn response_body(response: HttpResponse) -> String {
let hyper = response.into_hyper();
let body_bytes = hyper.into_body();
format!("{body_bytes:?}")
}
fn sample_view() -> JsonUiView {
JsonUiView::new()
.title("Test Page")
.component(ComponentNode {
key: "card".to_string(),
component: Component::Card(CardProps {
title: "Hello".to_string(),
description: Some("A test card".to_string()),
children: vec![],
max_width: None,
footer: vec![],
}),
action: None,
visibility: None,
})
}
fn has_content_type(
hyper: &hyper::Response<http_body_util::Full<bytes::Bytes>>,
expected: &str,
) -> bool {
hyper
.headers()
.get_all("Content-Type")
.iter()
.any(|v| v.to_str().map(|s| s == expected).unwrap_or(false))
}
#[test]
fn render_produces_valid_html() {
let view = sample_view();
let data = serde_json::json!({});
let result = JsonUi::render(&view, &data);
assert!(result.is_ok());
let response = ok_response(result);
assert_eq!(response.status_code(), 200);
let hyper = response.into_hyper();
assert!(has_content_type(&hyper, "text/html; charset=utf-8"));
let body = format!("{:?}", hyper.into_body());
assert!(body.contains("<!DOCTYPE html>"));
assert!(body.contains("Test Page"));
assert!(body.contains("data-view="));
assert!(body.contains("data-props="));
}
#[test]
fn render_json_returns_json() {
let view = sample_view();
let data = serde_json::json!({"users": [1, 2, 3]});
let result = JsonUi::render_json(&view, &data);
assert!(result.is_ok());
let response = ok_response(result);
assert_eq!(response.status_code(), 200);
let hyper = response.into_hyper();
assert!(has_content_type(&hyper, "application/json"));
let body = format!("{:?}", hyper.into_body());
assert!(body.contains("view"));
assert!(body.contains("data"));
}
#[test]
fn config_tailwind_disabled() {
let view = sample_view();
let data = serde_json::json!({});
let config = JsonUiConfig::new().tailwind_cdn(false);
let result = JsonUi::render_with_config(&view, &data, &config);
let body = response_body(ok_response(result));
assert!(!body.contains("@tailwindcss/browser"));
}
#[test]
fn config_custom_head() {
let view = sample_view();
let data = serde_json::json!({});
let config =
JsonUiConfig::new().custom_head(r#"<link rel="stylesheet" href="/custom.css">"#);
let result = JsonUi::render_with_config(&view, &data, &config);
let body = response_body(ok_response(result));
assert!(body.contains("/custom.css"));
}
#[test]
fn config_body_class() {
let view = sample_view();
let data = serde_json::json!({});
let config = JsonUiConfig::new().body_class("dark bg-black");
let result = JsonUi::render_with_config(&view, &data, &config);
let body = response_body(ok_response(result));
assert!(body.contains("dark bg-black"));
}
#[test]
fn bunny_fonts_link_in_head() {
let view = sample_view();
let data = serde_json::json!({});
let result = JsonUi::render(&view, &data);
let body = response_body(ok_response(result));
assert!(
body.contains("fonts.bunny.net"),
"head should contain Bunny Fonts link"
);
assert!(
body.contains("family=inter"),
"head should request Inter font family"
);
}
#[test]
fn html_escaping_prevents_xss_in_title() {
let view = JsonUiView::new().title(r#"<script>alert("xss")</script>"#);
let data = serde_json::json!({});
let result = JsonUi::render(&view, &data);
let body = response_body(ok_response(result));
assert!(!body.contains("<script>alert"));
assert!(body.contains("<script>"));
}
#[test]
fn html_escaping_in_data_attributes() {
let view = sample_view();
let data = serde_json::json!({"key": "<img src=x onerror=alert(1)>"});
let result = JsonUi::render(&view, &data);
let body = response_body(ok_response(result));
assert!(!body.contains("<img src=x"));
assert!(body.contains("<img"));
}
#[test]
fn empty_view_renders_valid_html() {
let view = JsonUiView::new();
let data = serde_json::json!({});
let result = JsonUi::render(&view, &data);
assert!(result.is_ok());
let response = ok_response(result);
assert_eq!(response.status_code(), 200);
let body = response_body(response);
assert!(body.contains("<!DOCTYPE html>"));
assert!(body.contains("Ferro"));
}
#[test]
fn html_escape_fn_handles_all_special_chars() {
let input = r#"Hello & "World" <foo> 'bar'"#;
let escaped = html_escape(input);
assert_eq!(
escaped,
"Hello & "World" <foo> 'bar'"
);
}
#[test]
fn render_json_uses_explicit_data_over_embedded() {
let view = sample_view().data(serde_json::json!({"embedded": true}));
let explicit_data = serde_json::json!({"explicit": true});
let result = JsonUi::render_json(&view, &explicit_data);
let response = ok_response(result);
let hyper = response.into_hyper();
let body = format!("{:?}", hyper.into_body());
assert!(body.contains("explicit"));
assert!(body.contains("embedded"));
}
#[test]
fn render_json_falls_back_to_embedded_data() {
let view = sample_view().data(serde_json::json!({"embedded": true}));
let null_data = serde_json::Value::Null;
let result = JsonUi::render_json(&view, &null_data);
let response = ok_response(result);
let hyper = response.into_hyper();
let body = format!("{:?}", hyper.into_body());
assert!(body.contains("embedded"));
}
#[test]
fn render_resolves_action_urls() {
crate::routing::register_route_name("users.index", "/users");
let view = JsonUiView::new().title("Users").component(ComponentNode {
key: "btn".to_string(),
component: Component::Button(ButtonProps {
label: "List Users".to_string(),
variant: ButtonVariant::Default,
size: Size::Default,
disabled: None,
icon: None,
icon_position: None,
button_type: None,
}),
action: Some(Action {
handler: "users.index".to_string(),
url: None,
method: HttpMethod::Get,
confirm: None,
on_success: None,
on_error: None,
target: None,
}),
visibility: None,
});
let result = JsonUi::render_json(&view, &serde_json::json!({}));
let body = response_body(ok_response(result));
assert!(
body.contains("/users"),
"render_json output should contain the resolved URL"
);
let result = JsonUi::render(&view, &serde_json::json!({}));
let body = response_body(ok_response(result));
assert!(
body.contains("/users"),
"render output should contain the resolved URL"
);
assert_eq!(
view.components[0].action.as_ref().unwrap().url,
None,
"original view should not be mutated"
);
}
#[test]
fn render_without_actions_still_works() {
let view = sample_view();
let data = serde_json::json!({"items": [1, 2]});
let result = JsonUi::render(&view, &data);
assert!(result.is_ok());
let result = JsonUi::render_json(&view, &data);
assert!(result.is_ok());
}
use ferro_json_ui::{FormProps, InputProps, InputType};
use std::collections::HashMap;
fn form_view_with_inputs() -> JsonUiView {
JsonUiView::new()
.title("Create User")
.component(ComponentNode {
key: "form".to_string(),
component: Component::Form(FormProps {
action: Action {
handler: "users.store".to_string(),
url: None,
method: HttpMethod::Post,
confirm: None,
on_success: None,
on_error: None,
target: None,
},
guard: None,
max_width: None,
fields: vec![
ComponentNode {
key: "name-input".to_string(),
component: Component::Input(InputProps {
field: "name".to_string(),
label: "Name".to_string(),
input_type: InputType::Text,
placeholder: None,
required: None,
disabled: None,
error: None,
description: None,
default_value: None,
data_path: None,
step: None,
list: None,
}),
action: None,
visibility: None,
},
ComponentNode {
key: "email-input".to_string(),
component: Component::Input(InputProps {
field: "email".to_string(),
label: "Email".to_string(),
input_type: InputType::Email,
placeholder: None,
required: None,
disabled: None,
error: None,
description: None,
default_value: None,
data_path: None,
step: None,
list: None,
}),
action: None,
visibility: None,
},
],
method: None,
}),
action: None,
visibility: None,
})
}
fn make_errors(pairs: &[(&str, &[&str])]) -> HashMap<String, Vec<String>> {
pairs
.iter()
.map(|(k, v)| (k.to_string(), v.iter().map(|s| s.to_string()).collect()))
.collect()
}
#[test]
fn render_with_errors_populates_form_fields() {
let view = form_view_with_inputs();
let errors = make_errors(&[
("name", &["Name is required"]),
("email", &["Email is invalid"]),
]);
let data = serde_json::json!({});
let result = JsonUi::render_with_errors(&view, &data, &errors);
assert!(result.is_ok());
let body = response_body(ok_response(result));
assert!(
body.contains("Name is required"),
"body should contain 'Name is required'"
);
assert!(
body.contains("Email is invalid"),
"body should contain 'Email is invalid'"
);
}
#[test]
fn render_json_with_errors_includes_errors_in_response() {
let view = form_view_with_inputs();
let errors = make_errors(&[("name", &["Name is required"])]);
let data = serde_json::json!({});
let result = JsonUi::render_json_with_errors(&view, &data, &errors);
assert!(result.is_ok());
let body = response_body(ok_response(result));
assert!(
body.contains("Name is required"),
"body should contain field-level error"
);
assert!(
body.contains("name"),
"body should contain the error field name"
);
}
#[test]
fn render_with_errors_empty_map_produces_no_errors() {
let view = form_view_with_inputs();
let errors: HashMap<String, Vec<String>> = HashMap::new();
let data = serde_json::json!({});
let with_errors = JsonUi::render_with_errors(&view, &data, &errors);
let without_errors = JsonUi::render(&view, &data);
assert!(with_errors.is_ok());
assert!(without_errors.is_ok());
let body_with = response_body(ok_response(with_errors));
assert!(
!body_with.contains("Name is required"),
"empty errors should not produce field-level messages"
);
}
#[test]
fn render_validation_error_accepts_framework_type() {
let view = form_view_with_inputs();
let mut ve = crate::validation::ValidationError::new();
ve.add("name", "Name is required");
ve.add("email", "Email must be valid");
let data = serde_json::json!({});
let result = JsonUi::render_validation_error(&view, &data, &ve);
assert!(result.is_ok());
let body = response_body(ok_response(result));
assert!(
body.contains("Name is required"),
"should contain name error"
);
assert!(
body.contains("Email must be valid"),
"should contain email error"
);
}
#[test]
fn render_with_errors_preserves_action_resolution() {
crate::routing::register_route_name("users.store", "/users");
let view = form_view_with_inputs();
let errors = make_errors(&[("name", &["Name is required"])]);
let data = serde_json::json!({});
let result = JsonUi::render_json_with_errors(&view, &data, &errors);
assert!(result.is_ok());
let body = response_body(ok_response(result));
assert!(body.contains("/users"), "action URL should be resolved");
assert!(
body.contains("Name is required"),
"field errors should be populated"
);
}
use ferro_json_ui::{register_layout, Layout, LayoutContext};
#[test]
fn render_uses_default_layout_when_none_set() {
let view = sample_view(); let data = serde_json::json!({});
let result = JsonUi::render(&view, &data);
assert!(result.is_ok());
let body = response_body(ok_response(result));
assert!(body.contains("<!DOCTYPE html>"));
assert!(body.contains("data-view="));
assert!(body.contains("data-props="));
assert!(body.contains("ferro-json-ui"));
assert!(!body.contains("<nav"));
assert!(!body.contains("<aside"));
}
#[test]
fn render_uses_named_layout() {
let view = sample_view().layout("app");
let data = serde_json::json!({});
let result = JsonUi::render(&view, &data);
assert!(result.is_ok());
let body = response_body(ok_response(result));
assert!(body.contains("<nav"));
assert!(body.contains("<aside"));
assert!(body.contains("<main"));
assert!(body.contains("ferro-json-ui"));
}
#[test]
fn render_uses_auth_layout() {
let view = sample_view().layout("auth");
let data = serde_json::json!({});
let result = JsonUi::render(&view, &data);
assert!(result.is_ok());
let body = response_body(ok_response(result));
assert!(body.contains("flex items-center justify-center"));
assert!(body.contains("max-w-md"));
assert!(body.contains("ferro-json-ui"));
assert!(!body.contains("<nav"));
assert!(!body.contains("<aside"));
}
#[test]
fn render_with_errors_uses_layout() {
let view = form_view_with_inputs().layout("auth");
let errors = make_errors(&[("name", &["Name is required"])]);
let data = serde_json::json!({});
let result = JsonUi::render_with_errors(&view, &data, &errors);
assert!(result.is_ok());
let body = response_body(ok_response(result));
assert!(body.contains("flex items-center justify-center"));
assert!(body.contains("Name is required"));
}
#[test]
fn render_custom_layout() {
struct TestLayout;
impl Layout for TestLayout {
fn render(&self, ctx: &LayoutContext) -> String {
format!("<custom-layout>{}</custom-layout>", ctx.content)
}
}
register_layout("test-custom", TestLayout);
let view = sample_view().layout("test-custom");
let data = serde_json::json!({});
let result = JsonUi::render(&view, &data);
assert!(result.is_ok());
let body = response_body(ok_response(result));
assert!(body.contains("<custom-layout>"));
assert!(body.contains("</custom-layout>"));
}
#[test]
fn render_unknown_layout_falls_back_to_default() {
let view = sample_view().layout("nonexistent-layout-xyz");
let data = serde_json::json!({});
let result = JsonUi::render(&view, &data);
assert!(result.is_ok());
let body = response_body(ok_response(result));
assert!(body.contains("<!DOCTYPE html>"));
assert!(body.contains("ferro-json-ui"));
assert!(!body.contains("<nav"));
assert!(!body.contains("<aside"));
}
use ferro_json_ui::PluginProps;
#[cfg(feature = "theme")]
mod theme_tests {
use super::*;
use crate::theme::context::{theme_scope, with_theme_scope};
use ferro_theme::Theme;
use std::sync::Arc;
fn ok_response_body(result: Response) -> String {
let response = match result {
Ok(r) => r,
Err(_) => panic!("expected Ok response"),
};
response.body().to_string()
}
fn sample_view() -> JsonUiView {
use ferro_json_ui::{CardProps, Component, ComponentNode};
JsonUiView::new()
.title("Theme Test")
.component(ComponentNode {
key: "card".to_string(),
component: Component::Card(CardProps {
title: "Hello".to_string(),
description: None,
children: vec![],
max_width: None,
footer: vec![],
}),
action: None,
visibility: None,
})
}
#[tokio::test]
async fn theme_css_injected_into_head_when_theme_active() {
let mut custom_theme = Theme::default_theme();
custom_theme.css = "@theme { --color-primary: red; }".to_string();
let scope = theme_scope();
{
let mut guard = scope.write().await;
*guard = Some(Arc::new(custom_theme));
}
let view = sample_view();
let data = serde_json::json!({});
let body = with_theme_scope(scope, async {
ok_response_body(JsonUi::render(&view, &data))
})
.await;
assert!(
body.contains("<style type=\"text/tailwindcss\">")
&& body.contains("--color-primary: red"),
"theme CSS should be injected as a <style type=\"text/tailwindcss\"> tag"
);
}
#[test]
fn no_theme_css_injected_when_no_middleware() {
let view = sample_view();
let data = serde_json::json!({});
let result = JsonUi::render(&view, &data);
let body = ok_response_body(result);
assert!(body.contains("<!DOCTYPE html>"));
}
#[tokio::test]
async fn theme_css_injected_after_tailwind_cdn() {
let mut custom_theme = Theme::default_theme();
custom_theme.css = "@theme { --color-test: blue; }".to_string();
let scope = theme_scope();
{
let mut guard = scope.write().await;
*guard = Some(Arc::new(custom_theme));
}
let view = sample_view();
let data = serde_json::json!({});
let config = JsonUiConfig::new().tailwind_cdn(true);
let body = with_theme_scope(scope, async {
ok_response_body(JsonUi::render_with_config(&view, &data, &config))
})
.await;
let cdn_pos = body.find("@tailwindcss/browser").unwrap_or(0);
let style_pos = body.find("--color-test").unwrap_or(0);
assert!(
cdn_pos < style_pos,
"theme CSS should come after Tailwind CDN script"
);
}
#[tokio::test]
async fn theme_css_does_not_duplicate_custom_head_content() {
let mut custom_theme = Theme::default_theme();
custom_theme.css = "@theme { --color-custom: purple; }".to_string();
let scope = theme_scope();
{
let mut guard = scope.write().await;
*guard = Some(Arc::new(custom_theme));
}
let view = sample_view();
let data = serde_json::json!({});
let config =
JsonUiConfig::new().custom_head(r#"<link rel="stylesheet" href="/my.css">"#);
let body = with_theme_scope(scope, async {
ok_response_body(JsonUi::render_with_config(&view, &data, &config))
})
.await;
assert!(body.contains("/my.css"), "custom_head should be present");
assert!(
body.contains("--color-custom"),
"theme CSS should be injected"
);
let count = body.matches("--color-custom").count();
assert_eq!(count, 1, "theme CSS should not be duplicated");
}
}
#[test]
fn test_plugin_component_renders_in_full_page() {
let view = JsonUiView::new()
.title("Map Page")
.component(ComponentNode {
key: "map".to_string(),
component: Component::Plugin(PluginProps {
plugin_type: "Map".to_string(),
props: serde_json::json!({
"center": [51.505, -0.09],
"zoom": 13,
"markers": [{"lat": 51.5, "lng": -0.09, "popup": "London"}]
}),
}),
action: None,
visibility: None,
});
let data = serde_json::json!({});
let result = JsonUi::render(&view, &data);
assert!(result.is_ok(), "render should succeed");
let body = response_body(ok_response(result));
assert!(
body.contains("leaflet.css"),
"should include Leaflet CSS link"
);
assert!(
body.contains("leaflet.js"),
"should include Leaflet JS script"
);
assert!(
body.contains("data-ferro-map"),
"should contain map data attribute"
);
assert!(
body.contains("DOMContentLoaded"),
"should contain init script"
);
}
}