use axum::{
body::Body, http::StatusCode, http::header, response::IntoResponse, response::Response,
};
use axum_extra::headers::HeaderValue;
use lib_humus_configuration::ErrorCause;
use lib_humus_configuration::read_from_toml_file;
use log::{error, info};
use tera::Tera;
use std::cell::Cell;
use std::collections::HashMap;
use std::marker::PhantomData;
use std::os::unix::ffi::OsStrExt;
use std::path::Path;
use std::path::PathBuf;
use std::sync::Arc;
use crate::HumusQuerySettings;
use crate::HumusView;
use crate::language::LanguageEngine;
use crate::language::TextFunction;
use crate::language::TextFunctionContext;
use crate::templating::HumusApiFormat;
use crate::templating::HumusFormatIdentifier;
use crate::templating::TemplatesManifest;
use crate::templating::{ExtraConfig, TemplatingEngineLoaderError};
thread_local! (
static TEMPLATE_BYPASSING_TEXT_FUNCTION_CONTEXT: Cell<Option<TextFunctionContext>> = const { Cell::new(None) }
);
#[derive(Debug, Clone)]
pub struct TemplatingEngine<ApiFormat>
where
ApiFormat: HumusApiFormat,
{
pub tera: HashMap<HumusFormatIdentifier, Tera>,
pub template_config: Option<ExtraConfig>,
pub templates_manifest: TemplatesManifest,
phantom_api_format: PhantomData<ApiFormat>,
}
impl<ApiFormat> TemplatingEngine<ApiFormat>
where
ApiFormat: HumusApiFormat,
{
pub fn load_from_directory(
path: impl AsRef<Path>,
extra_config_path: Option<&Path>,
template_tera: Tera,
language_engine: Option<Arc<LanguageEngine>>,
) -> Result<Self, TemplatingEngineLoaderError> {
let path = path.as_ref();
let extra_config: Option<ExtraConfig> = read_from_toml_file(
extra_config_path
.map(|p| p.to_path_buf())
.unwrap_or_else(|| path.join("extra.toml")),
)
.or_else(|e| match &e.cause {
ErrorCause::FileRead { .. } => {
if extra_config_path.is_some() {
return Err(TemplatingEngineLoaderError::ConfigurationError(e));
}
Ok(None)
}
_ => {
return Err(TemplatingEngineLoaderError::ConfigurationError(e));
}
})?;
let templates_manifest: TemplatesManifest =
read_from_toml_file(path.join("templates.toml"))
.map_err(TemplatingEngineLoaderError::TemplatesManifestError)?;
let problems = templates_manifest.find_problems::<ApiFormat>();
if !problems.is_empty() {
return Err(TemplatingEngineLoaderError::TemplatesManifestProblems(
problems,
));
}
let template_directory = path.join("tera2");
if !template_directory.is_dir() {
return Err(TemplatingEngineLoaderError::TemplateDirectoryNotFound {
path: template_directory,
});
}
let mut templating_engines: HashMap<HumusFormatIdentifier, Tera> = HashMap::new();
for (format_id, format_desc) in templates_manifest.format.iter() {
info!("Loading templates for format {format_id:?} ...");
let mut tera = template_tera.clone();
tera.register_filter("urlencode", tera_contrib::urlencode::urlencode);
tera.register_filter(
"urlencode_strict",
tera_contrib::urlencode::urlencode_strict,
);
tera.register_test("matching", tera_contrib::regex::Matching::default());
tera.register_filter(
"regex_replace",
tera_contrib::regex::RegexReplace::default(),
);
tera.register_filter("spaceless", tera_contrib::regex::spaceless);
tera.register_filter("striptags", tera_contrib::regex::striptags);
tera.register_filter("json_encode", tera_contrib::json::json_encode);
if let Some(ref language_engine) = language_engine {
let text_function = TextFunction::new(language_engine.clone(), |_, _| {
if let Some(context) = TEMPLATE_BYPASSING_TEXT_FUNCTION_CONTEXT.take() {
TEMPLATE_BYPASSING_TEXT_FUNCTION_CONTEXT.set(Some(context.clone()));
Ok(context)
} else {
Err(tera::Error::message(
"No context stored while rendering template, this is a bug in lib-humus!",
))
}
});
tera.register_function("text", text_function);
}
let mut templates_to_load: Vec<(PathBuf, Option<String>)> = format_desc
.additional_templates
.iter()
.map(|file| (template_directory.join(file), Some(file.clone())))
.collect();
let full_file_extension = format!(".{}", format_desc.extension);
for dir_entry in std::fs::read_dir(&template_directory).map_err(|e| {
TemplatingEngineLoaderError::TemplateDirectoryIoError {
path: template_directory.clone(),
error: e,
}
})? {
let dir_entry = dir_entry.map_err(|e| {
TemplatingEngineLoaderError::TemplateDirectoryIoError {
path: template_directory.clone(),
error: e,
}
})?;
let path = dir_entry.path();
if !path.is_file() {
continue;
}
let name = String::from_utf8_lossy(dir_entry.file_name().as_bytes()).to_string();
if name.ends_with(&full_file_extension) {
log::debug!("Discovered template: {name:?}");
templates_to_load.push((path, Some(name)));
}
}
if let Err(e) = tera.add_template_files(templates_to_load) {
error!("Error Parsing Template: {e}");
return Err(TemplatingEngineLoaderError::TemplateParseError {
path: path.to_path_buf(),
format: *format_id,
tera_error: e,
});
}
templating_engines.insert(*format_id, tera);
}
Ok(Self {
tera: templating_engines,
templates_manifest,
template_config: extra_config,
phantom_api_format: PhantomData,
})
}
pub fn render_view<S: HumusQuerySettings>(
&self,
settings: &S,
view: impl HumusView<S, ApiFormat>,
language_engine: &LanguageEngine,
) -> Response {
let format = settings.get_format();
let status_code = view.get_status_code(settings);
let mut response = if let Some(api_format) = ApiFormat::from_name(&format) {
view.into_api_response(settings, api_format)
} else if let Some(format_description) = self.templates_manifest.format.get(&format) {
let template_name = view.get_template_name();
let mime_type = &format_description.media_type;
let mut context = tera::Context::new();
context.insert("view", &template_name);
context.insert("format", &format);
let language = settings
.get_preferred_language()
.unwrap_or_else(|| language_engine.language_manifest().default_language.clone());
context.insert("lang", &language);
context.insert("language_manifest", language_engine.language_manifest());
context.insert("media_type", &mime_type.to_string());
context.insert("http_status", &status_code.as_u16());
context.insert("data", &view);
context.insert("extra", &self.template_config);
view.initalize_template_context(&mut context, settings);
TEMPLATE_BYPASSING_TEXT_FUNCTION_CONTEXT.set(Some(TextFunctionContext {
language: Some(language),
escape_function: tera::escape_html, safe_suffix: language_engine
.language_manifest()
.get_safe_suffix_for_format(format),
}));
let Some(tera) = self.tera.get(&format) else {
error!("Problem finding tera instance for format {format:?}");
return (
StatusCode::INTERNAL_SERVER_ERROR,
format!("Unknown template output format: {format:?}"),
)
.into_response();
};
match tera.render(
&format!("{template_name}.{}", format_description.extension),
&context,
) {
Ok(text) => {
let response = (
[(
header::CONTENT_TYPE,
HeaderValue::from_str(mime_type.as_ref())
.expect("MimeType should always be a valid header value."),
)],
Into::<Body>::into(text),
)
.into_response();
view.update_response(response, settings)
}
Err(e) => {
error!(
"There was an error while rendering template {template_name}:\n{}",
render_tera_error(e)
);
(
StatusCode::INTERNAL_SERVER_ERROR,
format!("Template error in {template_name}, contact owner or see logs.\n"),
)
.into_response()
}
}
} else {
(
StatusCode::BAD_REQUEST,
"(unknown format) You have requested an unknown template or API format.\n"
.to_string(),
)
.into_response()
};
if response.status() == StatusCode::OK {
*response.status_mut() = status_code;
}
response
}
}
fn render_tera_error(error: tera::Error) -> String {
let mut text = error.to_string();
let mut error: &(dyn core::error::Error + 'static) = &error;
while let Some(source) = error.source() {
text = format!("{text}\n* {source}");
error = source;
}
text
}