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