use crate::mdx::{create_error_response, mdx_to_html_with_frontmatter};
use crate::models::{
ComponentDefinition, NamedMdxBatchInput, OutputFormat, RenderedMdx, ResourceLimits,
};
use crate::renderer::pool::{RendererPool, RendererProfile};
use anyhow::Error as AnyhowError;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::env;
#[cfg(feature = "http")]
use std::fs;
#[cfg(feature = "http")]
use std::path::Path;
use std::path::PathBuf;
const ENV_STATIC_DIR: &str = "RUST_CMS_STATIC_DIR";
#[derive(Clone, Debug)]
pub struct RenderServiceConfig {
pub static_dir: PathBuf,
pub max_cached_renderers: usize,
pub resource_limits: ResourceLimits,
}
impl Default for RenderServiceConfig {
fn default() -> Self {
Self {
static_dir: PathBuf::from("static"),
max_cached_renderers: 4,
resource_limits: ResourceLimits::default(),
}
}
}
#[cfg(feature = "http")]
#[derive(Deserialize, Debug)]
struct TomlConfig {
static_dir: Option<String>,
max_cached_renderers: Option<usize>,
resource_limits: Option<TomlResourceLimits>,
}
#[cfg(feature = "http")]
#[derive(Deserialize, Debug)]
struct TomlResourceLimits {
max_batch_size: Option<usize>,
max_mdx_content_size: Option<usize>,
max_component_code_size: Option<usize>,
}
impl RenderServiceConfig {
pub fn from_env() -> Self {
let mut config = Self::default();
if let Ok(path) = env::var(ENV_STATIC_DIR) {
config.static_dir = PathBuf::from(path);
}
config
}
#[cfg(feature = "http")]
pub fn from_file(path: impl AsRef<Path>) -> Result<Self, String> {
let path = path.as_ref();
let contents = fs::read_to_string(path).map_err(|e| {
format!(
"Failed to read configuration file {}: {}",
path.display(),
e
)
})?;
let toml_config: TomlConfig = toml::from_str(&contents).map_err(|e| {
format!(
"Failed to parse TOML configuration file {}: {}",
path.display(),
e
)
})?;
let mut config = Self::default();
if let Some(static_dir) = toml_config.static_dir {
config.static_dir = PathBuf::from(static_dir);
}
if let Some(max_cached) = toml_config.max_cached_renderers {
config.max_cached_renderers = max_cached;
}
if let Some(limits) = toml_config.resource_limits {
if let Some(max_batch_size) = limits.max_batch_size {
config.resource_limits.max_batch_size = max_batch_size;
}
if let Some(max_mdx_content_size) = limits.max_mdx_content_size {
config.resource_limits.max_mdx_content_size = max_mdx_content_size;
}
if let Some(max_component_code_size) = limits.max_component_code_size {
config.resource_limits.max_component_code_size = max_component_code_size;
}
}
Ok(config)
}
#[cfg(feature = "http")]
pub fn from_file_and_env(path: impl AsRef<Path>) -> Result<Self, String> {
let mut config = Self::from_file(path)?;
if let Ok(path) = env::var(ENV_STATIC_DIR) {
config.static_dir = PathBuf::from(path);
}
Ok(config)
}
pub fn validate(&self) -> Result<(), String> {
if !self.static_dir.exists() {
return Err(format!(
"Static directory does not exist: {}",
self.static_dir.display()
));
}
if !self.static_dir.is_dir() {
return Err(format!(
"Static directory path is not a directory: {}",
self.static_dir.display()
));
}
if self.max_cached_renderers == 0 {
return Err("max_cached_renderers must be greater than 0".to_string());
}
if self.max_cached_renderers > 1000 {
return Err(format!(
"max_cached_renderers ({}) is unreasonably large, maximum recommended is 1000",
self.max_cached_renderers
));
}
self.resource_limits.validate()?;
Ok(())
}
}
#[derive(Clone)]
pub struct RenderService {
config: RenderServiceConfig,
pool: RendererPool,
}
impl RenderService {
pub fn new(config: RenderServiceConfig) -> Result<Self, String> {
config.validate()?;
let pool = RendererPool::new(config.static_dir.clone(), config.max_cached_renderers);
if env::var("RUST_CMS_SKIP_POOL_WARMING").is_err() {
pool.warm(1);
}
Ok(Self { config, pool })
}
pub fn new_with_validation(config: RenderServiceConfig) -> Result<Self, String> {
Self::new(config)
}
pub fn config(&self) -> &RenderServiceConfig {
&self.config
}
pub fn pool(&self) -> &RendererPool {
&self.pool
}
pub fn render_batch(
&self,
input: &NamedMdxBatchInput,
) -> Result<BatchRenderOutcome, RenderBatchError> {
let resolved_components = input.components.as_ref();
self.validate_resource_limits(input, resolved_components)?;
let profile = self.profile_for_request(&input.settings.output)?;
if input.mdx.is_empty() {
return Ok(BatchRenderOutcome::empty());
}
let renderer = self
.pool
.checkout(profile)
.map_err(RenderBatchError::Internal)?;
let mut files = HashMap::with_capacity(input.mdx.len());
const ESTIMATED_ERROR_RATE_DENOMINATOR: usize = 10;
let mut errors = Vec::with_capacity(input.mdx.len() / ESTIMATED_ERROR_RATE_DENOMINATOR);
let mut succeeded = 0usize;
let mut failed = 0usize;
for (name, mdx_source) in &input.mdx {
let renderer_ref = renderer
.renderer()
.map_err(|e| RenderBatchError::Internal(anyhow::Error::from(e)))?;
match mdx_to_html_with_frontmatter(
mdx_source,
renderer_ref,
resolved_components,
&input.settings,
) {
Ok(rendered) => {
succeeded += 1;
files.insert(name.clone(), FileRenderOutcome::success(rendered));
}
Err(err) => {
failed += 1;
let anyhow_err = anyhow::Error::from(err);
let message = format!("{:#}", anyhow_err);
let fallback = create_error_response(&anyhow_err);
errors.push(BatchError {
file: name.clone(),
message: message.clone(),
});
files.insert(name.clone(), FileRenderOutcome::failure(message, fallback));
}
}
}
Ok(BatchRenderOutcome::new(files, errors, succeeded, failed))
}
fn validate_resource_limits(
&self,
input: &NamedMdxBatchInput,
components: Option<&HashMap<String, ComponentDefinition>>,
) -> Result<(), RenderBatchError> {
let limits = &self.config.resource_limits;
if input.mdx.len() > limits.max_batch_size {
return Err(RenderBatchError::InvalidRequest(format!(
"Batch size {} exceeds maximum allowed {}",
input.mdx.len(),
limits.max_batch_size
)));
}
for (name, content) in &input.mdx {
if content.len() > limits.max_mdx_content_size {
return Err(RenderBatchError::InvalidRequest(format!(
"MDX content for '{}' is {} bytes, exceeds maximum allowed {} bytes",
name,
content.len(),
limits.max_mdx_content_size
)));
}
}
if let Some(component_map) = components {
for (name, comp_def) in component_map {
if comp_def.code.len() > limits.max_component_code_size {
return Err(RenderBatchError::InvalidRequest(format!(
"Component '{}' code is {} bytes, exceeds maximum allowed {} bytes",
name,
comp_def.code.len(),
limits.max_component_code_size
)));
}
}
}
Ok(())
}
fn profile_for_request(
&self,
format: &OutputFormat,
) -> Result<RendererProfile, RenderBatchError> {
match format {
OutputFormat::Html
| OutputFormat::Javascript
| OutputFormat::Schema
| OutputFormat::Json => Ok(RendererProfile::Engine),
}
}
}
#[derive(Debug)]
pub enum RenderBatchError {
Forbidden(String),
InvalidRequest(String),
Internal(AnyhowError),
}
impl std::error::Error for RenderBatchError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
RenderBatchError::Internal(err) => Some(err.as_ref()),
_ => None,
}
}
}
impl std::fmt::Display for RenderBatchError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RenderBatchError::Forbidden(msg) => write!(f, "Forbidden: {}", msg),
RenderBatchError::InvalidRequest(msg) => write!(f, "Invalid request: {}", msg),
RenderBatchError::Internal(err) => write!(f, "Internal error: {}", err),
}
}
}
impl From<anyhow::Error> for RenderBatchError {
fn from(err: anyhow::Error) -> Self {
RenderBatchError::Internal(err)
}
}
impl From<crate::error::MdxError> for RenderBatchError {
fn from(err: crate::error::MdxError) -> Self {
RenderBatchError::Internal(anyhow::Error::from(err))
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BatchRenderOutcome {
pub total: usize,
pub succeeded: usize,
pub failed: usize,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub errors: Vec<BatchError>,
#[serde(default)]
pub files: HashMap<String, FileRenderOutcome>,
}
impl BatchRenderOutcome {
pub fn new(
files: HashMap<String, FileRenderOutcome>,
errors: Vec<BatchError>,
succeeded: usize,
failed: usize,
) -> Self {
let total = succeeded + failed;
Self {
total,
succeeded,
failed,
errors,
files,
}
}
pub fn empty() -> Self {
Self {
total: 0,
succeeded: 0,
failed: 0,
errors: Vec::new(),
files: HashMap::new(),
}
}
pub fn is_all_success(&self) -> bool {
self.failed == 0
}
pub fn is_complete_failure(&self) -> bool {
self.total > 0 && self.succeeded == 0
}
}
#[derive(Debug, Serialize, Deserialize)]
pub struct BatchError {
pub file: String,
pub message: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum FileRenderStatus {
Success,
Failed,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct FileRenderOutcome {
pub status: FileRenderStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub result: Option<RenderedMdx>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
impl FileRenderOutcome {
fn success(result: RenderedMdx) -> Self {
Self {
status: FileRenderStatus::Success,
result: Some(result),
error: None,
}
}
fn failure(message: String, fallback: RenderedMdx) -> Self {
Self {
status: FileRenderStatus::Failed,
result: Some(fallback),
error: Some(message),
}
}
}