featherbit/plugins/native/
error_page.rs1use async_trait::async_trait;
30use bytes::Bytes;
31use std::collections::HashMap;
32
33use crate::context::Context;
34use crate::plugins::{Plugin, PluginOutput, PluginResult};
35use crate::vars::template::Template;
36
37const SUPPORTED_STATUS: [(u16, &str); 4] = [
40 (404, "404 Not Found"),
41 (500, "500 Internal Server Error"),
42 (502, "502 Bad Gateway"),
43 (503, "503 Service Unavailable"),
44];
45
46struct ErrorPage {
48 body: Template,
52 content_type: Template,
56}
57
58pub struct ErrorPagePlugin {
63 pages: HashMap<u16, ErrorPage>,
64}
65
66fn default_body(title: &str) -> String {
69 format!(
70 "<html>\n<head><title>{title}</title></head>\n<body>\n\
71 <center><h1>{title}</h1></center>\n\
72 <hr><center>featherbit</center>\n</body>\n</html>"
73 )
74}
75
76impl ErrorPagePlugin {
77 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
102 let mut pages = HashMap::new();
103
104 for (status, title) in SUPPORTED_STATUS {
105 let key = format!("error_{}", status);
106 let Some(raw) = config.get(&key) else {
107 continue;
108 };
109 let obj = raw
110 .as_object()
111 .ok_or_else(|| format!("{} must be an object", key))?;
112
113 let body = match obj.get("body") {
114 None => default_body(title),
115 Some(v) => v
116 .as_str()
117 .ok_or_else(|| format!("{}.body must be a string", key))?
118 .to_string(),
119 };
120 let content_type = match obj.get("content_type") {
121 None => "text/html".to_string(),
122 Some(v) => v
123 .as_str()
124 .ok_or_else(|| format!("{}.content_type must be a string", key))?
125 .to_string(),
126 };
127
128 let body = Template::parse(&body).0;
131 let content_type = Template::parse(&content_type).0;
132 pages.insert(status, ErrorPage { body, content_type });
133 }
134
135 Ok(Self { pages })
136 }
137}
138
139#[async_trait]
140impl Plugin for ErrorPagePlugin {
141 fn plugin_type(&self) -> &str {
142 "error-page"
143 }
144
145 async fn execute(&self, mut ctx: Context) -> PluginResult {
146 let gateway_generated = !ctx.errors.is_empty();
149
150 if gateway_generated {
151 if let Some(page) = self.pages.get(&ctx.response.status_code) {
152 let body = Bytes::from(page.body.render(&ctx).into_owned());
153 let content_type = page.content_type.render(&ctx).into_owned();
154 ctx.response.body = body;
155 ctx.response
156 .headers
157 .insert("content-type".to_string(), vec![content_type]);
158 ctx.response.headers.remove("content-length");
161 ctx.response.headers.remove("content-encoding");
162 }
163 }
164
165 Ok(PluginOutput::success(ctx))
166 }
167}
168
169#[cfg(test)]
170mod tests {
171 use super::*;
172 use crate::context::{GatewayError, GatewayRequest, GatewayResponse, Protocol};
173
174 fn test_context(status: u16, gateway_generated: bool) -> Context {
175 let mut response_headers = HashMap::new();
176 response_headers.insert("content-type".to_string(), vec!["text/plain".to_string()]);
177 response_headers.insert("content-length".to_string(), vec!["8".to_string()]);
178 let errors = if gateway_generated {
179 vec![GatewayError {
180 node_id: "upstream-1".to_string(),
181 code: "UPSTREAM_CONNECTION_ERROR".to_string(),
182 message: "connect refused".to_string(),
183 metadata: HashMap::new(),
184 }]
185 } else {
186 Vec::new()
187 };
188 Context {
189 request: GatewayRequest {
190 method: "GET".to_string(),
191 path: "/test".to_string(),
192 host: "localhost".to_string(),
193 scheme: "http".to_string(),
194 headers: HashMap::new(),
195 query_params: HashMap::new(),
196 body: Bytes::new(),
197 remote_addr: "127.0.0.1:12345".to_string(),
198 protocol: Protocol::Http1,
199 },
200 response: GatewayResponse {
201 status_code: status,
202 headers: response_headers,
203 body: Bytes::from_static(b"original"),
204 stream: None,
205 },
206 message: HashMap::new(),
207 errors,
208 }
209 }
210
211 fn plugin(config: serde_json::Value) -> ErrorPagePlugin {
212 let map: HashMap<String, serde_json::Value> =
213 serde_json::from_value(config).expect("test config must be an object");
214 ErrorPagePlugin::from_config(&map).expect("config should be valid")
215 }
216
217 #[tokio::test]
218 async fn test_error_page_replaces_gateway_error() {
219 let p = plugin(serde_json::json!({
220 "error_502": {
221 "body": "{\"error\": \"bad gateway\"}",
222 "content_type": "application/json"
223 }
224 }));
225 let out = p.execute(test_context(502, true)).await.unwrap();
226 assert_eq!(
227 out.context.response.body.as_ref(),
228 b"{\"error\": \"bad gateway\"}"
229 );
230 assert_eq!(
231 out.context.response.headers.get("content-type"),
232 Some(&vec!["application/json".to_string()])
233 );
234 assert!(!out.context.response.headers.contains_key("content-length"));
235 assert_eq!(out.context.response.status_code, 502);
236 }
237
238 #[tokio::test]
239 async fn test_error_page_default_body_and_content_type() {
240 let p = plugin(serde_json::json!({ "error_503": {} }));
241 let out = p.execute(test_context(503, true)).await.unwrap();
242 let body = String::from_utf8(out.context.response.body.to_vec()).unwrap();
243 assert!(body.contains("<h1>503 Service Unavailable</h1>"), "{body}");
244 assert!(body.contains("featherbit"));
245 assert_eq!(
246 out.context.response.headers.get("content-type"),
247 Some(&vec!["text/html".to_string()])
248 );
249 }
250
251 #[tokio::test]
252 async fn test_error_page_skips_upstream_sourced_response() {
253 let p = plugin(serde_json::json!({ "error_502": {} }));
256 let out = p.execute(test_context(502, false)).await.unwrap();
257 assert_eq!(out.context.response.body.as_ref(), b"original");
258 assert_eq!(
259 out.context.response.headers.get("content-type"),
260 Some(&vec!["text/plain".to_string()])
261 );
262 assert!(out.context.response.headers.contains_key("content-length"));
263 }
264
265 #[tokio::test]
266 async fn test_error_page_body_renders_template() {
267 let p = plugin(serde_json::json!({
268 "error_503": {
269 "body": "{\"error\": \"unavailable\", \"path\": \"{{request.path}}\"}",
270 "content_type": "application/json"
271 }
272 }));
273 let out = p.execute(test_context(503, true)).await.unwrap();
274 assert_eq!(
275 out.context.response.body.as_ref(),
276 b"{\"error\": \"unavailable\", \"path\": \"/test\"}"
277 );
278 }
279
280 #[tokio::test]
281 async fn test_error_page_content_type_renders_template() {
282 let p = plugin(serde_json::json!({
283 "error_503": {
284 "body": "unavailable",
285 "content_type": "text/plain; charset={{request.headers.x-charset}}"
286 }
287 }));
288 let mut ctx = test_context(503, true);
289 ctx.request
290 .headers
291 .insert("x-charset".to_string(), vec!["utf-16".to_string()]);
292 let out = p.execute(ctx).await.unwrap();
293 assert_eq!(
294 out.context.response.headers.get("content-type"),
295 Some(&vec!["text/plain; charset=utf-16".to_string()])
296 );
297 }
298
299 #[tokio::test]
300 async fn test_error_page_skips_unconfigured_status() {
301 let p = plugin(serde_json::json!({ "error_502": {} }));
302 let out = p.execute(test_context(500, true)).await.unwrap();
304 assert_eq!(out.context.response.body.as_ref(), b"original");
305
306 let p = plugin(serde_json::json!({ "error_502": {} }));
308 let out = p.execute(test_context(200, true)).await.unwrap();
309 assert_eq!(out.context.response.body.as_ref(), b"original");
310 }
311
312 #[test]
313 fn test_error_page_config_validation() {
314 for bad in [
315 serde_json::json!({ "error_502": "not an object" }),
316 serde_json::json!({ "error_404": { "body": 42 } }),
317 serde_json::json!({ "error_500": { "content_type": [] } }),
318 ] {
319 let map: HashMap<String, serde_json::Value> = serde_json::from_value(bad).unwrap();
320 assert!(ErrorPagePlugin::from_config(&map).is_err());
321 }
322 let map: HashMap<String, serde_json::Value> =
325 serde_json::from_value(serde_json::json!({ "error_418": {} })).unwrap();
326 let p = ErrorPagePlugin::from_config(&map).unwrap();
327 assert!(p.pages.is_empty());
328 }
329}