featherbit/plugins/native/
request_validation.rs1use async_trait::async_trait;
13use bytes::Bytes;
14use std::collections::HashMap;
15
16use crate::context::{Context, GatewayError};
17use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
18
19pub struct RequestValidationPlugin {
29 header_schema: Option<jsonschema::Validator>,
30 body_schema: Option<jsonschema::Validator>,
31 rejected_code: u16,
32 rejected_msg: Option<String>,
33}
34
35fn compile_schema(
38 config: &HashMap<String, serde_json::Value>,
39 key: &str,
40) -> Result<Option<jsonschema::Validator>, String> {
41 match config.get(key) {
42 None => Ok(None),
43 Some(raw) => {
44 if !raw.is_object() {
45 return Err(format!(
46 "request-validation: '{}' must be a JSON Schema object",
47 key
48 ));
49 }
50 jsonschema::validator_for(raw)
51 .map(Some)
52 .map_err(|e| format!("request-validation: invalid '{}': {}", key, e))
53 }
54 }
55}
56
57fn decode_urlencoded(body: &str) -> serde_json::Value {
62 let mut map = serde_json::Map::new();
63 for pair in body.split('&') {
64 if pair.is_empty() {
65 continue;
66 }
67 let (key, value) = match pair.split_once('=') {
68 Some((k, v)) => (urldecode(k), serde_json::Value::String(urldecode(v))),
69 None => (urldecode(pair), serde_json::Value::Bool(true)),
70 };
71 match map.get_mut(&key) {
72 None => {
73 map.insert(key, value);
74 }
75 Some(serde_json::Value::Array(arr)) => arr.push(value),
76 Some(existing) => {
77 let first = existing.take();
78 *existing = serde_json::Value::Array(vec![first, value]);
79 }
80 }
81 }
82 serde_json::Value::Object(map)
83}
84
85fn urldecode(s: &str) -> String {
88 let bytes = s.as_bytes();
89 let mut out = Vec::with_capacity(bytes.len());
90 let mut i = 0;
91 while i < bytes.len() {
92 match bytes[i] {
93 b'+' => out.push(b' '),
94 b'%' if i + 2 < bytes.len() => {
95 if let (Some(h), Some(l)) = (hex_val(bytes[i + 1]), hex_val(bytes[i + 2])) {
96 out.push(h * 16 + l);
97 i += 3;
98 continue;
99 }
100 out.push(b'%');
101 }
102 b => out.push(b),
103 }
104 i += 1;
105 }
106 String::from_utf8_lossy(&out).into_owned()
107}
108
109fn hex_val(b: u8) -> Option<u8> {
110 match b {
111 b'0'..=b'9' => Some(b - b'0'),
112 b'a'..=b'f' => Some(b - b'a' + 10),
113 b'A'..=b'F' => Some(b - b'A' + 10),
114 _ => None,
115 }
116}
117
118impl RequestValidationPlugin {
119 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
145 let header_schema = compile_schema(config, "header_schema")?;
146 let body_schema = compile_schema(config, "body_schema")?;
147
148 if header_schema.is_none() && body_schema.is_none() {
149 return Err(
150 "request-validation: at least one of 'header_schema' or 'body_schema' is required"
151 .to_string(),
152 );
153 }
154
155 let rejected_code = match config.get("rejected_code") {
156 None => 400,
157 Some(v) => {
158 let code = v
159 .as_u64()
160 .filter(|c| (200..=599).contains(c))
161 .ok_or("request-validation: 'rejected_code' must be an integer in 200..=599")?;
162 code as u16
163 }
164 };
165
166 let rejected_msg = config
167 .get("rejected_msg")
168 .and_then(|v| v.as_str())
169 .map(String::from);
170
171 Ok(Self {
172 header_schema,
173 body_schema,
174 rejected_code,
175 rejected_msg,
176 })
177 }
178
179 fn reject(&self, mut ctx: Context, detail: String) -> PluginExecutionError {
182 let message = self.rejected_msg.clone().unwrap_or(detail);
183 ctx.response.status_code = self.rejected_code;
184 ctx.response.body = Bytes::from(
185 serde_json::json!({ "error": "validation_failed", "message": message }).to_string(),
186 );
187 ctx.response.headers.insert(
188 "content-type".to_string(),
189 vec!["application/json".to_string()],
190 );
191 PluginExecutionError {
192 context: ctx,
193 error: GatewayError {
194 node_id: String::new(),
195 code: "VALIDATION_FAILED".to_string(),
196 message,
197 metadata: HashMap::new(),
198 },
199 }
200 }
201}
202
203#[async_trait]
204impl Plugin for RequestValidationPlugin {
205 fn plugin_type(&self) -> &str {
206 "request-validation"
207 }
208
209 async fn execute(
210 &self,
211 mut ctx: Context,
212 _named_inputs: &HashMap<String, serde_json::Value>,
213 ) -> PluginResult {
214 if let Some(validator) = &self.header_schema {
215 let headers: serde_json::Map<String, serde_json::Value> = ctx
216 .request
217 .headers
218 .iter()
219 .filter_map(|(k, v)| {
220 v.first()
221 .map(|first| (k.clone(), serde_json::Value::String(first.clone())))
222 })
223 .collect();
224 if let Err(e) = validator.validate(&serde_json::Value::Object(headers)) {
225 return Err(self.reject(ctx, format!("header validation failed: {}", e)));
226 }
227 }
228
229 if let Some(validator) = &self.body_schema {
230 if ctx.request.body.is_empty() {
231 return Err(self.reject(ctx, "request body is required".to_string()));
232 }
233
234 let is_urlencoded = ctx
235 .request
236 .headers
237 .get("content-type")
238 .and_then(|v| v.first())
239 .map(|ct| {
240 ct.to_lowercase()
241 .starts_with("application/x-www-form-urlencoded")
242 })
243 .unwrap_or(false);
244
245 let (parsed, body_is_json) = if is_urlencoded {
246 let text = String::from_utf8_lossy(&ctx.request.body).into_owned();
247 (decode_urlencoded(&text), false)
248 } else {
249 match serde_json::from_slice::<serde_json::Value>(&ctx.request.body) {
250 Ok(v) => (v, true),
251 Err(e) => {
252 return Err(
253 self.reject(ctx, format!("failed to decode the request body: {}", e))
254 );
255 }
256 }
257 };
258
259 if let Err(e) = validator.validate(&parsed) {
260 return Err(self.reject(ctx, format!("body validation failed: {}", e)));
261 }
262
263 if body_is_json {
264 ctx.request.body = Bytes::from(serde_json::to_vec(&parsed).unwrap_or_default());
268 ctx.request.headers.remove("content-length");
269 }
270 }
271
272 Ok(PluginOutput {
273 context: ctx,
274 named_outputs: HashMap::new(),
275 })
276 }
277}
278
279#[cfg(test)]
280mod tests {
281 use super::*;
282 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
283
284 fn test_context(body: &str, content_type: Option<&str>) -> Context {
285 let mut headers = HashMap::new();
286 headers.insert("x-api-version".to_string(), vec!["2".to_string()]);
287 if let Some(ct) = content_type {
288 headers.insert("content-type".to_string(), vec![ct.to_string()]);
289 }
290 if !body.is_empty() {
291 headers.insert("content-length".to_string(), vec![body.len().to_string()]);
292 }
293
294 Context {
295 request: GatewayRequest {
296 method: "POST".to_string(),
297 path: "/api".to_string(),
298 host: "localhost".to_string(),
299 scheme: "http".to_string(),
300 headers,
301 query_params: HashMap::new(),
302 body: Bytes::from(body.to_string()),
303 remote_addr: "127.0.0.1:12345".to_string(),
304 protocol: Protocol::Http1,
305 },
306 response: GatewayResponse {
307 status_code: 0,
308 headers: HashMap::new(),
309 body: Bytes::new(),
310 },
311 message: HashMap::new(),
312 errors: Vec::new(),
313 }
314 }
315
316 fn body_plugin(schema: serde_json::Value) -> RequestValidationPlugin {
317 let mut config = HashMap::new();
318 config.insert("body_schema".to_string(), schema);
319 RequestValidationPlugin::from_config(&config).unwrap()
320 }
321
322 #[tokio::test]
323 async fn test_request_validation_body_accept_and_normalize() {
324 let p = body_plugin(serde_json::json!({
325 "type": "object",
326 "required": ["name"],
327 "properties": { "name": { "type": "string" } }
328 }));
329 let ctx = test_context(r#"{"name": "jack"}"#, Some("application/json"));
330 let out = p.execute(ctx, &HashMap::new()).await.unwrap();
331 assert_eq!(out.context.request.body, Bytes::from(r#"{"name":"jack"}"#));
333 assert!(!out.context.request.headers.contains_key("content-length"));
334 }
335
336 #[tokio::test]
337 async fn test_request_validation_body_reject() {
338 let p = body_plugin(serde_json::json!({
339 "type": "object",
340 "required": ["name"]
341 }));
342 let ctx = test_context(r#"{"age": 3}"#, Some("application/json"));
343 let err = p.execute(ctx, &HashMap::new()).await.unwrap_err();
344 assert_eq!(err.error.code, "VALIDATION_FAILED");
345 assert_eq!(err.context.response.status_code, 400);
346 let body: serde_json::Value = serde_json::from_slice(&err.context.response.body).unwrap();
347 assert_eq!(body["error"], "validation_failed");
348 }
349
350 #[tokio::test]
351 async fn test_request_validation_non_json_body_rejected() {
352 let p = body_plugin(serde_json::json!({ "type": "object" }));
353 let ctx = test_context("this is not json", Some("application/json"));
354 let err = p.execute(ctx, &HashMap::new()).await.unwrap_err();
355 assert_eq!(err.error.code, "VALIDATION_FAILED");
356 }
357
358 #[tokio::test]
359 async fn test_request_validation_missing_body_rejected() {
360 let p = body_plugin(serde_json::json!({ "type": "object" }));
361 let err = p
362 .execute(test_context("", Some("application/json")), &HashMap::new())
363 .await
364 .unwrap_err();
365 assert_eq!(err.error.code, "VALIDATION_FAILED");
366 }
367
368 #[tokio::test]
369 async fn test_request_validation_urlencoded_body() {
370 let p = body_plugin(serde_json::json!({
371 "type": "object",
372 "required": ["user"],
373 "properties": { "user": { "type": "string", "minLength": 2 } }
374 }));
375 let ctx = test_context(
376 "user=jack¬e=hello%20world",
377 Some("application/x-www-form-urlencoded"),
378 );
379 let out = p.execute(ctx, &HashMap::new()).await.unwrap();
380 assert_eq!(
382 out.context.request.body,
383 Bytes::from("user=jack¬e=hello%20world")
384 );
385
386 let ctx = test_context("note=only", Some("application/x-www-form-urlencoded"));
387 assert!(p.execute(ctx, &HashMap::new()).await.is_err());
388 }
389
390 #[tokio::test]
391 async fn test_request_validation_header_schema() {
392 let mut config = HashMap::new();
393 config.insert(
394 "header_schema".to_string(),
395 serde_json::json!({
396 "type": "object",
397 "required": ["x-api-version"],
398 "properties": { "x-api-version": { "type": "string", "enum": ["2"] } }
399 }),
400 );
401 let p = RequestValidationPlugin::from_config(&config).unwrap();
402
403 assert!(p
404 .execute(test_context("", None), &HashMap::new())
405 .await
406 .is_ok());
407
408 let mut ctx = test_context("", None);
409 ctx.request.headers.remove("x-api-version");
410 let err = p.execute(ctx, &HashMap::new()).await.unwrap_err();
411 assert_eq!(err.error.code, "VALIDATION_FAILED");
412 }
413
414 #[tokio::test]
415 async fn test_request_validation_rejected_code_and_msg() {
416 let mut config = HashMap::new();
417 config.insert(
418 "body_schema".to_string(),
419 serde_json::json!({ "type": "object" }),
420 );
421 config.insert("rejected_code".to_string(), serde_json::json!(422));
422 config.insert("rejected_msg".to_string(), serde_json::json!("bad payload"));
423 let p = RequestValidationPlugin::from_config(&config).unwrap();
424
425 let ctx = test_context("[1,2,3]", Some("application/json"));
426 let err = p.execute(ctx, &HashMap::new()).await.unwrap_err();
427 assert_eq!(err.context.response.status_code, 422);
428 assert_eq!(err.error.message, "bad payload");
429 }
430
431 #[test]
432 fn test_request_validation_config_rejections() {
433 assert!(RequestValidationPlugin::from_config(&HashMap::new()).is_err());
435
436 let mut config = HashMap::new();
438 config.insert(
439 "body_schema".to_string(),
440 serde_json::json!({ "type": "definitely-not-a-type" }),
441 );
442 assert!(RequestValidationPlugin::from_config(&config).is_err());
443
444 let mut config = HashMap::new();
446 config.insert("body_schema".to_string(), serde_json::json!("nope"));
447 assert!(RequestValidationPlugin::from_config(&config).is_err());
448
449 let mut config = HashMap::new();
451 config.insert(
452 "body_schema".to_string(),
453 serde_json::json!({ "type": "object" }),
454 );
455 config.insert("rejected_code".to_string(), serde_json::json!(199));
456 assert!(RequestValidationPlugin::from_config(&config).is_err());
457 }
458
459 #[test]
460 fn test_request_validation_decode_urlencoded_shape() {
461 let v = decode_urlencoded("a=1&a=2&flag¬e=hello+world");
462 assert_eq!(v["a"], serde_json::json!(["1", "2"]));
463 assert_eq!(v["flag"], serde_json::json!(true));
464 assert_eq!(v["note"], serde_json::json!("hello world"));
465 }
466}