Skip to main content

lib_humus/templating/
manifest.rs

1// SPDX-FileCopyrightText: 2026 Slatian <baschdel@disroot.org>
2//
3// SPDX-License-Identifier: AGPL-3.0-or-later
4
5use serde::{Deserialize, Serialize};
6pub use tinystr::TinyAsciiStr;
7
8use std::collections::HashMap;
9
10use crate::headers::AcceptHeaderItem;
11use crate::templating::{HumusApiFormat, MediaType};
12
13/// An identifier string for an output format that either resolves to an API or Template renderer.
14///
15/// This is limited in length (currently 16 ASCII chars)
16pub type HumusFormatIdentifier = TinyAsciiStr<16>;
17
18/// The datastructure that is parsed from the `templates.toml` file in the templates directory.
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct TemplatesManifest {
21	/// The default template format to use in case none is specified
22	pub default_format: HumusFormatIdentifier,
23
24	/// A description of all supported template formats
25	pub format: HashMap<HumusFormatIdentifier, FormatDescription>,
26
27	/// Aliases for adding support for additional media types and for disambiguating when multiple frmats map to the same media type (wildcards).
28	#[serde(default)]
29	pub media_type_aliases: HashMap<AcceptHeaderItem, HumusFormatIdentifier>,
30
31	/// Fallback for when a UA doesn't request a known format, applied before the `default_format` after the `media_type_aliases`. Prioritized in order of increasing length.
32	#[serde(default)]
33	pub format_by_user_agent_prefix: HashMap<String, HumusFormatIdentifier>,
34}
35
36/// Describes a single template format that can be rendered to.
37#[derive(Debug, Clone, Serialize, Deserialize)]
38pub struct FormatDescription {
39	/// The media type that the server should serve the output of this template as.
40	pub media_type: MediaType,
41
42	/// The file extension that the template files use without a leading dot.
43	///
44	/// Example: if your template file is named `index.html` set `html` here.
45	pub extension: String,
46
47	/// Full filename of additional templates to load
48	#[serde(default)]
49	pub additional_templates: Vec<String>,
50}
51
52impl TemplatesManifest {
53	fn is_valid_format<ApiFormat: HumusApiFormat>(&self, format: &HumusFormatIdentifier) -> bool {
54		self.format.contains_key(format) || ApiFormat::from_name(format).is_some()
55	}
56
57	/// Searches the template manifest for problems in the context of a given `ApiFormat`
58	pub fn find_problems<ApiFormat: HumusApiFormat>(&self) -> Vec<TemplatesManifestProblem> {
59		let mut problems = vec![];
60
61		// Test for default format
62		if !self.is_valid_format::<ApiFormat>(&self.default_format) {
63			problems.push(TemplatesManifestProblem::DefaultReferencesNonSpecifiedFormat);
64		}
65
66		let mut media_type_to_formats: HashMap<AcceptHeaderItem, Vec<HumusFormatIdentifier>> =
67			HashMap::new();
68
69		for (format, description) in self.format.iter() {
70			// Test for overlaps with API format
71			if ApiFormat::from_name(format).is_some() {
72				problems.push(
73					TemplatesManifestProblem::SpecifiedFormatOverlapsWithApiFormat {
74						format: *format,
75					},
76				);
77			}
78
79			// Test for extensions that start or end with a dot
80			if description.extension.starts_with(".") || description.extension.ends_with(".") {
81				problems.push(
82					TemplatesManifestProblem::ExtensionShouldNotStartOrEndWithDot {
83						format: *format,
84					},
85				)
86			}
87
88			let media_type_string = description.media_type.to_string();
89
90			if media_type_string != description.media_type.essence_str() {
91				media_type_to_formats
92					.entry(AcceptHeaderItem::MediaType(
93						description.media_type.essence_str().to_string(),
94					))
95					.or_default()
96					.push(*format);
97			}
98
99			media_type_to_formats
100				.entry(AcceptHeaderItem::MediaType(media_type_string))
101				.or_default()
102				.push(*format);
103
104			media_type_to_formats
105				.entry(AcceptHeaderItem::PartialWildcard(
106					description.media_type.type_().to_string(),
107				))
108				.or_default()
109				.push(*format);
110		}
111
112		for api_format in ApiFormat::get_all() {
113			let format = api_format.get_name();
114			let media_type_string = api_format.get_media_type().to_string();
115
116			if media_type_string != api_format.get_media_type().essence_str() {
117				media_type_to_formats
118					.entry(AcceptHeaderItem::MediaType(
119						api_format.get_media_type().essence_str().to_owned(),
120					))
121					.or_default()
122					.push(format);
123			}
124
125			media_type_to_formats
126				.entry(AcceptHeaderItem::MediaType(media_type_string))
127				.or_default()
128				.push(format);
129
130			media_type_to_formats
131				.entry(AcceptHeaderItem::PartialWildcard(
132					api_format.get_media_type().type_().to_string(),
133				))
134				.or_default()
135				.push(format);
136		}
137
138		for (accept_header_item, formats) in media_type_to_formats {
139			if formats.len() <= 1 || self.media_type_aliases.contains_key(&accept_header_item) {
140				continue;
141			}
142
143			problems.push(TemplatesManifestProblem::AmbigiousMediaType {
144				media_type: accept_header_item,
145				formats,
146			});
147		}
148
149		for (accept_header_item, format) in self.media_type_aliases.iter() {
150			if matches!(accept_header_item, AcceptHeaderItem::Wildcard) {
151				problems.push(TemplatesManifestProblem::BlankWildcardMediaTypeAliasNotSupported);
152				continue;
153			}
154			if !self.is_valid_format::<ApiFormat>(format) {
155				problems.push(
156					TemplatesManifestProblem::MediaTypeAliasReferencesNonSpecifiedFormat {
157						accept_header_item: accept_header_item.clone(),
158					},
159				)
160			}
161		}
162
163		for (prefix, format) in self.format_by_user_agent_prefix.iter() {
164			if !self.is_valid_format::<ApiFormat>(format) {
165				problems.push(
166					TemplatesManifestProblem::DefaultByUserAgentReferencesNonSpecifiedFormat {
167						prefix: prefix.clone(),
168					},
169				);
170			}
171		}
172
173		problems
174	}
175}
176
177/// Describes a single problem that can occur with a template manifest
178#[derive(Debug, thiserror::Error)]
179pub enum TemplatesManifestProblem {
180	#[error("The File extension at format.{format}.extension may not start or end with a dot.")]
181	ExtensionShouldNotStartOrEndWithDot { format: HumusFormatIdentifier },
182
183	#[error(
184		"The format specified at format.{format} overlaps with an API format that uses the same id, please change the format id."
185	)]
186	SpecifiedFormatOverlapsWithApiFormat { format: HumusFormatIdentifier },
187
188	#[error("The default_format references a format that isn't specified as format or API format.")]
189	DefaultReferencesNonSpecifiedFormat,
190
191	#[error(
192		"The media_type_aliases.{accept_header_item:?} references a format that isn't specified as format or API format."
193	)]
194	MediaTypeAliasReferencesNonSpecifiedFormat {
195		accept_header_item: AcceptHeaderItem,
196	},
197
198	#[error(
199		"The default_format_by_user_agent_prefix.{prefix:?} references a format that isn't specified as format or API format."
200	)]
201	DefaultByUserAgentReferencesNonSpecifiedFormat { prefix: String },
202
203	#[error(
204		"The media_type {media_type:?} is given for multiple formats {formats:?}, which makes it unclear which format should be the default for this media type. Please specify a default media_type using a media type alias."
205	)]
206	AmbigiousMediaType {
207		media_type: AcceptHeaderItem,
208		formats: Vec<HumusFormatIdentifier>,
209	},
210
211	#[error(
212		"Using '*/*' as a key in media_type_aliases is not supported, use the default_format for this case."
213	)]
214	BlankWildcardMediaTypeAliasNotSupported,
215}