1use crate::response_selection::{ResponseSelectionMode, ResponseSelector};
7use crate::spec::OpenApiSpec;
8use mockforge_foundation::ai_response::AiResponseConfig;
9use mockforge_foundation::error::Result;
10use mockforge_foundation::intelligent_behavior::Persona;
11use openapiv3::{Operation, PathItem, ReferenceOr};
12use std::collections::BTreeMap;
13use std::sync::Arc;
14
15fn extract_path_parameters(path_template: &str) -> Vec<String> {
17 let mut params = Vec::new();
18 let mut in_param = false;
19 let mut current_param = String::new();
20
21 for ch in path_template.chars() {
22 match ch {
23 '{' => {
24 in_param = true;
25 current_param.clear();
26 }
27 '}' => {
28 if in_param {
29 params.push(current_param.clone());
30 in_param = false;
31 }
32 }
33 ch if in_param => {
34 current_param.push(ch);
35 }
36 _ => {}
37 }
38 }
39
40 params
41}
42
43#[derive(Debug, Clone)]
45pub struct OpenApiRoute {
46 pub method: String,
48 pub path: String,
50 pub operation: Operation,
52 pub metadata: BTreeMap<String, String>,
54 pub parameters: Vec<String>,
56 pub spec: Arc<OpenApiSpec>,
58 pub ai_config: Option<AiResponseConfig>,
60 pub response_selection_mode: ResponseSelectionMode,
62 pub response_selector: Arc<ResponseSelector>,
64 pub persona: Option<Arc<Persona>>,
66}
67
68impl OpenApiRoute {
69 pub fn new(method: String, path: String, operation: Operation, spec: Arc<OpenApiSpec>) -> Self {
71 Self::new_with_persona(method, path, operation, spec, None)
72 }
73
74 pub fn new_with_persona(
76 method: String,
77 path: String,
78 operation: Operation,
79 spec: Arc<OpenApiSpec>,
80 persona: Option<Arc<Persona>>,
81 ) -> Self {
82 let parameters = extract_path_parameters(&path);
83
84 let ai_config = Self::parse_ai_config(&operation);
86
87 let response_selection_mode = Self::parse_response_selection_mode(&operation);
89 let response_selector = Arc::new(ResponseSelector::new(response_selection_mode));
90
91 Self {
92 method,
93 path,
94 operation,
95 metadata: BTreeMap::new(),
96 parameters,
97 spec,
98 ai_config,
99 response_selection_mode,
100 response_selector,
101 persona,
102 }
103 }
104
105 fn parse_ai_config(operation: &Operation) -> Option<AiResponseConfig> {
107 if let Some(ai_config_value) = operation.extensions.get("x-mockforge-ai") {
109 match serde_json::from_value::<AiResponseConfig>(ai_config_value.clone()) {
111 Ok(config) => {
112 if config.is_active() {
113 tracing::debug!(
114 "Parsed AI config for operation {}: mode={:?}, prompt={:?}",
115 operation.operation_id.as_deref().unwrap_or("unknown"),
116 config.mode,
117 config.prompt
118 );
119 return Some(config);
120 }
121 }
122 Err(e) => {
123 tracing::warn!(
124 "Failed to parse x-mockforge-ai extension for operation {}: {}",
125 operation.operation_id.as_deref().unwrap_or("unknown"),
126 e
127 );
128 }
129 }
130 }
131 None
132 }
133
134 fn parse_response_selection_mode(operation: &Operation) -> ResponseSelectionMode {
136 let op_id = operation.operation_id.as_deref().unwrap_or("unknown");
138
139 if let Ok(op_env_var) = std::env::var(format!(
141 "MOCKFORGE_RESPONSE_SELECTION_{}",
142 op_id.to_uppercase().replace('-', "_")
143 )) {
144 if let Some(mode) = ResponseSelectionMode::from_str(&op_env_var) {
145 tracing::debug!(
146 "Using response selection mode from env var for operation {}: {:?}",
147 op_id,
148 mode
149 );
150 return mode;
151 }
152 }
153
154 if let Ok(global_mode_str) = std::env::var("MOCKFORGE_RESPONSE_SELECTION_MODE") {
156 if let Some(mode) = ResponseSelectionMode::from_str(&global_mode_str) {
157 tracing::debug!("Using global response selection mode from env var: {:?}", mode);
158 return mode;
159 }
160 }
161
162 Self::parse_spec_response_selection_mode(operation)
163 }
164
165 fn parse_spec_response_selection_mode(operation: &Operation) -> ResponseSelectionMode {
166 let op_id = operation.operation_id.as_deref().unwrap_or("unknown");
167 if let Some(selection_value) = operation.extensions.get("x-mockforge-response-selection") {
169 if let Some(mode_str) = selection_value.as_str() {
171 if let Some(mode) = ResponseSelectionMode::from_str(mode_str) {
172 tracing::debug!(
173 "Parsed response selection mode for operation {}: {:?}",
174 op_id,
175 mode
176 );
177 return mode;
178 }
179 }
180 if let Some(obj) = selection_value.as_object() {
182 if let Some(mode_str) = obj.get("mode").and_then(|v| v.as_str()) {
183 if let Some(mode) = ResponseSelectionMode::from_str(mode_str) {
184 tracing::debug!(
185 "Parsed response selection mode for operation {}: {:?}",
186 op_id,
187 mode
188 );
189 return mode;
190 }
191 }
192 }
193 tracing::warn!(
194 "Failed to parse x-mockforge-response-selection extension for operation {}",
195 op_id
196 );
197 }
198 ResponseSelectionMode::First
200 }
201
202 pub fn from_operation(
204 method: &str,
205 path: String,
206 operation: &Operation,
207 spec: Arc<OpenApiSpec>,
208 ) -> Self {
209 Self::from_operation_with_persona(method, path, operation, spec, None)
210 }
211
212 pub fn from_operation_with_persona(
214 method: &str,
215 path: String,
216 operation: &Operation,
217 spec: Arc<OpenApiSpec>,
218 persona: Option<Arc<Persona>>,
219 ) -> Self {
220 Self::new_with_persona(method.to_string(), path, operation.clone(), spec, persona)
221 }
222
223 pub fn axum_path(&self) -> String {
225 let path = self.path.split('?').next().unwrap_or(&self.path);
228
229 if path.contains("()") {
231 let path = path.replace("()", "");
232 return path;
233 }
234
235 if path.contains('(') && path.contains('=') {
240 let mut result = String::with_capacity(path.len());
241 let mut chars = path.chars().peekable();
242
243 while let Some(ch) = chars.next() {
244 if ch == '(' {
245 let mut paren_content = String::new();
247 for c in chars.by_ref() {
248 if c == ')' {
249 break;
250 }
251 paren_content.push(c);
252 }
253 for part in paren_content.split(',') {
255 if let Some((_key, value)) = part.split_once('=') {
256 let param = value.trim_matches(|c| c == '\'' || c == '"');
257 result.push('/');
258 result.push_str(param);
259 }
260 }
261 } else {
262 result.push(ch);
263 }
264 }
265 return result;
266 }
267
268 path.to_string()
269 }
270
271 pub fn is_valid_axum_path(&self) -> bool {
276 let path = self.axum_path();
277 if path.contains('(') || path.contains(')') {
279 return false;
280 }
281 for segment in path.split('/') {
283 let brace_count = segment.matches('{').count();
284 if brace_count > 1 {
285 return false;
286 }
287 if brace_count == 1
290 && segment
291 != format!(
292 "{{{}}}",
293 segment
294 .trim_matches(|c: char| c != '{' && c != '}')
295 .trim_matches(|c| c == '{' || c == '}')
296 )
297 {
298 if !segment.starts_with('{') || !segment.ends_with('}') {
301 return false;
302 }
303 }
304 }
305 true
306 }
307
308 pub fn with_metadata(mut self, key: String, value: String) -> Self {
310 self.metadata.insert(key, value);
311 self
312 }
313
314 pub async fn mock_response_with_status_async(
323 &self,
324 context: &mockforge_foundation::ai_response::RequestContext,
325 ai_generator: Option<&dyn crate::response::AiGenerator>,
326 ) -> (u16, serde_json::Value) {
327 use crate::response::ResponseGenerator;
328
329 let status_code = self.find_first_available_status_code();
331
332 if let Some(ai_config) = &self.ai_config {
334 if ai_config.is_active() {
335 tracing::info!(
336 "Using AI-assisted response generation for {} {}",
337 self.method,
338 self.path
339 );
340
341 match ResponseGenerator::generate_ai_response(ai_config, context, ai_generator)
342 .await
343 {
344 Ok(response_body) => {
345 tracing::debug!(
346 "AI response generated successfully for {} {}: {:?}",
347 self.method,
348 self.path,
349 response_body
350 );
351 return (status_code, response_body);
352 }
353 Err(e) => {
354 tracing::warn!(
355 "AI response generation failed for {} {}: {}, falling back to standard generation",
356 self.method,
357 self.path,
358 e
359 );
360 }
362 }
363 }
364 }
365
366 let expand_tokens = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
368 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
369 .unwrap_or(false);
370
371 let mode = Some(self.response_selection_mode);
373 let selector = Some(self.response_selector.as_ref());
374
375 let persona_ref = self.persona.as_deref();
377
378 match ResponseGenerator::generate_response_with_expansion_and_mode_and_persona(
379 &self.spec,
380 &self.operation,
381 status_code,
382 Some("application/json"),
383 expand_tokens,
384 mode,
385 selector,
386 persona_ref,
387 ) {
388 Ok(response_body) => {
389 tracing::debug!(
390 "ResponseGenerator succeeded for {} {} with status {}: {:?}",
391 self.method,
392 self.path,
393 status_code,
394 response_body
395 );
396 (status_code, response_body)
397 }
398 Err(e) => {
399 tracing::debug!(
400 "ResponseGenerator failed for {} {}: {}, using fallback",
401 self.method,
402 self.path,
403 e
404 );
405 let response_body = serde_json::json!({
407 "message": format!("Mock response for {} {}", self.method, self.path),
408 "operation_id": self.operation.operation_id,
409 "status": status_code
410 });
411 (status_code, response_body)
412 }
413 }
414 }
415
416 pub fn mock_response_with_status(&self) -> (u16, serde_json::Value) {
421 self.mock_response_with_status_and_scenario(None)
422 }
423
424 pub fn mock_response_with_status_and_scenario(
436 &self,
437 scenario: Option<&str>,
438 ) -> (u16, serde_json::Value) {
439 self.mock_response_with_status_and_scenario_and_override(scenario, None)
440 }
441
442 pub(crate) fn hosted_response(
444 &self,
445 scenario: Option<&str>,
446 status_override: Option<u16>,
447 ) -> (u16, serde_json::Value) {
448 let status = status_override
449 .filter(|code| self.has_response_for_status(*code))
450 .unwrap_or_else(|| self.find_first_available_status_code());
451 let body = crate::response::ResponseGenerator::generate_hosted_response(
452 &self.spec,
453 &self.operation,
454 status,
455 scenario,
456 Self::parse_spec_response_selection_mode(&self.operation),
457 &self.response_selector,
458 )
459 .unwrap_or_else(|_| serde_json::json!({}));
460 (status, body)
461 }
462
463 pub fn mock_response_with_status_and_scenario_and_override(
469 &self,
470 scenario: Option<&str>,
471 status_override: Option<u16>,
472 ) -> (u16, serde_json::Value) {
473 let (status, body, _) =
474 self.mock_response_with_status_and_scenario_and_trace(scenario, status_override);
475 (status, body)
476 }
477
478 pub fn mock_response_with_status_and_scenario_and_trace(
482 &self,
483 scenario: Option<&str>,
484 status_override: Option<u16>,
485 ) -> (
486 u16,
487 serde_json::Value,
488 mockforge_foundation::response_generation_trace::ResponseGenerationTrace,
489 ) {
490 use crate::response_trace;
491 use mockforge_foundation::response_generation_trace::ResponseGenerationTrace;
492
493 if let Some(requested) = status_override {
506 if !self.has_response_for_status(requested) {
507 let available: Vec<String> =
508 self.operation.responses.responses.keys().map(|k| format!("{:?}", k)).collect();
509 mockforge_foundation::conformance_violations::record(
510 mockforge_foundation::conformance_violations::ServerConformanceViolation {
511 timestamp: chrono::Utc::now(),
512 method: self.method.clone(),
513 path: self.path.clone(),
514 client_ip: "unknown".to_string(),
515 status: requested,
516 reason: format!(
517 "spec defines no response for status {} on {} {}; available: {}",
518 requested,
519 self.method,
520 self.path,
521 available.join(", ")
522 ),
523 category: "response-shape".to_string(),
524 occurrences: 1,
525 client_mockforge_version: None,
532 client_sent_at: None,
533 summary: String::new(),
534 categories: Vec::new(),
535 },
536 );
537 }
538 }
539
540 let status_code = status_override
542 .filter(|code| self.has_response_for_status(*code))
543 .unwrap_or_else(|| self.find_first_available_status_code());
544
545 let expand_tokens = std::env::var("MOCKFORGE_RESPONSE_TEMPLATE_EXPAND")
547 .map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
548 .unwrap_or(false);
549
550 let mode = Some(self.response_selection_mode);
552 let selector = Some(self.response_selector.as_ref());
553
554 match response_trace::generate_response_with_trace(
556 &self.spec,
557 &self.operation,
558 status_code,
559 Some("application/json"),
560 expand_tokens,
561 scenario,
562 mode,
563 selector,
564 None, ) {
566 Ok((response_body, trace)) => {
567 tracing::debug!(
568 "ResponseGenerator succeeded for {} {} with status {} and scenario {:?}: {:?}",
569 self.method,
570 self.path,
571 status_code,
572 scenario,
573 response_body
574 );
575 (status_code, response_body, trace)
576 }
577 Err(e) => {
578 tracing::debug!(
579 "ResponseGenerator failed for {} {}: {}, using fallback",
580 self.method,
581 self.path,
582 e
583 );
584 let response_body = serde_json::json!({
586 "message": format!("Mock response for {} {}", self.method, self.path),
587 "operation_id": self.operation.operation_id,
588 "status": status_code
589 });
590 let mut trace = ResponseGenerationTrace::new();
592 trace.set_final_payload(response_body.clone());
593 trace.add_metadata("fallback".to_string(), serde_json::json!(true));
594 trace.add_metadata("error".to_string(), serde_json::json!(e.to_string()));
595 (status_code, response_body, trace)
596 }
597 }
598 }
599
600 pub fn has_response_for_status(&self, code: u16) -> bool {
602 self.operation
603 .responses
604 .responses
605 .iter()
606 .any(|(status, _)| matches!(status, openapiv3::StatusCode::Code(c) if *c == code))
607 }
608
609 pub fn find_first_available_status_code(&self) -> u16 {
618 let mut lowest_2xx: Option<u16> = None;
619 let mut has_2xx_range = false;
620 let mut lowest_other: Option<u16> = None;
621
622 for (status, _) in &self.operation.responses.responses {
623 match status {
624 openapiv3::StatusCode::Code(code) => {
625 if (200..300).contains(code) {
626 lowest_2xx = Some(lowest_2xx.map_or(*code, |c| c.min(*code)));
627 } else {
628 lowest_other = Some(lowest_other.map_or(*code, |c| c.min(*code)));
629 }
630 }
631 openapiv3::StatusCode::Range(2) => has_2xx_range = true,
632 openapiv3::StatusCode::Range(_) => {}
633 }
634 }
635
636 if let Some(code) = lowest_2xx {
637 return code;
638 }
639 if has_2xx_range {
640 return 200;
641 }
642 if self.operation.responses.default.is_some() {
645 return 200;
646 }
647 lowest_other.unwrap_or(200)
648 }
649
650 pub fn mock_response_headers_for_status(&self, status_code: u16) -> Vec<(String, String)> {
670 use openapiv3::{
671 ParameterSchemaOrContent, ReferenceOr, SchemaKind, StatusCode, Type,
672 VariantOrUnknownOrEmpty,
673 };
674
675 let response_ref =
676 self.operation.responses.responses.iter().find_map(|(code, r)| match code {
677 StatusCode::Code(c) if *c == status_code => Some(r),
678 _ => None,
679 });
680 let Some(response_ref) = response_ref else {
681 return Vec::new();
682 };
683 let Some(response_item) = response_ref.as_item() else {
684 return Vec::new();
685 };
686
687 let mut out = Vec::new();
688 for (name, header_ref) in &response_item.headers {
689 let Some(header) = header_ref.as_item() else {
690 out.push((name.clone(), "mockforge-mock-value".to_string()));
691 continue;
692 };
693
694 let value = match &header.format {
695 ParameterSchemaOrContent::Schema(ReferenceOr::Item(schema)) => {
696 match &schema.schema_kind {
697 SchemaKind::Type(Type::Integer(_)) => "0".to_string(),
698 SchemaKind::Type(Type::Number(_)) => "0".to_string(),
699 SchemaKind::Type(Type::Boolean(_)) => "true".to_string(),
700 SchemaKind::Type(Type::String(s)) => match &s.format {
701 VariantOrUnknownOrEmpty::Item(openapiv3::StringFormat::DateTime) => {
702 "1970-01-01T00:00:00Z".to_string()
703 }
704 VariantOrUnknownOrEmpty::Item(openapiv3::StringFormat::Date) => {
705 "1970-01-01".to_string()
706 }
707 VariantOrUnknownOrEmpty::Unknown(fmt) if fmt == "uuid" => {
708 "00000000-0000-0000-0000-000000000000".to_string()
709 }
710 _ => synthesize_string_header_value(name),
711 },
712 _ => synthesize_string_header_value(name),
713 }
714 }
715 _ => synthesize_string_header_value(name),
716 };
717 out.push((name.clone(), value));
718 }
719 out
720 }
721}
722
723fn synthesize_string_header_value(header_name: &str) -> String {
728 if header_name.eq_ignore_ascii_case("set-cookie") {
729 "mockforge_session=mockforge-synthetic; Path=/".to_string()
733 } else {
734 "mockforge-mock-value".to_string()
735 }
736}
737
738#[derive(Debug, Clone)]
740pub struct OpenApiOperation {
741 pub method: String,
743 pub path: String,
745 pub operation: Operation,
747}
748
749impl OpenApiOperation {
750 pub fn new(method: String, path: String, operation: Operation) -> Self {
752 Self {
753 method,
754 path,
755 operation,
756 }
757 }
758}
759
760pub struct RouteGenerator;
762
763impl RouteGenerator {
764 pub fn generate_routes_from_path(
766 path: &str,
767 path_item: &ReferenceOr<PathItem>,
768 spec: &Arc<OpenApiSpec>,
769 ) -> Result<Vec<OpenApiRoute>> {
770 Self::generate_routes_from_path_with_persona(path, path_item, spec, None)
771 }
772
773 pub fn generate_routes_from_path_with_persona(
775 path: &str,
776 path_item: &ReferenceOr<PathItem>,
777 spec: &Arc<OpenApiSpec>,
778 persona: Option<Arc<Persona>>,
779 ) -> Result<Vec<OpenApiRoute>> {
780 let mut routes = Vec::new();
781
782 if let Some(item) = path_item.as_item() {
783 if let Some(op) = &item.get {
785 routes.push(OpenApiRoute::new_with_persona(
786 "GET".to_string(),
787 path.to_string(),
788 op.clone(),
789 spec.clone(),
790 persona.clone(),
791 ));
792 }
793 if let Some(op) = &item.post {
794 routes.push(OpenApiRoute::new_with_persona(
795 "POST".to_string(),
796 path.to_string(),
797 op.clone(),
798 spec.clone(),
799 persona.clone(),
800 ));
801 }
802 if let Some(op) = &item.put {
803 routes.push(OpenApiRoute::new_with_persona(
804 "PUT".to_string(),
805 path.to_string(),
806 op.clone(),
807 spec.clone(),
808 persona.clone(),
809 ));
810 }
811 if let Some(op) = &item.delete {
812 routes.push(OpenApiRoute::new_with_persona(
813 "DELETE".to_string(),
814 path.to_string(),
815 op.clone(),
816 spec.clone(),
817 persona.clone(),
818 ));
819 }
820 if let Some(op) = &item.patch {
821 routes.push(OpenApiRoute::new_with_persona(
822 "PATCH".to_string(),
823 path.to_string(),
824 op.clone(),
825 spec.clone(),
826 persona.clone(),
827 ));
828 }
829 if let Some(op) = &item.head {
830 routes.push(OpenApiRoute::new_with_persona(
831 "HEAD".to_string(),
832 path.to_string(),
833 op.clone(),
834 spec.clone(),
835 persona.clone(),
836 ));
837 }
838 if let Some(op) = &item.options {
839 routes.push(OpenApiRoute::new_with_persona(
840 "OPTIONS".to_string(),
841 path.to_string(),
842 op.clone(),
843 spec.clone(),
844 persona.clone(),
845 ));
846 }
847 if let Some(op) = &item.trace {
848 routes.push(OpenApiRoute::new_with_persona(
849 "TRACE".to_string(),
850 path.to_string(),
851 op.clone(),
852 spec.clone(),
853 persona.clone(),
854 ));
855 }
856 }
857
858 Ok(routes)
859 }
860}
861
862#[cfg(test)]
863mod status_code_selection_tests {
864 use super::OpenApiRoute;
865 use crate::spec::OpenApiSpec;
866 use serde_json::json;
867 use std::sync::Arc;
868
869 fn route_from_responses(responses: serde_json::Value) -> OpenApiRoute {
870 let operation: openapiv3::Operation =
871 serde_json::from_value(json!({ "responses": responses })).expect("valid operation");
872 let spec = OpenApiSpec::from_json(json!({
873 "openapi": "3.0.0",
874 "info": {"title": "t", "version": "1.0.0"},
875 "paths": {}
876 }))
877 .expect("valid spec");
878 OpenApiRoute::new("GET".to_string(), "/x".to_string(), operation, Arc::new(spec))
879 }
880
881 #[test]
882 fn prefers_2xx_over_earlier_listed_error_codes() {
883 let r = route_from_responses(json!({
886 "400": {"description": "bad"},
887 "404": {"description": "missing"},
888 "200": {"description": "ok"},
889 }));
890 assert_eq!(r.find_first_available_status_code(), 200);
891 }
892
893 #[test]
894 fn prefers_lowest_2xx() {
895 let r = route_from_responses(json!({
896 "204": {"description": "no content"},
897 "201": {"description": "created"},
898 }));
899 assert_eq!(r.find_first_available_status_code(), 201);
900 }
901
902 #[test]
903 fn uses_2xx_range_when_no_explicit_2xx() {
904 let r = route_from_responses(json!({ "2XX": {"description": "ok-ish"} }));
905 assert_eq!(r.find_first_available_status_code(), 200);
906 }
907
908 #[test]
909 fn default_only_returns_200() {
910 let r = route_from_responses(json!({ "default": {"description": "any"} }));
911 assert_eq!(r.find_first_available_status_code(), 200);
912 }
913
914 #[test]
915 fn error_only_returns_lowest_error() {
916 let r = route_from_responses(json!({
918 "500": {"description": "err"},
919 "404": {"description": "err"},
920 }));
921 assert_eq!(r.find_first_available_status_code(), 404);
922 }
923
924 #[test]
925 fn synthesises_set_cookie_when_declared_in_spec() {
926 let r = route_from_responses(json!({
932 "200": {
933 "description": "ok",
934 "headers": {
935 "Set-Cookie": {"schema": {"type": "string"}}
936 }
937 }
938 }));
939 let headers = r.mock_response_headers_for_status(200);
940 let (_, value) = headers
941 .iter()
942 .find(|(name, _)| name.eq_ignore_ascii_case("Set-Cookie"))
943 .expect("Set-Cookie header should be synthesised");
944 assert!(
945 value.contains('=') && value.contains("Path="),
946 "expected cookie shape `name=value; Path=...`, got: {value:?}"
947 );
948 }
949
950 #[test]
951 fn synthesises_uuid_format_value_for_string_header() {
952 let r = route_from_responses(json!({
953 "200": {
954 "description": "ok",
955 "headers": {
956 "X-Request-Id": {"schema": {"type": "string", "format": "uuid"}}
957 }
958 }
959 }));
960 let headers = r.mock_response_headers_for_status(200);
961 let (_, value) = headers
962 .iter()
963 .find(|(name, _)| name.eq_ignore_ascii_case("X-Request-Id"))
964 .expect("X-Request-Id header should be synthesised");
965 assert_eq!(value, "00000000-0000-0000-0000-000000000000");
966 }
967
968 #[test]
969 fn omits_headers_for_unmatched_status() {
970 let r = route_from_responses(json!({
972 "200": {
973 "description": "ok",
974 "headers": {"Set-Cookie": {"schema": {"type": "string"}}}
975 }
976 }));
977 assert!(r.mock_response_headers_for_status(404).is_empty());
978 }
979}