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 `error` 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, GatewayError};
38use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
39
40/// One segment of a templated OpenAPI path.
41enum Segment {
42    /// A literal path segment that must match exactly.
43    Literal(String),
44    /// A `{name}` template segment that matches any single non-empty segment.
45    Param,
46}
47
48/// A compiled OpenAPI operation: everything needed to match a request and
49/// validate it, precomputed at config load.
50struct CompiledOp {
51    /// Uppercase HTTP method (`GET`, `POST`, …).
52    method: String,
53    /// The path template split into segments, for matching.
54    segments: Vec<Segment>,
55    /// Count of literal segments — used to prefer more-specific matches.
56    literal_count: usize,
57    /// Names of `required: true` query parameters.
58    required_query: Vec<String>,
59    /// Lowercased names of `required: true` header parameters.
60    required_headers: Vec<String>,
61    /// Compiled `application/json` requestBody schema, if any.
62    body_schema: Option<jsonschema::Validator>,
63    /// Whether the requestBody is marked `required: true`.
64    body_required: bool,
65}
66
67/// Validates requests against a compiled OpenAPI 3 spec.
68pub struct OasValidatorPlugin {
69    operations: Vec<CompiledOp>,
70    rejected_code: u16,
71    rejected_msg: Option<String>,
72}
73
74/// Resolves a local JSON pointer `$ref` (`#/a/b/c`) against the root document.
75fn 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
80/// Splits an OpenAPI path template into segments.
81fn 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
95/// Returns true if a request path matches this operation's segment template.
96fn 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
118/// Gathers `required` parameters (query + header) for an operation, merging
119/// path-item-level and operation-level parameter lists and resolving local
120/// `$ref`s. Returns `(required_query, required_headers_lowercased)`.
121fn 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            // Resolve a parameter $ref if present.
136            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                // path params are inherently present once the path matches;
158                // cookie params are out of scope.
159                _ => {}
160            }
161        }
162    }
163
164    Ok((required_query, required_headers))
165}
166
167/// Compiles the `application/json` requestBody schema for an operation, if
168/// present, embedding the spec's `components` so local `$ref`s resolve.
169fn 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    // requestBody itself may be a $ref.
181    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    // Embed `components` into the schema root so `#/components/...` refs resolve.
211    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    /// Builds the plugin from node config.
233    ///
234    /// Accepted keys:
235    /// - `spec` (object, **required**): the OpenAPI 3 document as an inline
236    ///   JSON object. Its `paths` are walked and every operation's
237    ///   `application/json` requestBody schema is compiled and required
238    ///   parameters indexed. A non-object `spec`, a malformed `paths`, or an
239    ///   uncompilable schema fails here at config load.
240    /// - `rejected_code` (integer 400–599, default `400`): response status for
241    ///   rejected requests.
242    /// - `rejected_msg` (string): fixed message returned instead of the
243    ///   per-violation detail.
244    ///
245    /// ```yaml
246    /// type: oas-validator
247    /// config:
248    ///   rejected_code: 400
249    ///   spec:
250    ///     openapi: 3.0.0
251    ///     info: { title: demo, version: "1.0" }
252    ///     paths:
253    ///       /users/{id}:
254    ///         get:
255    ///           parameters:
256    ///             - { name: verbose, in: query, required: true, schema: { type: string } }
257    ///         post:
258    ///           requestBody:
259    ///             required: true
260    ///             content:
261    ///               application/json:
262    ///                 schema:
263    ///                   type: object
264    ///                   required: [name]
265    ///                   properties: { name: { type: string } }
266    /// ```
267    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                // Rebuild segments per op (Segment isn't Clone; cheap).
308                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    /// Finds the best-matching operation for a method + path: the matching
345    /// operation with the most literal (non-templated) segments wins, so an
346    /// exact path is preferred over a templated one.
347    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    /// Writes the rejection onto the response and returns the error that routes
355    /// the context through the node's `error` port.
356    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        // Find the matching operation; no match → pass through (not our job to 404).
390        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        // Required query parameters must be present.
401        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        // Required header parameters must be present (headers are lowercased).
408        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        // Request body validation.
415        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); // absent content-type: treat as JSON
429                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        // GET /users/{id} requires ?verbose and x-trace header, path templating matches.
526        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        // no verbose query param
544        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        // missing required "name" (via $ref schema)
583        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"); // empty body, requestBody.required
592        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        // No operation for this path → pass through untouched.
600        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        // wrong method on a known path also passes through
606        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        // missing spec
615        assert!(OasValidatorPlugin::from_config(&HashMap::new()).is_err());
616
617        // spec not an object
618        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        // spec without paths
623        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        // uncompilable body schema fails fast
631        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        // rejected_code out of range
653        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}