1use async_trait::async_trait;
34use bytes::Bytes;
35use std::collections::HashMap;
36
37use crate::context::Context;
38use crate::plugins::{Plugin, PluginOutput, PluginResult};
39use crate::vars::template::Template;
40
41enum Segment {
43 Literal(String),
45 Param,
47}
48
49struct CompiledOp {
52 method: String,
54 segments: Vec<Segment>,
56 literal_count: usize,
58 required_query: Vec<String>,
60 required_headers: Vec<String>,
62 body_schema: Option<jsonschema::Validator>,
64 body_required: bool,
66}
67
68pub struct OasValidatorPlugin {
70 operations: Vec<CompiledOp>,
71 rejected_code: u16,
72 rejected_msg: Option<Template>,
76}
77
78fn resolve_ref<'a>(root: &'a serde_json::Value, reference: &str) -> Option<&'a serde_json::Value> {
80 let pointer = reference.strip_prefix('#')?;
81 root.pointer(pointer)
82}
83
84fn parse_template(path: &str) -> (Vec<Segment>, usize) {
86 let mut segments = Vec::new();
87 let mut literal_count = 0;
88 for part in path.split('/').filter(|s| !s.is_empty()) {
89 if part.starts_with('{') && part.ends_with('}') {
90 segments.push(Segment::Param);
91 } else {
92 literal_count += 1;
93 segments.push(Segment::Literal(part.to_string()));
94 }
95 }
96 (segments, literal_count)
97}
98
99fn path_matches(segments: &[Segment], req_path: &str) -> bool {
101 let parts: Vec<&str> = req_path.split('/').filter(|s| !s.is_empty()).collect();
102 if parts.len() != segments.len() {
103 return false;
104 }
105 for (seg, part) in segments.iter().zip(parts.iter()) {
106 match seg {
107 Segment::Param => {
108 if part.is_empty() {
109 return false;
110 }
111 }
112 Segment::Literal(lit) => {
113 if lit != part {
114 return false;
115 }
116 }
117 }
118 }
119 true
120}
121
122fn collect_required_params(
126 spec: &serde_json::Value,
127 path_item: &serde_json::Value,
128 operation: &serde_json::Value,
129) -> Result<(Vec<String>, Vec<String>), String> {
130 let mut required_query = Vec::new();
131 let mut required_headers = Vec::new();
132
133 let lists = [path_item.get("parameters"), operation.get("parameters")];
134 for list in lists.into_iter().flatten() {
135 let arr = list
136 .as_array()
137 .ok_or("oas-validator: 'parameters' must be an array")?;
138 for raw in arr {
139 let param = if let Some(r) = raw.get("$ref").and_then(|v| v.as_str()) {
141 resolve_ref(spec, r)
142 .ok_or_else(|| format!("oas-validator: unresolved parameter $ref '{}'", r))?
143 } else {
144 raw
145 };
146
147 let required = param
148 .get("required")
149 .and_then(|v| v.as_bool())
150 .unwrap_or(false);
151 if !required {
152 continue;
153 }
154 let name = match param.get("name").and_then(|v| v.as_str()) {
155 Some(n) => n,
156 None => continue,
157 };
158 match param.get("in").and_then(|v| v.as_str()) {
159 Some("query") => required_query.push(name.to_string()),
160 Some("header") => required_headers.push(name.to_lowercase()),
161 _ => {}
164 }
165 }
166 }
167
168 Ok((required_query, required_headers))
169}
170
171fn compile_body_schema(
174 spec: &serde_json::Value,
175 operation: &serde_json::Value,
176 method: &str,
177 template: &str,
178) -> Result<(Option<jsonschema::Validator>, bool), String> {
179 let request_body = match operation.get("requestBody") {
180 Some(rb) => rb,
181 None => return Ok((None, false)),
182 };
183
184 let request_body = if let Some(r) = request_body.get("$ref").and_then(|v| v.as_str()) {
186 resolve_ref(spec, r)
187 .ok_or_else(|| format!("oas-validator: unresolved requestBody $ref '{}'", r))?
188 } else {
189 request_body
190 };
191
192 let body_required = request_body
193 .get("required")
194 .and_then(|v| v.as_bool())
195 .unwrap_or(false);
196
197 let schema = request_body
198 .get("content")
199 .and_then(|c| c.get("application/json"))
200 .and_then(|m| m.get("schema"));
201
202 let schema = match schema {
203 Some(s) => s,
204 None => return Ok((None, body_required)),
205 };
206
207 if !schema.is_object() {
208 return Err(format!(
209 "oas-validator: {} {} requestBody schema must be an object",
210 method, template
211 ));
212 }
213
214 let mut root = schema.clone();
216 if let (Some(map), Some(components)) = (root.as_object_mut(), spec.get("components")) {
217 map.entry("components".to_string())
218 .or_insert_with(|| components.clone());
219 }
220
221 let validator = jsonschema::validator_for(&root).map_err(|e| {
222 format!(
223 "oas-validator: invalid requestBody schema for {} {}: {}",
224 method, template, e
225 )
226 })?;
227
228 Ok((Some(validator), body_required))
229}
230
231const HTTP_METHODS: [&str; 8] = [
232 "get", "put", "post", "delete", "options", "head", "patch", "trace",
233];
234
235impl OasValidatorPlugin {
236 pub fn from_config(config: &HashMap<String, serde_json::Value>) -> Result<Self, String> {
272 let spec = config
273 .get("spec")
274 .ok_or("oas-validator: 'spec' (inline OpenAPI JSON object) is required")?;
275
276 if !spec.is_object() {
277 return Err("oas-validator: 'spec' must be a JSON object".to_string());
278 }
279
280 let paths = spec
281 .get("paths")
282 .and_then(|p| p.as_object())
283 .ok_or("oas-validator: spec is missing a 'paths' object")?;
284
285 let mut operations = Vec::new();
286 for (template, path_item) in paths {
287 if !path_item.is_object() {
288 return Err(format!(
289 "oas-validator: path item '{}' must be an object",
290 template
291 ));
292 }
293
294 for method in HTTP_METHODS {
295 let operation = match path_item.get(method) {
296 Some(op) => op,
297 None => continue,
298 };
299 if !operation.is_object() {
300 return Err(format!(
301 "oas-validator: operation '{} {}' must be an object",
302 method, template
303 ));
304 }
305
306 let (required_query, required_headers) =
307 collect_required_params(spec, path_item, operation)?;
308 let (body_schema, body_required) =
309 compile_body_schema(spec, operation, method, template)?;
310
311 let (segments, literal_count) = parse_template(template);
313 operations.push(CompiledOp {
314 method: method.to_uppercase(),
315 segments,
316 literal_count,
317 required_query,
318 required_headers,
319 body_schema,
320 body_required,
321 });
322 }
323 }
324
325 let rejected_code = match config.get("rejected_code") {
326 None => 400,
327 Some(v) => {
328 let code = v
329 .as_u64()
330 .filter(|c| (400..=599).contains(c))
331 .ok_or("oas-validator: 'rejected_code' must be an integer in 400..=599")?;
332 code as u16
333 }
334 };
335
336 let rejected_msg = config
337 .get("rejected_msg")
338 .and_then(|v| v.as_str())
339 .map(|s| Template::parse(s).0);
342
343 Ok(Self {
344 operations,
345 rejected_code,
346 rejected_msg,
347 })
348 }
349
350 fn match_operation(&self, method: &str, path: &str) -> Option<&CompiledOp> {
354 self.operations
355 .iter()
356 .filter(|op| op.method == method && path_matches(&op.segments, path))
357 .max_by_key(|op| op.literal_count)
358 }
359
360 fn reject(&self, mut ctx: Context, detail: String) -> PluginResult {
363 let message = self
364 .rejected_msg
365 .as_ref()
366 .map(|t| t.render(&ctx).into_owned())
367 .unwrap_or(detail);
368 ctx.response.status_code = self.rejected_code;
369 ctx.response.body = Bytes::from(
370 serde_json::json!({ "error": "oas_validation_failed", "message": message }).to_string(),
371 );
372 ctx.response.headers.insert(
373 "content-type".to_string(),
374 vec!["application/json".to_string()],
375 );
376 Ok(PluginOutput::on_port(ctx, "denied"))
377 }
378}
379
380#[async_trait]
381impl Plugin for OasValidatorPlugin {
382 fn plugin_type(&self) -> &str {
383 "oas-validator"
384 }
385
386 async fn execute(&self, ctx: Context) -> PluginResult {
387 let op = match self.match_operation(&ctx.request.method, &ctx.request.path) {
389 Some(op) => op,
390 None => return Ok(PluginOutput::success(ctx)),
391 };
392
393 for q in &op.required_query {
395 if !ctx.request.query_params.contains_key(q) {
396 return self.reject(ctx, format!("missing required query parameter '{}'", q));
397 }
398 }
399
400 for h in &op.required_headers {
402 if !ctx.request.headers.contains_key(h) {
403 return self.reject(ctx, format!("missing required header '{}'", h));
404 }
405 }
406
407 let body_empty = ctx.request.body.is_empty();
409 if op.body_required && body_empty {
410 return self.reject(ctx, "request body is required".to_string());
411 }
412
413 if let Some(validator) = &op.body_schema {
414 if !body_empty {
415 let is_json = ctx
416 .request
417 .headers
418 .get("content-type")
419 .and_then(|v| v.first())
420 .map(|ct| ct.to_lowercase().starts_with("application/json"))
421 .unwrap_or(true); if is_json {
423 let parsed: serde_json::Value = match serde_json::from_slice(&ctx.request.body)
424 {
425 Ok(v) => v,
426 Err(e) => {
427 return self
428 .reject(ctx, format!("failed to decode the request body: {}", e))
429 }
430 };
431 if let Err(e) = validator.validate(&parsed) {
432 return self.reject(ctx, format!("body validation failed: {}", e));
433 }
434 }
435 }
436 }
437
438 Ok(PluginOutput::success(ctx))
439 }
440}
441
442#[cfg(test)]
443mod tests {
444 use super::*;
445 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
446
447 fn spec() -> serde_json::Value {
448 serde_json::json!({
449 "openapi": "3.0.0",
450 "info": { "title": "demo", "version": "1.0" },
451 "components": {
452 "schemas": {
453 "User": {
454 "type": "object",
455 "required": ["name"],
456 "properties": { "name": { "type": "string", "minLength": 1 } }
457 }
458 }
459 },
460 "paths": {
461 "/users/{id}": {
462 "get": {
463 "parameters": [
464 { "name": "verbose", "in": "query", "required": true, "schema": { "type": "string" } },
465 { "name": "x-trace", "in": "header", "required": true, "schema": { "type": "string" } }
466 ]
467 },
468 "post": {
469 "requestBody": {
470 "required": true,
471 "content": {
472 "application/json": {
473 "schema": { "$ref": "#/components/schemas/User" }
474 }
475 }
476 }
477 }
478 }
479 }
480 })
481 }
482
483 fn plugin() -> OasValidatorPlugin {
484 let mut config = HashMap::new();
485 config.insert("spec".to_string(), spec());
486 OasValidatorPlugin::from_config(&config).unwrap()
487 }
488
489 fn ctx(method: &str, path: &str) -> Context {
490 Context {
491 request: GatewayRequest {
492 method: method.to_string(),
493 path: path.to_string(),
494 host: "localhost".to_string(),
495 scheme: "http".to_string(),
496 headers: HashMap::new(),
497 query_params: HashMap::new(),
498 body: Bytes::new(),
499 remote_addr: "127.0.0.1:1".to_string(),
500 protocol: Protocol::Http1,
501 },
502 response: GatewayResponse {
503 status_code: 0,
504 headers: HashMap::new(),
505 body: Bytes::new(),
506 stream: None,
507 },
508 message: HashMap::new(),
509 errors: Vec::new(),
510 }
511 }
512
513 #[tokio::test]
514 async fn test_oas_valid_request_with_params_passes() {
515 let p = plugin();
516 let mut c = ctx("GET", "/users/42");
518 c.request
519 .query_params
520 .insert("verbose".to_string(), vec!["true".to_string()]);
521 c.request
522 .headers
523 .insert("x-trace".to_string(), vec!["abc".to_string()]);
524 assert!(p.execute(c).await.unwrap().port.is_none());
525 }
526
527 #[tokio::test]
528 async fn test_oas_missing_required_query_rejected() {
529 let p = plugin();
530 let mut c = ctx("GET", "/users/42");
531 c.request
532 .headers
533 .insert("x-trace".to_string(), vec!["abc".to_string()]);
534 let out = p.execute(c).await.unwrap();
536 assert_eq!(out.port, Some("denied"));
537 assert_eq!(out.context.response.status_code, 400);
538 let body: serde_json::Value = serde_json::from_slice(&out.context.response.body).unwrap();
539 assert_eq!(body["error"], "oas_validation_failed");
540 }
541
542 #[tokio::test]
543 async fn test_oas_missing_required_header_rejected() {
544 let p = plugin();
545 let mut c = ctx("GET", "/users/42");
546 c.request
547 .query_params
548 .insert("verbose".to_string(), vec!["true".to_string()]);
549 let out = p.execute(c).await.unwrap();
550 assert_eq!(out.port, Some("denied"));
551 }
552
553 #[tokio::test]
554 async fn test_oas_valid_body_passes() {
555 let p = plugin();
556 let mut c = ctx("POST", "/users/42");
557 c.request.headers.insert(
558 "content-type".to_string(),
559 vec!["application/json".to_string()],
560 );
561 c.request.body = Bytes::from(r#"{"name":"jack"}"#);
562 assert!(p.execute(c).await.unwrap().port.is_none());
563 }
564
565 #[tokio::test]
566 async fn test_oas_bad_body_rejected() {
567 let p = plugin();
568 let mut c = ctx("POST", "/users/42");
569 c.request.headers.insert(
570 "content-type".to_string(),
571 vec!["application/json".to_string()],
572 );
573 c.request.body = Bytes::from(r#"{"age":3}"#);
575 let out = p.execute(c).await.unwrap();
576 assert_eq!(out.port, Some("denied"));
577 }
578
579 #[tokio::test]
580 async fn test_oas_required_body_missing_rejected() {
581 let p = plugin();
582 let c = ctx("POST", "/users/42"); let out = p.execute(c).await.unwrap();
584 assert_eq!(out.port, Some("denied"));
585 }
586
587 #[tokio::test]
588 async fn test_oas_non_matching_path_passes_through() {
589 let p = plugin();
590 let out = p.execute(ctx("GET", "/nope/here")).await.unwrap();
592 assert!(out.port.is_none());
593 assert_eq!(out.context.response.status_code, 0);
594 assert!(p
596 .execute(ctx("DELETE", "/users/42"))
597 .await
598 .unwrap()
599 .port
600 .is_none());
601 }
602
603 #[tokio::test]
604 async fn test_oas_rejected_msg_renders_template() {
605 let mut config = HashMap::new();
606 config.insert("spec".to_string(), spec());
607 config.insert(
608 "rejected_msg".to_string(),
609 serde_json::json!("invalid request to {{request.path}}"),
610 );
611 let p = OasValidatorPlugin::from_config(&config).unwrap();
612
613 let mut c = ctx("GET", "/users/42");
614 c.request
615 .headers
616 .insert("x-trace".to_string(), vec!["abc".to_string()]);
617 let out = p.execute(c).await.unwrap();
619 assert_eq!(out.port, Some("denied"));
620 let body: serde_json::Value = serde_json::from_slice(&out.context.response.body).unwrap();
621 assert_eq!(body["message"], "invalid request to /users/42");
622 }
623
624 #[test]
625 fn test_oas_config_rejections() {
626 assert!(OasValidatorPlugin::from_config(&HashMap::new()).is_err());
628
629 let mut c = HashMap::new();
631 c.insert("spec".to_string(), serde_json::json!("not an object"));
632 assert!(OasValidatorPlugin::from_config(&c).is_err());
633
634 let mut c = HashMap::new();
636 c.insert(
637 "spec".to_string(),
638 serde_json::json!({ "openapi": "3.0.0" }),
639 );
640 assert!(OasValidatorPlugin::from_config(&c).is_err());
641
642 let mut c = HashMap::new();
644 c.insert(
645 "spec".to_string(),
646 serde_json::json!({
647 "paths": {
648 "/x": {
649 "post": {
650 "requestBody": {
651 "content": {
652 "application/json": {
653 "schema": { "type": "not-a-real-type" }
654 }
655 }
656 }
657 }
658 }
659 }
660 }),
661 );
662 assert!(OasValidatorPlugin::from_config(&c).is_err());
663
664 let mut c = HashMap::new();
666 c.insert("spec".to_string(), spec());
667 c.insert("rejected_code".to_string(), serde_json::json!(200));
668 assert!(OasValidatorPlugin::from_config(&c).is_err());
669 }
670
671 #[test]
672 fn test_oas_path_templating_matcher() {
673 let (segs, lits) = parse_template("/users/{id}/posts");
674 assert_eq!(lits, 2);
675 assert!(path_matches(&segs, "/users/9/posts"));
676 assert!(!path_matches(&segs, "/users/9"));
677 assert!(!path_matches(&segs, "/users/9/posts/1"));
678 }
679}