featherbit/plugins/native/
error_page.rs1use async_trait::async_trait;
19use bytes::Bytes;
20use std::collections::HashMap;
21
22use crate::context::Context;
23use crate::plugins::{Plugin, PluginOutput, PluginResult};
24
25const SUPPORTED_STATUS: [(u16, &str); 4] = [
28 (404, "404 Not Found"),
29 (500, "500 Internal Server Error"),
30 (502, "502 Bad Gateway"),
31 (503, "503 Service Unavailable"),
32];
33
34struct ErrorPage {
36 body: Bytes,
37 content_type: String,
38}
39
40pub struct ErrorPagePlugin {
45 pages: HashMap<u16, ErrorPage>,
46}
47
48fn default_body(title: &str) -> String {
51 format!(
52 "<html>\n<head><title>{title}</title></head>\n<body>\n\
53 <center><h1>{title}</h1></center>\n\
54 <hr><center>featherbit</center>\n</body>\n</html>"
55 )
56}
57
58impl ErrorPagePlugin {
59 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
82 let mut pages = HashMap::new();
83
84 for (status, title) in SUPPORTED_STATUS {
85 let key = format!("error_{}", status);
86 let Some(raw) = config.get(&key) else {
87 continue;
88 };
89 let obj = raw
90 .as_object()
91 .ok_or_else(|| format!("{} must be an object", key))?;
92
93 let body = match obj.get("body") {
94 None => default_body(title),
95 Some(v) => v
96 .as_str()
97 .ok_or_else(|| format!("{}.body must be a string", key))?
98 .to_string(),
99 };
100 let content_type = match obj.get("content_type") {
101 None => "text/html".to_string(),
102 Some(v) => v
103 .as_str()
104 .ok_or_else(|| format!("{}.content_type must be a string", key))?
105 .to_string(),
106 };
107
108 pages.insert(
109 status,
110 ErrorPage {
111 body: Bytes::from(body),
112 content_type,
113 },
114 );
115 }
116
117 Ok(Self { pages })
118 }
119}
120
121#[async_trait]
122impl Plugin for ErrorPagePlugin {
123 fn plugin_type(&self) -> &str {
124 "error-page"
125 }
126
127 async fn execute(
128 &self,
129 mut ctx: Context,
130 _named_inputs: &HashMap<String, serde_json::Value>,
131 ) -> PluginResult {
132 let gateway_generated = !ctx.errors.is_empty();
135
136 if gateway_generated {
137 if let Some(page) = self.pages.get(&ctx.response.status_code) {
138 ctx.response.body = page.body.clone();
139 ctx.response
140 .headers
141 .insert("content-type".to_string(), vec![page.content_type.clone()]);
142 ctx.response.headers.remove("content-length");
145 ctx.response.headers.remove("content-encoding");
146 }
147 }
148
149 Ok(PluginOutput {
150 context: ctx,
151 named_outputs: HashMap::new(),
152 })
153 }
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159 use crate::context::{GatewayError, GatewayRequest, GatewayResponse, Protocol};
160
161 fn test_context(status: u16, gateway_generated: bool) -> Context {
162 let mut response_headers = HashMap::new();
163 response_headers.insert("content-type".to_string(), vec!["text/plain".to_string()]);
164 response_headers.insert("content-length".to_string(), vec!["8".to_string()]);
165 let errors = if gateway_generated {
166 vec![GatewayError {
167 node_id: "upstream-1".to_string(),
168 code: "UPSTREAM_CONNECTION_ERROR".to_string(),
169 message: "connect refused".to_string(),
170 metadata: HashMap::new(),
171 }]
172 } else {
173 Vec::new()
174 };
175 Context {
176 request: GatewayRequest {
177 method: "GET".to_string(),
178 path: "/test".to_string(),
179 host: "localhost".to_string(),
180 scheme: "http".to_string(),
181 headers: HashMap::new(),
182 query_params: HashMap::new(),
183 body: Bytes::new(),
184 remote_addr: "127.0.0.1:12345".to_string(),
185 protocol: Protocol::Http1,
186 },
187 response: GatewayResponse {
188 status_code: status,
189 headers: response_headers,
190 body: Bytes::from_static(b"original"),
191 },
192 message: HashMap::new(),
193 errors,
194 }
195 }
196
197 fn plugin(config: serde_json::Value) -> ErrorPagePlugin {
198 let map: HashMap<String, serde_json::Value> =
199 serde_json::from_value(config).expect("test config must be an object");
200 ErrorPagePlugin::from_config(&map).expect("config should be valid")
201 }
202
203 #[tokio::test]
204 async fn test_error_page_replaces_gateway_error() {
205 let p = plugin(serde_json::json!({
206 "error_502": {
207 "body": "{\"error\": \"bad gateway\"}",
208 "content_type": "application/json"
209 }
210 }));
211 let out = p
212 .execute(test_context(502, true), &HashMap::new())
213 .await
214 .unwrap();
215 assert_eq!(
216 out.context.response.body.as_ref(),
217 b"{\"error\": \"bad gateway\"}"
218 );
219 assert_eq!(
220 out.context.response.headers.get("content-type"),
221 Some(&vec!["application/json".to_string()])
222 );
223 assert!(!out.context.response.headers.contains_key("content-length"));
224 assert_eq!(out.context.response.status_code, 502);
225 }
226
227 #[tokio::test]
228 async fn test_error_page_default_body_and_content_type() {
229 let p = plugin(serde_json::json!({ "error_503": {} }));
230 let out = p
231 .execute(test_context(503, true), &HashMap::new())
232 .await
233 .unwrap();
234 let body = String::from_utf8(out.context.response.body.to_vec()).unwrap();
235 assert!(body.contains("<h1>503 Service Unavailable</h1>"), "{body}");
236 assert!(body.contains("featherbit"));
237 assert_eq!(
238 out.context.response.headers.get("content-type"),
239 Some(&vec!["text/html".to_string()])
240 );
241 }
242
243 #[tokio::test]
244 async fn test_error_page_skips_upstream_sourced_response() {
245 let p = plugin(serde_json::json!({ "error_502": {} }));
248 let out = p
249 .execute(test_context(502, false), &HashMap::new())
250 .await
251 .unwrap();
252 assert_eq!(out.context.response.body.as_ref(), b"original");
253 assert_eq!(
254 out.context.response.headers.get("content-type"),
255 Some(&vec!["text/plain".to_string()])
256 );
257 assert!(out.context.response.headers.contains_key("content-length"));
258 }
259
260 #[tokio::test]
261 async fn test_error_page_skips_unconfigured_status() {
262 let p = plugin(serde_json::json!({ "error_502": {} }));
263 let out = p
265 .execute(test_context(500, true), &HashMap::new())
266 .await
267 .unwrap();
268 assert_eq!(out.context.response.body.as_ref(), b"original");
269
270 let p = plugin(serde_json::json!({ "error_502": {} }));
272 let out = p
273 .execute(test_context(200, true), &HashMap::new())
274 .await
275 .unwrap();
276 assert_eq!(out.context.response.body.as_ref(), b"original");
277 }
278
279 #[test]
280 fn test_error_page_config_validation() {
281 for bad in [
282 serde_json::json!({ "error_502": "not an object" }),
283 serde_json::json!({ "error_404": { "body": 42 } }),
284 serde_json::json!({ "error_500": { "content_type": [] } }),
285 ] {
286 let map: HashMap<String, serde_json::Value> = serde_json::from_value(bad).unwrap();
287 assert!(ErrorPagePlugin::from_config(&map).is_err());
288 }
289 let map: HashMap<String, serde_json::Value> =
292 serde_json::from_value(serde_json::json!({ "error_418": {} })).unwrap();
293 let p = ErrorPagePlugin::from_config(&map).unwrap();
294 assert!(p.pages.is_empty());
295 }
296}