Skip to main content

specmock_runtime/http/
openapi.rs

1//! Minimal OpenAPI 3.0/3.1 runtime parser and request/response engine.
2
3use std::{collections::HashMap, path::Path};
4
5use http::{HeaderMap, Method};
6use serde_json::{Map, Value};
7use specmock_core::{
8    ValidationIssue, faker::generate_json_value, ref_resolver::RefResolver,
9    validate::validate_instance,
10};
11
12use super::router::{PathRouter, RouteMatch};
13use crate::RuntimeError;
14
15/// Loaded OpenAPI runtime.
16#[derive(Debug, Clone)]
17pub struct OpenApiRuntime {
18    operations: Vec<OperationSpec>,
19    router: PathRouter,
20}
21
22/// Resolved operation and path parameters.
23#[derive(Debug)]
24pub struct MatchedOperation<'a> {
25    /// Operation definition.
26    pub operation: &'a OperationSpec,
27    /// Extracted path parameters.
28    pub path_params: HashMap<String, String>,
29}
30
31/// Operation model.
32#[derive(Debug, Clone)]
33pub struct OperationSpec {
34    /// HTTP method.
35    pub method: Method,
36    /// Path template.
37    pub path_template: String,
38    /// Operation id (if present).
39    pub operation_id: Option<String>,
40    /// Parameters.
41    pub parameters: Vec<ParameterSpec>,
42    /// Request body schema.
43    pub request_body_schema: Option<Value>,
44    /// Whether request body is required.
45    pub request_body_required: bool,
46    /// Declared responses.
47    pub responses: Vec<ResponseSpec>,
48    /// OpenAPI callbacks (outbound requests fired after response).
49    pub callbacks: Vec<CallbackSpec>,
50}
51
52/// Callback specification parsed from OpenAPI `callbacks`.
53#[derive(Debug, Clone)]
54pub struct CallbackSpec {
55    /// Runtime expression for the callback URL, e.g. `"{$request.body#/callbackUrl}/notify"`.
56    pub callback_url_expression: String,
57    /// HTTP method for the outbound request.
58    pub method: Method,
59    /// Optional JSON schema for the callback request body.
60    pub request_body_schema: Option<Value>,
61}
62
63/// Parameter location.
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum ParameterIn {
66    /// Path parameter.
67    Path,
68    /// Query parameter.
69    Query,
70    /// Header parameter.
71    Header,
72}
73
74/// Parameter spec.
75#[derive(Debug, Clone)]
76pub struct ParameterSpec {
77    /// Parameter name.
78    pub name: String,
79    /// Location.
80    pub location: ParameterIn,
81    /// Required flag.
82    pub required: bool,
83    /// Schema.
84    pub schema: Value,
85}
86
87/// Response spec.
88#[derive(Debug, Clone)]
89pub struct ResponseSpec {
90    /// Status selector (`200`, `default`).
91    pub status: String,
92    /// JSON schema.
93    pub schema: Option<Value>,
94    /// Explicit example payload.
95    pub example: Option<Value>,
96    /// Named examples keyed by example name.
97    pub named_examples: HashMap<String, Value>,
98}
99
100/// Generated response.
101#[derive(Debug, Clone)]
102pub struct MockHttpResponse {
103    /// HTTP status code.
104    pub status: u16,
105    /// Optional JSON body.
106    pub body: Option<Value>,
107}
108
109impl OpenApiRuntime {
110    /// Load OpenAPI document from path.
111    ///
112    /// The file is loaded, all `$ref` nodes are resolved via [`RefResolver`],
113    /// and the fully-inlined document is then parsed into operation specs.
114    pub fn from_path(path: &Path) -> Result<Self, RuntimeError> {
115        let base_dir = path.parent().unwrap_or_else(|| Path::new(".")).to_path_buf();
116        let mut resolver = RefResolver::new(base_dir);
117        let resolved =
118            resolver.resolve(path).map_err(|error| RuntimeError::Parse(error.to_string()))?;
119        Self::from_resolved(resolved.root)
120    }
121
122    /// Build from an already-resolved OpenAPI document value.
123    ///
124    /// The caller must ensure that all `$ref` nodes have been inlined before
125    /// invoking this constructor.
126    pub fn from_resolved(root: Value) -> Result<Self, RuntimeError> {
127        let version = root
128            .get("openapi")
129            .and_then(Value::as_str)
130            .ok_or_else(|| RuntimeError::Parse("openapi version field missing".to_owned()))?;
131        if !(version.starts_with("3.0") || version.starts_with("3.1")) {
132            return Err(RuntimeError::Parse(format!(
133                "unsupported openapi version: {version}, expected 3.0.x or 3.1.x"
134            )));
135        }
136
137        let paths = root
138            .get("paths")
139            .and_then(Value::as_object)
140            .ok_or_else(|| RuntimeError::Parse("openapi paths object missing".to_owned()))?;
141
142        let mut operations = Vec::new();
143        for (path_template, path_item) in paths {
144            let Some(path_object) = path_item.as_object() else {
145                continue;
146            };
147            let inherited_parameters = parse_parameters(path_object.get("parameters"), version)?;
148
149            for method_name in ["get", "post", "put", "patch", "delete", "head", "options", "trace"]
150            {
151                let Some(operation_value) = path_object.get(method_name) else {
152                    continue;
153                };
154                let Some(operation_object) = operation_value.as_object() else {
155                    continue;
156                };
157
158                let mut parameters = inherited_parameters.clone();
159                let operation_params =
160                    parse_parameters(operation_object.get("parameters"), version)?;
161                for parameter in operation_params {
162                    parameters.retain(|existing| {
163                        existing.location != parameter.location || existing.name != parameter.name
164                    });
165                    parameters.push(parameter);
166                }
167
168                let request_body = parse_request_body(operation_object, version)?;
169                let responses = parse_responses(operation_object, version)?;
170                let callbacks = parse_callbacks(operation_object, version)?;
171
172                let method_name_upper = method_name.to_ascii_uppercase();
173                let method = Method::from_bytes(method_name_upper.as_bytes())
174                    .map_err(|error| RuntimeError::Parse(error.to_string()))?;
175                operations.push(OperationSpec {
176                    method,
177                    path_template: path_template.clone(),
178                    operation_id: operation_object
179                        .get("operationId")
180                        .and_then(Value::as_str)
181                        .map(ToOwned::to_owned),
182                    parameters,
183                    request_body_schema: request_body.0,
184                    request_body_required: request_body.1,
185                    responses,
186                    callbacks,
187                });
188            }
189        }
190
191        let router = PathRouter::build(&operations);
192        Ok(Self { operations, router })
193    }
194
195    /// Match operation by method and path.
196    pub fn match_operation<'a>(
197        &'a self,
198        method: &Method,
199        path: &str,
200    ) -> Option<MatchedOperation<'a>> {
201        let RouteMatch { operation_index, path_params } = self.router.match_route(method, path)?;
202        Some(MatchedOperation { operation: &self.operations[operation_index], path_params })
203    }
204}
205
206impl OperationSpec {
207    /// Validate request parts.
208    pub fn validate_request(
209        &self,
210        path_params: &HashMap<String, String>,
211        query_params: &HashMap<String, Vec<String>>,
212        headers: &HeaderMap,
213        body_json: Option<&Value>,
214    ) -> Vec<ValidationIssue> {
215        let mut issues = Vec::new();
216
217        for parameter in &self.parameters {
218            match parameter.location {
219                ParameterIn::Path => {
220                    let raw = path_params.get(&parameter.name).cloned();
221                    if parameter.required && raw.is_none() {
222                        issues.push(ValidationIssue {
223                            instance_pointer: format!("/{}", parameter.name),
224                            schema_pointer: "#/parameters".to_owned(),
225                            keyword: "required".to_owned(),
226                            message: format!("missing required parameter '{}'", parameter.name),
227                        });
228                        continue;
229                    }
230                    if let Some(raw_value) = raw {
231                        let parsed_value = parse_parameter_value(&raw_value, &parameter.schema);
232                        match validate_instance(&parameter.schema, &parsed_value) {
233                            Ok(mut parameter_issues) => issues.append(&mut parameter_issues),
234                            Err(error) => issues.push(ValidationIssue {
235                                instance_pointer: format!("/{}", parameter.name),
236                                schema_pointer: "#/parameters".to_owned(),
237                                keyword: "schema".to_owned(),
238                                message: error.to_string(),
239                            }),
240                        }
241                    }
242                }
243                ParameterIn::Query => {
244                    let values = query_params.get(&parameter.name);
245                    let is_missing = values.is_none_or(Vec::is_empty);
246
247                    if parameter.required && is_missing {
248                        issues.push(ValidationIssue {
249                            instance_pointer: format!("/{}", parameter.name),
250                            schema_pointer: "#/parameters".to_owned(),
251                            keyword: "required".to_owned(),
252                            message: format!("missing required parameter '{}'", parameter.name),
253                        });
254                        continue;
255                    }
256
257                    if let Some(vals) = values &&
258                        !vals.is_empty()
259                    {
260                        let is_array = schema_type_is_array(&parameter.schema);
261                        if is_array {
262                            // Collect all values into a JSON array, parsing each
263                            // element against the items sub-schema.
264                            let items_schema = parameter
265                                .schema
266                                .get("items")
267                                .cloned()
268                                .unwrap_or_else(|| Value::Object(serde_json::Map::new()));
269                            let elements: Vec<Value> = vals
270                                .iter()
271                                .map(|v| parse_parameter_value(v, &items_schema))
272                                .collect();
273                            let parsed_value = Value::Array(elements);
274                            match validate_instance(&parameter.schema, &parsed_value) {
275                                Ok(mut parameter_issues) => {
276                                    issues.append(&mut parameter_issues);
277                                }
278                                Err(error) => issues.push(ValidationIssue {
279                                    instance_pointer: format!("/{}", parameter.name),
280                                    schema_pointer: "#/parameters".to_owned(),
281                                    keyword: "schema".to_owned(),
282                                    message: error.to_string(),
283                                }),
284                            }
285                        } else {
286                            // Non-array: use first value.
287                            let raw_value = &vals[0];
288                            let parsed_value = parse_parameter_value(raw_value, &parameter.schema);
289                            match validate_instance(&parameter.schema, &parsed_value) {
290                                Ok(mut parameter_issues) => {
291                                    issues.append(&mut parameter_issues);
292                                }
293                                Err(error) => issues.push(ValidationIssue {
294                                    instance_pointer: format!("/{}", parameter.name),
295                                    schema_pointer: "#/parameters".to_owned(),
296                                    keyword: "schema".to_owned(),
297                                    message: error.to_string(),
298                                }),
299                            }
300                        }
301                    }
302                }
303                ParameterIn::Header => {
304                    let raw = headers
305                        .get(&parameter.name)
306                        .and_then(|value| value.to_str().ok())
307                        .map(ToOwned::to_owned);
308                    if parameter.required && raw.is_none() {
309                        issues.push(ValidationIssue {
310                            instance_pointer: format!("/{}", parameter.name),
311                            schema_pointer: "#/parameters".to_owned(),
312                            keyword: "required".to_owned(),
313                            message: format!("missing required parameter '{}'", parameter.name),
314                        });
315                        continue;
316                    }
317                    if let Some(raw_value) = raw {
318                        let parsed_value = parse_parameter_value(&raw_value, &parameter.schema);
319                        match validate_instance(&parameter.schema, &parsed_value) {
320                            Ok(mut parameter_issues) => issues.append(&mut parameter_issues),
321                            Err(error) => issues.push(ValidationIssue {
322                                instance_pointer: format!("/{}", parameter.name),
323                                schema_pointer: "#/parameters".to_owned(),
324                                keyword: "schema".to_owned(),
325                                message: error.to_string(),
326                            }),
327                        }
328                    }
329                }
330            }
331        }
332
333        if self.request_body_required && body_json.is_none() {
334            issues.push(ValidationIssue {
335                instance_pointer: "/body".to_owned(),
336                schema_pointer: "#/requestBody".to_owned(),
337                keyword: "required".to_owned(),
338                message: "missing required request body".to_owned(),
339            });
340        }
341
342        if let (Some(schema), Some(body)) = (&self.request_body_schema, body_json) {
343            match validate_instance(schema, body) {
344                Ok(mut body_issues) => issues.append(&mut body_issues),
345                Err(error) => issues.push(ValidationIssue {
346                    instance_pointer: "/body".to_owned(),
347                    schema_pointer: "#/requestBody".to_owned(),
348                    keyword: "schema".to_owned(),
349                    message: error.to_string(),
350                }),
351            }
352        }
353
354        issues
355    }
356
357    /// Build a mocked response from OpenAPI response entries.
358    ///
359    /// The caller supplies [`PreferDirectives`] parsed from the request so the
360    /// engine can honour `Prefer: code=…`, `Prefer: example=…`, and
361    /// `Prefer: dynamic=true`.
362    pub fn mock_response(
363        &self,
364        seed: u64,
365        prefer: &super::negotiate::PreferDirectives,
366    ) -> Result<MockHttpResponse, RuntimeError> {
367        let selected = super::negotiate::select_response(&self.responses, prefer)
368            .ok_or_else(|| RuntimeError::Parse("operation has no responses".to_owned()))?;
369
370        // Named example override.
371        if let Some(name) = &prefer.example &&
372            let Some(value) = selected.named_examples.get(name)
373        {
374            return Ok(MockHttpResponse {
375                status: parse_status_code(&selected.status),
376                body: Some(value.clone()),
377            });
378        }
379
380        // Dynamic mode: always use faker even when a static example exists.
381        if prefer.dynamic &&
382            let Some(schema) = &selected.schema
383        {
384            let value = generate_json_value(schema, seed)
385                .map_err(|error| RuntimeError::Parse(error.to_string()))?;
386            return Ok(MockHttpResponse {
387                status: parse_status_code(&selected.status),
388                body: Some(value),
389            });
390        }
391
392        if let Some(example) = &selected.example {
393            return Ok(MockHttpResponse {
394                status: parse_status_code(&selected.status),
395                body: Some(example.clone()),
396            });
397        }
398
399        if let Some(schema) = &selected.schema {
400            let value = generate_json_value(schema, seed)
401                .map_err(|error| RuntimeError::Parse(error.to_string()))?;
402            return Ok(MockHttpResponse {
403                status: parse_status_code(&selected.status),
404                body: Some(value),
405            });
406        }
407
408        Ok(MockHttpResponse { status: parse_status_code(&selected.status), body: None })
409    }
410
411    /// Retrieve response schema by concrete status code with default fallback.
412    pub fn response_schema_for_status(&self, status: u16) -> Option<&Value> {
413        let status_text = status.to_string();
414        if let Some(exact) = self
415            .responses
416            .iter()
417            .find(|response| response.status == status_text)
418            .and_then(|response| response.schema.as_ref())
419        {
420            return Some(exact);
421        }
422        self.responses
423            .iter()
424            .find(|response| response.status == "default")
425            .and_then(|response| response.schema.as_ref())
426    }
427}
428
429fn parse_parameters(
430    parameters_node: Option<&Value>,
431    openapi_version: &str,
432) -> Result<Vec<ParameterSpec>, RuntimeError> {
433    let Some(parameters_array) = parameters_node.and_then(Value::as_array) else {
434        return Ok(Vec::new());
435    };
436
437    let mut parameters = Vec::new();
438    for parameter_node in parameters_array {
439        let Some(parameter_object) = parameter_node.as_object() else {
440            continue;
441        };
442        let Some(name) = parameter_object.get("name").and_then(Value::as_str) else {
443            continue;
444        };
445
446        let location = match parameter_object.get("in").and_then(Value::as_str) {
447            Some("path") => ParameterIn::Path,
448            Some("query") => ParameterIn::Query,
449            Some("header") => ParameterIn::Header,
450            _ => continue,
451        };
452
453        let required = parameter_object.get("required").and_then(Value::as_bool).unwrap_or(false) ||
454            location == ParameterIn::Path;
455
456        let schema = parameter_object
457            .get("schema")
458            .and_then(Value::as_object)
459            .cloned()
460            .unwrap_or_else(Map::new);
461        let normalized =
462            normalize_schema(Value::Object(schema), openapi_version.starts_with("3.0"));
463
464        parameters.push(ParameterSpec {
465            name: name.to_owned(),
466            location,
467            required,
468            schema: normalized,
469        });
470    }
471    Ok(parameters)
472}
473
474fn parse_request_body(
475    operation: &Map<String, Value>,
476    openapi_version: &str,
477) -> Result<(Option<Value>, bool), RuntimeError> {
478    let Some(request_body) = operation.get("requestBody").and_then(Value::as_object) else {
479        return Ok((None, false));
480    };
481
482    let required = request_body.get("required").and_then(Value::as_bool).unwrap_or(false);
483
484    let Some(content) = request_body.get("content").and_then(Value::as_object) else {
485        return Ok((None, required));
486    };
487    let Some(media_type) = content
488        .get("application/json")
489        .and_then(Value::as_object)
490        .cloned()
491        .or_else(|| content.values().find_map(Value::as_object).cloned())
492    else {
493        return Ok((None, required));
494    };
495
496    let Some(schema) = media_type.get("schema").and_then(Value::as_object) else {
497        return Ok((None, required));
498    };
499
500    Ok((
501        Some(normalize_schema(Value::Object(schema.clone()), openapi_version.starts_with("3.0"))),
502        required,
503    ))
504}
505
506fn parse_responses(
507    operation: &Map<String, Value>,
508    openapi_version: &str,
509) -> Result<Vec<ResponseSpec>, RuntimeError> {
510    let Some(responses_node) = operation.get("responses").and_then(Value::as_object) else {
511        return Ok(Vec::new());
512    };
513
514    let mut responses = Vec::new();
515    for (status, response_node) in responses_node {
516        let Some(response_object) = response_node.as_object() else {
517            continue;
518        };
519
520        let (schema, example, named_examples) = if let Some(content) =
521            response_object.get("content").and_then(Value::as_object) &&
522            let Some(media_type) = content
523                .get("application/json")
524                .and_then(Value::as_object)
525                .cloned()
526                .or_else(|| content.values().find_map(Value::as_object).cloned())
527        {
528            let schema = media_type.get("schema").and_then(Value::as_object).map(|schema_object| {
529                normalize_schema(
530                    Value::Object(schema_object.clone()),
531                    openapi_version.starts_with("3.0"),
532                )
533            });
534            // Collect named examples map.
535            let mut named_examples = HashMap::new();
536            if let Some(examples_obj) = media_type.get("examples").and_then(Value::as_object) {
537                for (example_name, example_entry) in examples_obj {
538                    if let Some(val) = example_entry.get("value") {
539                        named_examples.insert(example_name.clone(), val.clone());
540                    }
541                }
542            }
543
544            let example = media_type
545                .get("example")
546                .cloned()
547                .or_else(|| named_examples.values().next().cloned());
548            (schema, example, named_examples)
549        } else {
550            (None, None, HashMap::new())
551        };
552
553        responses.push(ResponseSpec { status: status.clone(), schema, example, named_examples });
554    }
555
556    Ok(responses)
557}
558
559fn parse_callbacks(
560    operation: &Map<String, Value>,
561    openapi_version: &str,
562) -> Result<Vec<CallbackSpec>, RuntimeError> {
563    let Some(callbacks_node) = operation.get("callbacks").and_then(Value::as_object) else {
564        return Ok(Vec::new());
565    };
566
567    let mut callbacks = Vec::new();
568    // Each entry: callbackName -> { expressionUrl -> pathItemObject }
569    for (_callback_name, callback_value) in callbacks_node {
570        let Some(callback_object) = callback_value.as_object() else {
571            continue;
572        };
573        for (url_expression, path_item_value) in callback_object {
574            let Some(path_item) = path_item_value.as_object() else {
575                continue;
576            };
577            for method_name in ["get", "post", "put", "patch", "delete", "head", "options", "trace"]
578            {
579                let Some(cb_operation) = path_item.get(method_name).and_then(Value::as_object)
580                else {
581                    continue;
582                };
583
584                let method_upper = method_name.to_ascii_uppercase();
585                let method = Method::from_bytes(method_upper.as_bytes())
586                    .map_err(|error| RuntimeError::Parse(error.to_string()))?;
587
588                let schema = cb_operation
589                    .get("requestBody")
590                    .and_then(|rb| rb.get("content"))
591                    .and_then(Value::as_object)
592                    .and_then(|content| {
593                        content
594                            .get("application/json")
595                            .and_then(Value::as_object)
596                            .cloned()
597                            .or_else(|| content.values().find_map(Value::as_object).cloned())
598                    })
599                    .and_then(|media| media.get("schema").and_then(Value::as_object).cloned())
600                    .map(|s| {
601                        normalize_schema(Value::Object(s), openapi_version.starts_with("3.0"))
602                    });
603
604                callbacks.push(CallbackSpec {
605                    callback_url_expression: url_expression.clone(),
606                    method,
607                    request_body_schema: schema,
608                });
609            }
610        }
611    }
612
613    Ok(callbacks)
614}
615
616/// Resolve a callback URL runtime expression against the original request body.
617///
618/// Supports the `{$request.body#/jsonPointer}` syntax defined in OpenAPI 3.x.
619/// Literal text outside `{…}` is preserved as-is.
620pub fn resolve_callback_url(expression: &str, request_body: Option<&Value>) -> Option<String> {
621    let mut result = String::with_capacity(expression.len());
622    let mut remaining = expression;
623
624    while let Some(open) = remaining.find('{') {
625        result.push_str(&remaining[..open]);
626        let after_open = &remaining[open + 1..];
627        let close = after_open.find('}')?;
628        let token = &after_open[..close];
629        remaining = &after_open[close + 1..];
630
631        if let Some(pointer_path) = token.strip_prefix("$request.body#") {
632            let body = request_body?;
633            let value = json_pointer(body, pointer_path)?;
634            let text = value.as_str().map_or_else(|| value.to_string(), ToOwned::to_owned);
635            result.push_str(&text);
636        } else {
637            // Unsupported expression token – bail out.
638            return None;
639        }
640    }
641    result.push_str(remaining);
642
643    if result.is_empty() { None } else { Some(result) }
644}
645
646/// Minimal JSON Pointer (RFC 6901) resolver.
647fn json_pointer<'a>(value: &'a Value, pointer: &str) -> Option<&'a Value> {
648    if pointer.is_empty() || pointer == "/" {
649        return Some(value);
650    }
651    let path = pointer.strip_prefix('/')?;
652    let mut current = value;
653    for segment in path.split('/') {
654        let decoded = segment.replace("~1", "/").replace("~0", "~");
655        match current {
656            Value::Object(map) => current = map.get(&decoded)?,
657            Value::Array(arr) => {
658                let idx: usize = decoded.parse().ok()?;
659                current = arr.get(idx)?;
660            }
661            _ => return None,
662        }
663    }
664    Some(current)
665}
666
667fn normalize_schema(mut schema: Value, use_nullable_transform: bool) -> Value {
668    if let Some(object) = schema.as_object_mut() {
669        for nested_key in ["properties", "$defs", "definitions"] {
670            if let Some(properties) = object.get_mut(nested_key).and_then(Value::as_object_mut) {
671                for value in properties.values_mut() {
672                    let normalized = normalize_schema(value.clone(), use_nullable_transform);
673                    *value = normalized;
674                }
675            }
676        }
677
678        for nested_key in ["items", "additionalProperties", "not"] {
679            if let Some(value) = object.get_mut(nested_key) {
680                let normalized = normalize_schema(value.clone(), use_nullable_transform);
681                *value = normalized;
682            }
683        }
684
685        for nested_key in ["allOf", "anyOf", "oneOf"] {
686            if let Some(items) = object.get_mut(nested_key).and_then(Value::as_array_mut) {
687                for item in items {
688                    let normalized = normalize_schema(item.clone(), use_nullable_transform);
689                    *item = normalized;
690                }
691            }
692        }
693
694        if use_nullable_transform &&
695            object.get("nullable").and_then(Value::as_bool).unwrap_or(false) &&
696            let Some(type_value) = object.get_mut("type")
697        {
698            match type_value {
699                Value::String(original_type) => {
700                    *type_value = Value::Array(vec![
701                        Value::String(original_type.clone()),
702                        Value::String("null".to_owned()),
703                    ]);
704                }
705                Value::Array(types) => {
706                    let has_null = types.iter().any(|item| item == "null");
707                    if !has_null {
708                        types.push(Value::String("null".to_owned()));
709                    }
710                }
711                _value => {}
712            }
713            object.remove("nullable");
714        }
715    }
716    schema
717}
718
719/// Returns `true` when the schema's `type` field is (or includes) `"array"`.
720fn schema_type_is_array(schema: &Value) -> bool {
721    match schema.get("type") {
722        Some(Value::String(t)) => t == "array",
723        Some(Value::Array(types)) => types.iter().any(|t| t.as_str() == Some("array")),
724        _ => false,
725    }
726}
727
728fn parse_parameter_value(raw: &str, schema: &Value) -> Value {
729    let inferred_type = schema
730        .get("type")
731        .and_then(|value| {
732            value.as_str().map(ToOwned::to_owned).or_else(|| {
733                value.as_array().and_then(|types| {
734                    types.iter().find_map(|entry| entry.as_str().map(ToOwned::to_owned))
735                })
736            })
737        })
738        .unwrap_or_else(|| "string".to_owned());
739
740    match inferred_type.as_str() {
741        "integer" => {
742            raw.parse::<i64>().map_or_else(|_error| Value::String(raw.to_owned()), Value::from)
743        }
744        "number" => {
745            raw.parse::<f64>().map_or_else(|_error| Value::String(raw.to_owned()), Value::from)
746        }
747        "boolean" => {
748            raw.parse::<bool>().map_or_else(|_error| Value::String(raw.to_owned()), Value::from)
749        }
750        _ => Value::String(raw.to_owned()),
751    }
752}
753
754fn parse_status_code(status: &str) -> u16 {
755    if status == "default" {
756        return 200;
757    }
758    status.parse::<u16>().unwrap_or(200)
759}
760
761#[cfg(test)]
762mod tests {
763    use std::collections::HashMap;
764
765    use http::{HeaderMap, Method};
766    use serde_json::json;
767
768    use super::OpenApiRuntime;
769
770    #[test]
771    fn operation_level_parameter_overrides_path_level_parameter() {
772        let root = json!({
773            "openapi": "3.1.0",
774            "paths": {
775                "/pets/{id}": {
776                    "parameters": [
777                        {
778                            "name": "id",
779                            "in": "path",
780                            "required": true,
781                            "schema": {"type": "integer"}
782                        }
783                    ],
784                    "get": {
785                        "parameters": [
786                            {
787                                "name": "id",
788                                "in": "path",
789                                "required": true,
790                                "schema": {
791                                    "type": "string",
792                                    "pattern": "^[a-z]+$"
793                                }
794                            }
795                        ],
796                        "responses": {
797                            "200": {
798                                "description": "ok"
799                            }
800                        }
801                    }
802                }
803            }
804        });
805
806        let runtime = OpenApiRuntime::from_resolved(root).expect("runtime should parse");
807
808        let alpha = runtime
809            .match_operation(&Method::GET, "/pets/abc")
810            .expect("operation should match alpha path");
811        let alpha_issues = alpha.operation.validate_request(
812            &alpha.path_params,
813            &HashMap::new(),
814            &HeaderMap::new(),
815            None,
816        );
817        assert!(alpha_issues.is_empty(), "operation-level schema should accept alpha id");
818
819        let numeric = runtime
820            .match_operation(&Method::GET, "/pets/123")
821            .expect("operation should match numeric path");
822        let numeric_issues = numeric.operation.validate_request(
823            &numeric.path_params,
824            &HashMap::new(),
825            &HeaderMap::new(),
826            None,
827        );
828        assert!(!numeric_issues.is_empty(), "operation-level pattern should reject numeric id");
829    }
830}