use crate::config::Config;
use crate::resolve::{self, Format};
use axum::http::StatusCode;
use serde::Serialize;
use std::fmt;
use std::io::ErrorKind;
use std::path::Path;
const X_ROBOTS_TAG_NAME: &str = "X-Robots-Tag";
pub const X_ROBOTS_TAG_VALUE: &str = "noindex, nofollow, nosnippet, noarchive";
const RETRY_AFTER_NAME: &str = "Retry-After";
#[derive(Debug)]
pub enum RenderError {
MissingHtml { code: u16, default_code: u16 },
Io(std::io::Error),
}
impl fmt::Display for RenderError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
RenderError::MissingHtml { code, default_code } => write!(
f,
"no hay página HTML para el error: ni `{code}.html` ni `{default_code}.html` \
existen en el directorio de contenido"
),
RenderError::Io(err) => {
write!(
f,
"no se pudo construir el cuerpo de la página de error: {err}"
)
}
}
}
}
impl std::error::Error for RenderError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
RenderError::Io(err) => Some(err),
RenderError::MissingHtml { .. } => None,
}
}
}
#[derive(Debug)]
pub struct RenderedPage {
pub status: StatusCode,
pub content_type: &'static str,
pub headers: Vec<(&'static str, String)>,
pub body: Vec<u8>,
}
pub fn render_body(
code: u16,
default_code: u16,
format: Format,
content_dir: &Path,
) -> Result<Vec<u8>, RenderError> {
match format {
Format::Html => html_body(code, default_code, content_dir),
Format::Json => json_body(code),
Format::Xml => Ok(xml_body(code).into_bytes()),
Format::Text => Ok(text_body(code).into_bytes()),
}
}
pub fn render_page(
config: &Config,
code: u16,
format: Format,
) -> Result<RenderedPage, RenderError> {
let body = render_body(code, config.default_error_page, format, &config.content_dir)?;
Ok(RenderedPage {
status: status_of(code),
content_type: content_type_for(format),
headers: companion_headers(code, config.retry_after),
body,
})
}
pub fn render_text_fallback(code: u16, retry_after: u32) -> RenderedPage {
RenderedPage {
status: status_of(code),
content_type: content_type_for(Format::Text),
headers: companion_headers(code, retry_after),
body: text_body(code).into_bytes(),
}
}
fn status_of(code: u16) -> StatusCode {
StatusCode::from_u16(code).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR)
}
fn companion_headers(code: u16, retry_after: u32) -> Vec<(&'static str, String)> {
let mut headers = vec![(X_ROBOTS_TAG_NAME, X_ROBOTS_TAG_VALUE.to_string())];
if let Some(secs) = resolve::retry_after_secs(code, retry_after) {
headers.push((RETRY_AFTER_NAME, secs.to_string()));
}
headers
}
fn content_type_for(format: Format) -> &'static str {
match format {
Format::Html => "text/html; charset=utf-8",
Format::Json => "application/json",
Format::Xml => "application/xml",
Format::Text => "text/plain; charset=utf-8",
}
}
fn html_body(code: u16, default_code: u16, content_dir: &Path) -> Result<Vec<u8>, RenderError> {
if let Some(body) = read_html(content_dir, code).map_err(RenderError::Io)? {
return Ok(body);
}
if code != default_code
&& let Some(body) = read_html(content_dir, default_code).map_err(RenderError::Io)?
{
return Ok(body);
}
Err(RenderError::MissingHtml { code, default_code })
}
fn read_html(content_dir: &Path, code: u16) -> Result<Option<Vec<u8>>, std::io::Error> {
match std::fs::read(content_dir.join(format!("{code}.html"))) {
Ok(body) => Ok(Some(body)),
Err(err) if err.kind() == ErrorKind::NotFound => Ok(None),
Err(err) => Err(err),
}
}
fn json_body(code: u16) -> Result<Vec<u8>, RenderError> {
let body = JsonBody {
error: true,
code,
message: resolve::canonical_reason(code),
description: resolve::description(code),
};
serde_json::to_vec(&body).map_err(|err| RenderError::Io(std::io::Error::other(err)))
}
#[derive(Debug, Serialize)]
struct JsonBody<'a> {
error: bool,
code: u16,
message: &'a str,
description: &'a str,
}
fn xml_body(code: u16) -> String {
let message = escape_xml(resolve::canonical_reason(code));
let description = escape_xml(resolve::description(code));
format!(
"<?xml version=\"1.0\" encoding=\"utf-8\"?>\n\
<error><code>{code}</code><message>{message}</message><description>{description}</description></error>"
)
}
fn text_body(code: u16) -> String {
format!(
"{code} {}\n{}\n",
resolve::canonical_reason(code),
resolve::description(code)
)
}
fn escape_xml(value: &str) -> String {
value
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
.replace('\'', "'")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::resolve::Format;
use axum::http::StatusCode;
use serde_json::Value;
use std::path::{Path, PathBuf};
const FIXTURE_404_HTML: &str = include_str!("../tests/fixtures/content/404.html");
fn fixtures_content_dir() -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/content")
}
fn fixtures_config() -> Config {
Config::from_env_iter([(
"CONTENT_DIR".to_string(),
fixtures_content_dir().to_string_lossy().into_owned(),
)])
.unwrap()
}
fn header<'a>(page: &'a RenderedPage, name: &str) -> Option<&'a str> {
page.headers
.iter()
.find(|(key, _)| *key == name)
.map(|(_, value)| value.as_str())
}
#[test]
fn el_cuerpo_json_lleva_las_cuatro_claves() {
let body = render_body(404, 404, Format::Json, &fixtures_content_dir()).unwrap();
let json: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(json["error"], Value::Bool(true));
assert_eq!(json["code"], Value::from(404));
assert_eq!(json["message"], Value::from("Not Found"));
assert!(json["description"].is_string());
assert!(!json["description"].as_str().unwrap().is_empty());
}
#[test]
fn el_cuerpo_xml_lleva_el_elemento_code() {
let body = render_body(503, 404, Format::Xml, &fixtures_content_dir()).unwrap();
let xml = String::from_utf8(body).unwrap();
assert!(
xml.contains("<code>503</code>"),
"el XML debe contener <code>503</code>, fue: {xml}"
);
assert!(xml.contains("<message>"), "el XML debe llevar <message>");
assert!(
xml.contains("<description>"),
"el XML debe llevar <description>"
);
}
#[test]
fn el_cuerpo_de_texto_lleva_codigo_y_razon() {
let body = render_body(503, 404, Format::Text, &fixtures_content_dir()).unwrap();
let text = String::from_utf8(body).unwrap();
assert!(
text.contains("503"),
"el texto debe llevar el código: {text}"
);
assert!(
text.contains("Service Unavailable"),
"el texto debe llevar la razón canónica: {text}"
);
}
#[test]
fn el_cuerpo_html_sale_del_fichero_del_codigo() {
let body = render_body(404, 404, Format::Html, &fixtures_content_dir()).unwrap();
let html = String::from_utf8(body).unwrap();
assert_eq!(html.trim(), FIXTURE_404_HTML.trim());
}
#[test]
fn el_cuerpo_html_cae_al_fichero_del_codigo_por_defecto() {
let body = render_body(418, 404, Format::Html, &fixtures_content_dir()).unwrap();
let html = String::from_utf8(body).unwrap();
assert_eq!(html.trim(), FIXTURE_404_HTML.trim());
}
#[test]
fn el_cuerpo_no_depende_del_formato_para_el_codigo() {
for format in [Format::Html, Format::Json, Format::Xml, Format::Text] {
let body = render_body(503, 404, format, &fixtures_content_dir()).unwrap();
assert!(!body.is_empty(), "cuerpo vacío para {format:?}");
}
}
#[test]
fn toda_pagina_de_error_lleva_x_robots_tag() {
for code in [404, 500, 503] {
let page = render_page(&fixtures_config(), code, Format::Json).unwrap();
assert_eq!(
header(&page, "X-Robots-Tag"),
Some("noindex, nofollow, nosnippet, noarchive"),
"X-Robots-Tag ausente o incorrecta para {code}"
);
}
}
#[test]
fn el_503_lleva_retry_after_120() {
let page = render_page(&fixtures_config(), 503, Format::Text).unwrap();
assert_eq!(header(&page, "Retry-After"), Some("120"));
}
#[test]
fn el_429_lleva_retry_after_120() {
let page = render_page(&fixtures_config(), 429, Format::Text).unwrap();
assert_eq!(header(&page, "Retry-After"), Some("120"));
}
#[test]
fn el_404_no_lleva_retry_after() {
let page = render_page(&fixtures_config(), 404, Format::Text).unwrap();
assert_eq!(header(&page, "Retry-After"), None);
}
#[test]
fn el_estado_es_el_codigo_resuelto_y_nunca_200() {
let page = render_page(&fixtures_config(), 404, Format::Html).unwrap();
assert_eq!(page.status, StatusCode::NOT_FOUND);
assert_ne!(page.status, StatusCode::OK);
let page = render_page(&fixtures_config(), 503, Format::Html).unwrap();
assert_eq!(page.status, StatusCode::SERVICE_UNAVAILABLE);
}
#[test]
fn el_content_type_sigue_al_formato() {
let content_type = |format| {
render_page(&fixtures_config(), 404, format)
.unwrap()
.content_type
};
assert!(content_type(Format::Html).starts_with("text/html"));
assert!(content_type(Format::Json).starts_with("application/json"));
assert!(content_type(Format::Xml).starts_with("application/xml"));
assert!(content_type(Format::Text).starts_with("text/plain"));
}
#[test]
fn el_respaldo_de_texto_conserva_el_codigo_y_sus_cabeceras() {
let page = render_text_fallback(418, 120);
assert_eq!(page.status, StatusCode::IM_A_TEAPOT);
assert_ne!(page.status, StatusCode::INTERNAL_SERVER_ERROR);
assert_ne!(page.status, StatusCode::OK);
assert!(page.content_type.starts_with("text/plain"));
assert_eq!(
header(&page, "X-Robots-Tag"),
Some("noindex, nofollow, nosnippet, noarchive")
);
let body = String::from_utf8(page.body).unwrap();
assert!(
body.contains("418"),
"el cuerpo debe llevar el código: {body}"
);
}
#[test]
fn el_respaldo_de_texto_mantiene_retry_after() {
assert_eq!(
header(&render_text_fallback(503, 120), "Retry-After"),
Some("120")
);
assert_eq!(header(&render_text_fallback(404, 120), "Retry-After"), None);
}
fn fixtures_config_con_retry_after(value: &str) -> Config {
Config::from_env_iter([
(
"CONTENT_DIR".to_string(),
fixtures_content_dir().to_string_lossy().into_owned(),
),
("RETRY_AFTER".to_string(), value.to_string()),
])
.expect("la configuración de fixtures debe construirse")
}
#[test]
fn el_503_lleva_el_retry_after_configurado() {
let config = fixtures_config_con_retry_after("30");
let page = render_page(&config, 503, Format::Text).unwrap();
assert_eq!(
header(&page, "Retry-After"),
Some("30"),
"el `503` debe llevar el valor configurado, no el 120 del defecto"
);
}
#[test]
fn el_429_lleva_el_retry_after_configurado() {
let config = fixtures_config_con_retry_after("45");
let page = render_page(&config, 429, Format::Text).unwrap();
assert_eq!(
header(&page, "Retry-After"),
Some("45"),
"el `429` debe llevar el valor configurado, no el 120 del defecto"
);
}
#[test]
fn el_retry_after_cero_sale_en_la_cabecera() {
let config = fixtures_config_con_retry_after("0");
let page = render_page(&config, 503, Format::Text).unwrap();
assert_eq!(
header(&page, "Retry-After"),
Some("0"),
"un `RETRY_AFTER=0` debe emitir `Retry-After: 0`, no suprimir la cabecera"
);
}
#[test]
fn el_404_no_lleva_retry_after_ni_configurado() {
let config = fixtures_config_con_retry_after("30");
let page = render_page(&config, 404, Format::Text).unwrap();
assert_eq!(
header(&page, "Retry-After"),
None,
"un `404` no lleva `Retry-After` por mucho que haya `RETRY_AFTER` configurado"
);
}
#[test]
fn el_respaldo_de_texto_lleva_el_retry_after_configurado() {
let page = render_text_fallback(503, 30);
assert_eq!(
header(&page, "Retry-After"),
Some("30"),
"el respaldo del `503` debe llevar el valor configurado"
);
assert_eq!(page.status, StatusCode::SERVICE_UNAVAILABLE);
}
}