Skip to main content

roas_http_validator/
validator.rs

1//! The validator itself: a description, prepared once, judging many
2//! requests.
3
4use std::collections::BTreeMap;
5
6use roas::v3_2::operation::Operation;
7use roas::v3_2::parameter::Parameter;
8use roas::v3_2::path_item::PathItem;
9use roas::v3_2::spec::Spec;
10
11use crate::body;
12use crate::parameter;
13use crate::paths;
14use crate::report::{ErrorKind, Location, RoutingError, ValidationError, ValidationReport};
15use crate::request::{RequestView, decode_path_segment};
16use crate::router::Router;
17
18/// What to check, and where the description's paths start.
19///
20/// ```
21/// use roas_http_validator::Options;
22///
23/// let options = Options::new().base_path("/api/v1").reject_undescribed_query_parameters();
24/// ```
25#[derive(Clone, Debug, Default, PartialEq, Eq)]
26#[non_exhaustive]
27pub struct Options {
28    base_path: Option<String>,
29    skip_body: bool,
30    reject_undescribed_query_parameters: bool,
31}
32
33impl Options {
34    /// Everything checked, base path taken from the Server Objects.
35    #[must_use]
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// The prefix a request path carries before the description's own
41    /// paths begin, overriding whatever `servers` implies.
42    #[must_use]
43    pub fn base_path(mut self, base_path: impl Into<String>) -> Self {
44        self.base_path = Some(base_path.into());
45        self
46    }
47
48    /// Leave the body alone. Useful in a middleware that would rather
49    /// not buffer one, and in a client-side check of a request that has
50    /// not been serialized yet.
51    #[must_use]
52    pub fn skip_body(mut self) -> Self {
53        self.skip_body = true;
54        self
55    }
56
57    /// Report a query parameter the operation does not describe.
58    ///
59    /// Off by default: OpenAPI does not forbid undescribed query
60    /// parameters, and plenty of real clients send tracking parameters
61    /// that no description mentions. On, it catches the typo in
62    /// `?limti=10` that would otherwise silently do nothing.
63    #[must_use]
64    pub fn reject_undescribed_query_parameters(mut self) -> Self {
65        self.reject_undescribed_query_parameters = true;
66        self
67    }
68}
69
70/// One OpenAPI description, ready to judge requests against.
71///
72/// Building one walks the description's paths once; validating is then
73/// a match and a handful of schema checks, so a server builds this at
74/// startup and keeps it.
75///
76/// ```
77/// use roas_http_validator::{RequestView, Validator};
78///
79/// # fn main() -> Result<(), Box<dyn std::error::Error>> {
80/// let spec = serde_json::from_str(r#"{
81///   "openapi": "3.2.0",
82///   "info": { "title": "Pets", "version": "1.0.0" },
83///   "paths": {
84///     "/pets/{petId}": {
85///       "get": {
86///         "operationId": "getPet",
87///         "parameters": [
88///           { "name": "petId", "in": "path", "required": true,
89///             "schema": { "type": "integer" } }
90///         ]
91///       }
92///     }
93///   }
94/// }"#)?;
95///
96/// let validator = Validator::new(spec);
97/// assert!(validator.validate(&RequestView::new("GET", "/pets/7"))?.is_valid());
98/// assert!(!validator.validate(&RequestView::new("GET", "/pets/rex"))?.is_valid());
99/// # Ok(()) }
100/// ```
101#[derive(Clone, Debug)]
102pub struct Validator {
103    spec: Spec,
104    /// Every Path Item Object with its `$ref` followed and merged,
105    /// resolved once here rather than on every request.
106    path_items: BTreeMap<String, PathItem>,
107    router: Router,
108    options: Options,
109}
110
111impl Validator {
112    /// Prepare a v3.2 description with the default [`Options`].
113    #[must_use]
114    pub fn new(spec: Spec) -> Self {
115        Self::with_options(spec, Options::new())
116    }
117
118    /// Prepare a v3.2 description.
119    #[must_use]
120    pub fn with_options(spec: Spec, options: Options) -> Self {
121        let path_items = paths::resolve(&spec);
122        let router = Router::new(
123            &path_items,
124            spec.servers.as_deref(),
125            options.base_path.as_deref(),
126        );
127        Self {
128            spec,
129            path_items,
130            router,
131            options,
132        }
133    }
134
135    /// The description being validated against.
136    #[must_use]
137    pub fn spec(&self) -> &Spec {
138        &self.spec
139    }
140
141    /// Judge one request.
142    ///
143    /// # Errors
144    ///
145    /// [`RoutingError`] when the request cannot be judged at all, which
146    /// is a different answer from "the request is invalid" and usually a
147    /// different response code:
148    ///
149    /// - [`RoutingError::PathNotFound`] — no template matches the path.
150    /// - [`RoutingError::MethodNotAllowed`] — a template matches and
151    ///   describes other methods, but not this one.
152    /// - [`RoutingError::Unresolved`] — a template matches but its Path
153    ///   Item Object could not be read, so neither of the above can be
154    ///   said honestly.
155    pub fn validate(&self, request: &RequestView<'_>) -> Result<ValidationReport, RoutingError> {
156        let matched = self
157            .router
158            .route(&request.path, &request.method)
159            .ok_or_else(|| RoutingError::PathNotFound {
160                path: request.path.clone().into_owned(),
161            })?;
162        let template = matched.template.to_owned();
163        let path_parameters = matched.parameters;
164
165        let path_item = self.path_item(&template);
166        // A `$ref` chain that could not be followed leaves part of this
167        // Path Item Object unread.
168        let unresolved = path_item.and_then(|item| item.reference.clone());
169        let found = path_item.and_then(|item| self.operation(item, request));
170
171        let Some((method, operation)) = found else {
172            // With half the Path Item Object unread, "no such method"
173            // is not something that can be said: the half that did not
174            // arrive may well have described it.
175            if let Some(reference) = unresolved {
176                return Err(RoutingError::Unresolved {
177                    template,
178                    reference,
179                });
180            }
181            return Err(RoutingError::MethodNotAllowed {
182                template,
183                // The token the request actually carried, not a
184                // normalization of it: `get` was refused *because* it is
185                // not `GET`, and saying "no GET here" beside an `Allow`
186                // naming `GET` would be nonsense.
187                method: request.method.clone().into_owned(),
188                allowed: path_item.map(allowed_methods).unwrap_or_default(),
189            });
190        };
191
192        let mut errors = Vec::new();
193        // An operation was found, so the request can still be judged —
194        // but whatever the unread half held went unapplied, and saying
195        // so is the difference between "valid" and "not checked".
196        if let Some(reference) = unresolved {
197            errors.push(ValidationError {
198                location: Location::Description,
199                name: String::new(),
200                pointer: String::new(),
201                kind: ErrorKind::UnresolvedReference(reference),
202            });
203        }
204        let parameters = self.parameters(path_item, operation, &mut errors);
205        // Decoded once for the whole operation rather than per parameter.
206        let extracted = parameter::Extracted::new(request, &path_parameters);
207
208        for parameter in &parameters {
209            parameter::validate(parameter, request, &extracted, &self.spec, &mut errors);
210        }
211
212        if self.options.reject_undescribed_query_parameters {
213            check_for_strays(&extracted, &parameters, &self.spec, &mut errors);
214        }
215
216        if !self.options.skip_body
217            && let Some(request_body) = &operation.request_body
218        {
219            match request_body.get_item(&self.spec) {
220                Ok(request_body) => {
221                    body::validate(request_body, request, &self.spec, &mut errors);
222                }
223                Err(error) => errors.push(ValidationError {
224                    location: Location::Body,
225                    name: String::new(),
226                    pointer: String::new(),
227                    kind: ErrorKind::UnresolvedReference(error.to_string()),
228                }),
229            }
230        }
231
232        Ok(ValidationReport {
233            template,
234            method,
235            operation_id: operation.operation_id.clone(),
236            // Decoded here and only here: validation splits before it
237            // decodes, but a report is for a reader.
238            path_parameters: path_parameters
239                .iter()
240                .map(|(name, raw)| (name.clone(), decode_path_segment(raw)))
241                .collect(),
242            errors,
243        })
244    }
245
246    /// The operation a request's method names, and the key the Path
247    /// Item Object files it under.
248    ///
249    /// See [`crate::method`] for why `get` does not find `get`.
250    fn operation<'i>(
251        &self,
252        path_item: &'i PathItem,
253        request: &RequestView<'_>,
254    ) -> Option<(String, &'i Operation)> {
255        // Each map is searched with its own key and never the other's.
256        if let Some(key) = crate::method::standard(&request.method)
257            && let Some((key, operation)) = path_item
258                .operations
259                .as_ref()
260                .and_then(|operations| operations.get_key_value(&key))
261        {
262            return Some((crate::method::from_standard_key(key), operation));
263        }
264        path_item
265            .additional_operations
266            .as_ref()?
267            .get_key_value(request.method.as_ref())
268            // Already a method token: `additionalOperations` is keyed by
269            // the method itself.
270            .map(|(key, operation)| (key.clone(), operation))
271    }
272
273    /// The Path Item Object for a template, already resolved.
274    fn path_item(&self, template: &str) -> Option<&PathItem> {
275        self.path_items.get(template)
276    }
277
278    /// The parameters that apply to one operation: the Path Item
279    /// Object's, overridden by the Operation Object's where both name
280    /// the same `name` and `in`.
281    fn parameters(
282        &self,
283        path_item: Option<&PathItem>,
284        operation: &Operation,
285        errors: &mut Vec<ValidationError>,
286    ) -> Vec<Parameter> {
287        let mut merged: BTreeMap<(String, Location), Parameter> = BTreeMap::new();
288        let inherited = path_item.and_then(|item| item.parameters.as_deref());
289        let declared = operation.parameters.as_deref();
290
291        for source in [inherited, declared].into_iter().flatten() {
292            for parameter in source {
293                match parameter.get_item(&self.spec) {
294                    Ok(parameter) => {
295                        merged.insert(identity(parameter), parameter.clone());
296                    }
297                    // The parameter cannot be read, so it cannot be
298                    // checked — which is the description's fault, not
299                    // the request's, and says so.
300                    Err(error) => errors.push(ValidationError {
301                        location: Location::Description,
302                        name: String::new(),
303                        pointer: String::new(),
304                        kind: ErrorKind::UnresolvedReference(error.to_string()),
305                    }),
306                }
307            }
308        }
309        merged.into_values().collect()
310    }
311}
312
313/// Report query parameters the operation says nothing about.
314fn check_for_strays(
315    extracted: &parameter::Extracted<'_>,
316    parameters: &[Parameter],
317    spec: &Spec,
318    errors: &mut Vec<ValidationError>,
319) {
320    // `in: querystring` describes the query string whole, so there is no
321    // such thing as a stray parameter alongside one.
322    if parameters
323        .iter()
324        .any(|parameter| matches!(parameter, Parameter::Querystring(_)))
325    {
326        return;
327    }
328    for (name, _) in &extracted.query {
329        if !parameters
330            .iter()
331            .any(|parameter| parameter::accounts_for(parameter, name, spec))
332        {
333            errors.push(ValidationError {
334                location: Location::Query,
335                name: name.clone(),
336                pointer: String::new(),
337                kind: ErrorKind::Undescribed,
338            });
339        }
340    }
341}
342
343/// Every method a Path Item Object describes, as method tokens — which
344/// is what an `Allow` header wants, and what `operations`' lowercase
345/// keys are not.
346fn allowed_methods(path_item: &PathItem) -> Vec<String> {
347    let standard = path_item
348        .operations
349        .iter()
350        .flatten()
351        .map(|(key, _)| crate::method::from_standard_key(key));
352    let additional = path_item
353        .additional_operations
354        .iter()
355        .flatten()
356        .map(|(key, _)| key.clone());
357    standard.chain(additional).collect()
358}
359
360/// What makes a parameter unique: its name and its location.
361fn identity(parameter: &Parameter) -> (String, Location) {
362    match parameter {
363        Parameter::Path(path) => (path.name.clone(), Location::Path),
364        Parameter::Query(query) => (query.name.clone(), Location::Query),
365        Parameter::Querystring(querystring) => (querystring.name.clone(), Location::Querystring),
366        Parameter::Header(header) => (header.name.clone(), Location::Header),
367        Parameter::Cookie(cookie) => (cookie.name.clone(), Location::Cookie),
368    }
369}