1use 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#[derive(Debug, Clone)]
17pub struct OpenApiRuntime {
18 operations: Vec<OperationSpec>,
19 router: PathRouter,
20}
21
22#[derive(Debug)]
24pub struct MatchedOperation<'a> {
25 pub operation: &'a OperationSpec,
27 pub path_params: HashMap<String, String>,
29}
30
31#[derive(Debug, Clone)]
33pub struct OperationSpec {
34 pub method: Method,
36 pub path_template: String,
38 pub operation_id: Option<String>,
40 pub parameters: Vec<ParameterSpec>,
42 pub request_body_schema: Option<Value>,
44 pub request_body_required: bool,
46 pub responses: Vec<ResponseSpec>,
48 pub callbacks: Vec<CallbackSpec>,
50}
51
52#[derive(Debug, Clone)]
54pub struct CallbackSpec {
55 pub callback_url_expression: String,
57 pub method: Method,
59 pub request_body_schema: Option<Value>,
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum ParameterIn {
66 Path,
68 Query,
70 Header,
72}
73
74#[derive(Debug, Clone)]
76pub struct ParameterSpec {
77 pub name: String,
79 pub location: ParameterIn,
81 pub required: bool,
83 pub schema: Value,
85}
86
87#[derive(Debug, Clone)]
89pub struct ResponseSpec {
90 pub status: String,
92 pub schema: Option<Value>,
94 pub example: Option<Value>,
96 pub named_examples: HashMap<String, Value>,
98}
99
100#[derive(Debug, Clone)]
102pub struct MockHttpResponse {
103 pub status: u16,
105 pub body: Option<Value>,
107}
108
109impl OpenApiRuntime {
110 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 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 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 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(¶meter.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, ¶meter.schema);
232 match validate_instance(¶meter.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(¶meter.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(¶meter.schema);
261 if is_array {
262 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(¶meter.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 let raw_value = &vals[0];
288 let parsed_value = parse_parameter_value(raw_value, ¶meter.schema);
289 match validate_instance(¶meter.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(¶meter.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, ¶meter.schema);
319 match validate_instance(¶meter.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 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 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 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 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 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 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
616pub 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 return None;
639 }
640 }
641 result.push_str(remaining);
642
643 if result.is_empty() { None } else { Some(result) }
644}
645
646fn 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
719fn 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}