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 ¶meters {
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, ¶meters, &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: ¶meter::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}