Skip to main content

featherbit/plugins/native/
oas_validator.rs

1//! The `oas-validator` node — validates the incoming request against an
2//! OpenAPI 3 (OAS 3) specification before it reaches the upstream.
3//!
4//! Port of APISIX's `oas-validator` plugin. The OpenAPI document is supplied
5//! inline as a JSON object in the node config; at config load every
6//! operation's `requestBody` JSON Schema is compiled and its required
7//! parameters are indexed, so a malformed spec or an uncompilable schema fails
8//! policy compilation, not a live request.
9//!
10//! At request time the plugin matches the request's method + path (with
11//! OpenAPI path templating, e.g. `/users/{id}` matches `/users/123`) against
12//! the spec. When an operation matches it validates required query/header
13//! parameters and the JSON request body against the operation's schema; on any
14//! violation the request is rejected through the `denied` port. When **no**
15//! operation matches, the request passes through untouched (matching APISIX:
16//! it is not the validator's job to 404).
17//!
18//! ## Deviations from APISIX / scope
19//!
20//! - **Inline-JSON spec only.** The `spec` is an inline OpenAPI JSON object.
21//!   APISIX's `spec` (a JSON *string*) and `spec_url` (remote fetch) forms, a
22//!   YAML loader, and secret-reference indirection are out of scope.
23//! - **Faithful subset.** Validated: presence of `required` query/header
24//!   parameters, and the `application/json` `requestBody` schema. Local
25//!   `$ref`s (`#/components/...`) are resolved by embedding `components` into
26//!   the compiled schema root. Not covered: response validation, parameter
27//!   *type/format* coercion and schema validation, `oneOf`/`anyOf` operation
28//!   selection, cookie parameters, and `$ref`s to external documents.
29//! - APISIX's `skip_*` toggles, `verbose_errors`, and `reject_if_not_match`
30//!   are not modelled; a matched operation is always validated and violations
31//!   are always rejected with `rejected_code`.
32
33use 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
41/// One segment of a templated OpenAPI path.
42enum Segment {
43    /// A literal path segment that must match exactly.
44    Literal(String),
45    /// A `{name}` template segment that matches any single non-empty segment.
46    Param,
47}
48
49/// A compiled OpenAPI operation: everything needed to match a request and
50/// validate it, precomputed at config load.
51struct CompiledOp {
52    /// Uppercase HTTP method (`GET`, `POST`, …).
53    method: String,
54    /// The path template split into segments, for matching.
55    segments: Vec<Segment>,
56    /// Count of literal segments — used to prefer more-specific matches.
57    literal_count: usize,
58    /// Names of `required: true` query parameters.
59    required_query: Vec<String>,
60    /// Lowercased names of `required: true` header parameters.
61    required_headers: Vec<String>,
62    /// Compiled `application/json` requestBody schema, if any.
63    body_schema: Option<jsonschema::Validator>,
64    /// Whether the requestBody is marked `required: true`.
65    body_required: bool,
66}
67
68/// Validates requests against a compiled OpenAPI 3 spec.
69pub struct OasValidatorPlugin {
70    operations: Vec<CompiledOp>,
71    rejected_code: u16,
72    /// Fixed message returned instead of the per-violation detail. Supports
73    /// `{{namespace.path}}` references (no legacy `$var` interpolation —
74    /// this field never supported it, so this sweep must not start).
75    rejected_msg: Option<Template>,
76}
77
78/// Resolves a local JSON pointer `$ref` (`#/a/b/c`) against the root document.
79fn 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
84/// Splits an OpenAPI path template into segments.
85fn 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
99/// Returns true if a request path matches this operation's segment template.
100fn 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
122/// Gathers `required` parameters (query + header) for an operation, merging
123/// path-item-level and operation-level parameter lists and resolving local
124/// `$ref`s. Returns `(required_query, required_headers_lowercased)`.
125fn 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            // Resolve a parameter $ref if present.
140            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                // path params are inherently present once the path matches;
162                // cookie params are out of scope.
163                _ => {}
164            }
165        }
166    }
167
168    Ok((required_query, required_headers))
169}
170
171/// Compiles the `application/json` requestBody schema for an operation, if
172/// present, embedding the spec's `components` so local `$ref`s resolve.
173fn 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    // requestBody itself may be a $ref.
185    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    // Embed `components` into the schema root so `#/components/...` refs resolve.
215    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    /// Builds the plugin from node config.
237    ///
238    /// Accepted keys:
239    /// - `spec` (object, **required**): the OpenAPI 3 document as an inline
240    ///   JSON object. Its `paths` are walked and every operation's
241    ///   `application/json` requestBody schema is compiled and required
242    ///   parameters indexed. A non-object `spec`, a malformed `paths`, or an
243    ///   uncompilable schema fails here at config load.
244    /// - `rejected_code` (integer 400–599, default `400`): response status for
245    ///   rejected requests.
246    /// - `rejected_msg` (string): fixed message returned instead of the
247    ///   per-violation detail. Supports `{{namespace.path}}` references.
248    ///
249    /// ```yaml
250    /// type: oas-validator
251    /// config:
252    ///   rejected_code: 400
253    ///   spec:
254    ///     openapi: 3.0.0
255    ///     info: { title: demo, version: "1.0" }
256    ///     paths:
257    ///       /users/{id}:
258    ///         get:
259    ///           parameters:
260    ///             - { name: verbose, in: query, required: true, schema: { type: string } }
261    ///         post:
262    ///           requestBody:
263    ///             required: true
264    ///             content:
265    ///               application/json:
266    ///                 schema:
267    ///                   type: object
268    ///                   required: [name]
269    ///                   properties: { name: { type: string } }
270    /// ```
271    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                // Rebuild segments per op (Segment isn't Clone; cheap).
312                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            // Discard warnings here — the compile-time walk (a later task)
340            // reports well-formed-but-unknown references; execution must not.
341            .map(|s| Template::parse(s).0);
342
343        Ok(Self {
344            operations,
345            rejected_code,
346            rejected_msg,
347        })
348    }
349
350    /// Finds the best-matching operation for a method + path: the matching
351    /// operation with the most literal (non-templated) segments wins, so an
352    /// exact path is preferred over a templated one.
353    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    /// Writes the rejection onto the response and routes the context through
361    /// the node's `denied` port.
362    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        // Find the matching operation; no match → pass through (not our job to 404).
388        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        // Required query parameters must be present.
394        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        // Required header parameters must be present (headers are lowercased).
401        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        // Request body validation.
408        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); // absent content-type: treat as JSON
422                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        // GET /users/{id} requires ?verbose and x-trace header, path templating matches.
517        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        // no verbose query param
535        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        // missing required "name" (via $ref schema)
574        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"); // empty body, requestBody.required
583        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        // No operation for this path → pass through untouched.
591        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        // wrong method on a known path also passes through
595        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        // no verbose query param -> rejected
618        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        // missing spec
627        assert!(OasValidatorPlugin::from_config(&HashMap::new()).is_err());
628
629        // spec not an object
630        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        // spec without paths
635        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        // uncompilable body schema fails fast
643        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        // rejected_code out of range
665        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}