1mod encoding;
15mod parameters;
16
17use parameters::{cookie_parameters, header_parameters, query_parameters};
18mod response_schema;
19
20use response_schema::declared_json_schema;
21
22use encoding::decode_body;
23use std::collections::HashMap;
24
25use axum::http::HeaderMap;
26use serde_json::Map as JsonMap;
27use serde_json::Value;
28
29use mockforge_openapi::openapi_routes::{OpenApiRouteRegistry, ValidationMode, ValidationOptions};
30use mockforge_openapi::schema_ref_resolver::merge_components_into;
31use mockforge_openapi::spec::OpenApiSpec;
32
33pub struct ConformanceTap {
35 registry: OpenApiRouteRegistry,
38 strict: bool,
39}
40
41impl ConformanceTap {
42 pub fn from_spec_value(spec: Value, strict: bool) -> Result<Self, String> {
45 let openapi = OpenApiSpec::from_json(spec).map_err(|e| format!("invalid spec: {e}"))?;
46 let routes = OpenApiRouteRegistry::new(openapi.clone());
47 let options = ValidationOptions {
50 overrides: routes
51 .routes()
52 .iter()
53 .map(|route| {
54 (
55 format!("{} {}", route.method.to_uppercase(), route.path),
56 ValidationMode::Enforce,
57 )
58 })
59 .collect(),
60 ..Default::default()
61 };
62 Ok(Self {
63 registry: OpenApiRouteRegistry::new_with_options(openapi, options),
64 strict,
65 })
66 }
67
68 pub fn is_strict(&self) -> bool {
69 self.strict
70 }
71
72 pub fn match_template(
75 &self,
76 method: &str,
77 concrete_path: &str,
78 ) -> Option<(String, HashMap<String, String>)> {
79 self.registry.routes().iter().find_map(|route| {
81 if !route.method.eq_ignore_ascii_case(method) {
82 return None;
83 }
84 match_template(concrete_path, &route.path).map(|params| (route.path.clone(), params))
85 })
86 }
87
88 fn matched_route(
90 &self,
91 method: &str,
92 concrete_path: &str,
93 ) -> Option<(String, &mockforge_openapi::route::OpenApiRoute)> {
94 let (template, _) = self.match_template(method, concrete_path)?;
95 let route = self.registry.get_route(&template, method)?;
96 Some((template, route))
97 }
98
99 pub async fn validate_request(
106 &self,
107 method: &str,
108 concrete_path: &str,
109 query: Option<&str>,
110 headers: &HeaderMap,
111 body: Option<&[u8]>,
112 ) -> Option<(u16, Value)> {
113 let Some((template, path_params)) = self.match_template(method, concrete_path) else {
114 return None; };
116
117 let query_map = query_parameters(query);
118 let route = self.matched_route(method, concrete_path).map(|(_, route)| route);
119 let header_values = header_parameters(headers, route);
120
121 let path_param_map: JsonMap<String, Value> =
123 path_params.iter().map(|(k, v)| (k.clone(), Value::String(v.clone()))).collect();
124
125 let decoded;
126 let body = if let Some(encoding) = headers.get(http::header::CONTENT_ENCODING) {
127 let result = match encoding.to_str() {
128 Ok(encoding) => decode_body(body.unwrap_or_default(), Some(encoding)).await,
129 Err(_) => Err("request content-encoding is invalid".to_string()),
130 };
131 match result {
132 Ok(bytes) => {
133 decoded = bytes;
134 Some(decoded.as_slice())
135 }
136 Err(error) => {
137 let reason = format!("request body cannot be validated: {error}");
138 mockforge_foundation::conformance_violations::record(
139 mockforge_foundation::conformance_violations::ServerConformanceViolation {
140 timestamp: chrono::Utc::now(),
141 method: method.to_string(),
142 path: concrete_path.to_string(),
143 client_ip: "unknown".to_string(),
144 status: 400,
145 reason: reason.clone(),
146 category: "request-body".to_string(),
147 occurrences: 1,
148 client_mockforge_version: None,
149 client_sent_at: None,
150 summary: String::new(),
151 categories: vec!["request-body".to_string()],
152 },
153 );
154 return self.strict.then(|| (400, serde_json::json!({"error": reason})));
155 }
156 }
157 } else {
158 body
159 };
160 let (body_json, body_present) = parse_body(body);
161 let json_content_type = headers
162 .get(http::header::CONTENT_TYPE)
163 .and_then(|value| value.to_str().ok())
164 .and_then(|value| value.split(';').next())
165 .map(|value| value.trim().to_ascii_lowercase())
166 .is_some_and(|value| value == "application/json" || value.ends_with("+json"));
167 if body_present && body_json.is_none() && json_content_type {
168 let reason = "request body is not valid JSON";
169 mockforge_foundation::conformance_violations::record(
170 mockforge_foundation::conformance_violations::ServerConformanceViolation {
171 timestamp: chrono::Utc::now(),
172 method: method.to_string(),
173 path: concrete_path.to_string(),
174 client_ip: "unknown".to_string(),
175 status: 400,
176 reason: reason.to_string(),
177 category: "request-body".to_string(),
178 occurrences: 1,
179 client_mockforge_version: None,
180 client_sent_at: None,
181 summary: String::new(),
182 categories: vec!["request-body".to_string()],
183 },
184 );
185 return self.strict.then(|| (400, serde_json::json!({"error": reason})));
186 }
187
188 let cookies = cookie_parameters(headers);
189
190 match self.registry.run_validation_with_recording_ex(
191 &template,
192 method,
193 &path_param_map,
194 &query_map,
195 &header_values,
196 &cookies,
197 body_json.as_ref(),
198 body_present,
199 ) {
200 Ok(()) => None,
201 Err((status, payload)) => self.strict.then_some((status, payload)),
202 }
203 }
204
205 pub async fn validate_response(
213 &self,
214 method: &str,
215 concrete_path: &str,
216 status: u16,
217 body: Option<&[u8]>,
218 ) {
219 self.check_response(method, concrete_path, status, body).await;
220 }
221
222 pub async fn check_response(
225 &self,
226 method: &str,
227 concrete_path: &str,
228 status: u16,
229 body: Option<&[u8]>,
230 ) -> bool {
231 self.check_encoded_response(method, concrete_path, status, body, None).await
232 }
233
234 pub async fn check_encoded_response(
237 &self,
238 method: &str,
239 concrete_path: &str,
240 status: u16,
241 body: Option<&[u8]>,
242 content_encoding: Option<&str>,
243 ) -> bool {
244 let mut headers = HeaderMap::new();
245 headers
246 .insert(http::header::CONTENT_TYPE, http::HeaderValue::from_static("application/json"));
247 if let Some(encoding) = content_encoding {
248 let Ok(value) = http::HeaderValue::from_str(encoding) else {
249 return false;
250 };
251 headers.insert(http::header::CONTENT_ENCODING, value);
252 }
253 self.check_response_headers(method, concrete_path, status, body, &headers).await
254 }
255
256 pub async fn check_response_headers(
258 &self,
259 method: &str,
260 concrete_path: &str,
261 status: u16,
262 body: Option<&[u8]>,
263 headers: &HeaderMap,
264 ) -> bool {
265 let Some((template, _)) = self.match_template(method, concrete_path) else {
266 return true;
267 };
268 let Some(route) = self.registry.get_route(&template, method) else {
269 return true;
270 };
271
272 let responses = &route.operation.responses;
274 let status_declared = responses.responses.keys().any(|code| match code {
275 openapiv3::StatusCode::Code(c) => *c == status,
276 openapiv3::StatusCode::Range(class) => status / 100 == *class,
277 });
278 let has_default = responses.default.is_some();
279 if !status_declared && !has_default {
280 record_response_shape(
281 method,
282 concrete_path,
283 status,
284 &format!("response status {status} is not declared for {method} {template}"),
285 );
286 return false;
287 }
288
289 if method.eq_ignore_ascii_case("HEAD") || status == 204 || status == 304 {
291 return true;
292 }
293 let content_type = headers.get(http::header::CONTENT_TYPE).and_then(|v| v.to_str().ok());
294 let schema_value =
295 match declared_json_schema(responses, status, &self.registry.spec().spec, content_type)
296 {
297 Ok(Some(schema)) => schema,
298 Ok(None) => return true,
299 Err(error) => {
300 record_response_shape(method, concrete_path, status, &error);
301 return false;
302 }
303 };
304 let content_encoding =
305 headers.get(http::header::CONTENT_ENCODING).and_then(|v| v.to_str().ok());
306 let decoded = match decode_body(body.unwrap_or_default(), content_encoding).await {
307 Ok(decoded) => decoded,
308 Err(error) => {
309 record_response_shape(method, concrete_path, status, &error);
310 return false;
311 }
312 };
313 let Ok(body_json) = serde_json::from_slice::<Value>(&decoded) else {
314 record_response_shape(
315 method,
316 concrete_path,
317 status,
318 "response body is not valid JSON for the declared JSON schema",
319 );
320 return false;
321 };
322
323 let merged = merge_components_into(schema_value.clone(), &self.registry.spec().spec);
324 let result =
325 mockforge_openapi::openapi_routes::validation::validate_json_value(&body_json, &merged);
326 if !result.errors.is_empty() {
327 let detail =
328 result.errors.iter().map(|e| e.message.clone()).collect::<Vec<_>>().join("; ");
329 record_response_shape(
330 method,
331 concrete_path,
332 status,
333 &format!("response body violates {method} {template} {status} schema: {detail}"),
334 );
335 return false;
336 }
337 true
338 }
339}
340
341fn record_response_shape(method: &str, path: &str, status: u16, reason: &str) {
342 use mockforge_foundation::conformance_violations::{self, ServerConformanceViolation};
343 conformance_violations::record(ServerConformanceViolation {
344 timestamp: chrono::Utc::now(),
345 method: method.to_string(),
346 path: path.to_string(),
347 client_ip: "unknown".to_string(),
348 status,
349 reason: reason.to_string(),
350 category: "response-shape".to_string(),
351 occurrences: 1,
352 client_mockforge_version: None,
353 client_sent_at: None,
354 summary: String::new(),
355 categories: vec!["response-shape".to_string()],
356 });
357}
358
359fn match_template(concrete: &str, template: &str) -> Option<HashMap<String, String>> {
362 let mut params = HashMap::new();
363 let mut c_parts = concrete.split('/');
364 for t in template.split('/') {
365 let c = c_parts.next()?;
366 if t.starts_with('{') && t.ends_with('}') {
367 params.insert(t[1..t.len() - 1].to_string(), c.to_string());
368 } else if t != c {
369 return None;
370 }
371 }
372 if c_parts.next().is_some() {
373 return None; }
375 Some(params)
376}
377
378fn parse_body(body: Option<&[u8]>) -> (Option<Value>, bool) {
379 match body {
380 None => (None, false),
381 Some([]) => (None, false),
382 Some(b) => (serde_json::from_slice::<Value>(b).ok(), true),
383 }
384}
385
386#[cfg(test)]
387mod tests {
388 use super::*;
389 use mockforge_foundation::conformance_violations;
390
391 #[tokio::test]
392 async fn response_uses_actual_media_type_and_http_body_semantics() {
393 let spec = serde_json::json!({
394 "openapi":"3.0.0", "info":{"title":"Media", "version":"1"},
395 "paths":{"/media":{"get":{"responses":{
396 "200":{"description":"ok", "content":{
397 "application/json":{"schema":{"type":"object", "required":["id"]}},
398 "application/problem+json":{"schema":{"type":"object", "required":["title"]}},
399 "application/xml":{"schema":{"type":"string"}}
400 }},
401 "204":{"description":"no content", "content":{"application/json":{"schema":{"type":"object"}}}},
402 "304":{"description":"not modified", "content":{"application/json":{"schema":{"type":"object"}}}}
403 }}, "head":{"responses":{"200":{"description":"headers", "content":{"application/json":{"schema":{"type":"object"}}}}}}}}
404 });
405 let tap = ConformanceTap::from_spec_value(spec, true).unwrap();
406 let mut headers = HeaderMap::new();
407 headers.insert(
408 http::header::CONTENT_TYPE,
409 http::HeaderValue::from_static("application/xml; charset=utf-8"),
410 );
411 assert!(
412 tap.check_response_headers("GET", "/media", 200, Some(b"<id>1</id>"), &headers)
413 .await
414 );
415 headers.insert(
416 http::header::CONTENT_TYPE,
417 http::HeaderValue::from_static("application/problem+json"),
418 );
419 assert!(
420 tap.check_response_headers(
421 "GET",
422 "/media",
423 200,
424 Some(br#"{"title":"Problem"}"#),
425 &headers
426 )
427 .await
428 );
429 assert!(
430 !tap.check_response_headers("GET", "/media", 200, Some(br#"{"id":1}"#), &headers)
431 .await
432 );
433 headers.insert(
434 http::header::CONTENT_TYPE,
435 http::HeaderValue::from_static("Application/JSON; charset=utf-8"),
436 );
437 assert!(
438 tap.check_response_headers("GET", "/media", 200, Some(br#"{"id":1}"#), &headers)
439 .await
440 );
441 headers.insert(http::header::CONTENT_TYPE, http::HeaderValue::from_static("text/plain"));
442 assert!(
443 !tap.check_response_headers("GET", "/media", 200, Some(b"unexpected"), &headers)
444 .await
445 );
446 headers.clear();
447 assert!(
448 !tap.check_response_headers("GET", "/media", 200, Some(br#"{"id":1}"#), &headers)
449 .await
450 );
451 for status in [204, 304] {
452 assert!(tap.check_response_headers("GET", "/media", status, None, &headers).await);
453 }
454 assert!(tap.check_response_headers("HEAD", "/media", 200, None, &headers).await);
455 assert!(!tap.check_response_headers("GET", "/media", 418, None, &headers).await);
456 }
457
458 fn demo_spec() -> Value {
459 serde_json::json!({
460 "openapi": "3.0.0",
461 "info": { "title": "T", "version": "1" },
462 "paths": {
463 "/users/{id}": {
464 "get": {
465 "parameters": [
466 { "name": "id", "in": "path", "required": true,
467 "schema": { "type": "string" } },
468 { "name": "X-Trace", "in": "header", "required": true,
469 "schema": { "type": "string" } }
470 ],
471 "responses": {
472 "200": { "description": "ok",
473 "content": { "application/json": {
474 "schema": { "type": "object",
475 "required": ["id"],
476 "properties": { "id": { "type": "string" } } } } } }
477 }
478 }
479 },
480 "/users": {
481 "post": {
482 "requestBody": {
483 "required": true,
484 "content": { "application/json": {
485 "schema": { "type": "object",
486 "required": ["email"],
487 "properties": { "email": { "type": "string" } } } } }
488 },
489 "responses": { "200": { "description": "ok" } }
490 }
491 }
492 }
493 })
494 }
495
496 async fn tap(strict: bool) -> ConformanceTap {
497 ConformanceTap::from_spec_value(demo_spec(), strict).expect("spec builds")
498 }
499
500 #[tokio::test]
501 async fn missing_required_header_records_query_side_violation() {
502 conformance_violations::clear();
503 let t = tap(false).await;
504
505 let rejected = t.validate_request("GET", "/users/abc", None, &HeaderMap::new(), None).await;
507 assert!(rejected.is_none(), "observational mode must not reject");
508
509 let snap = conformance_violations::snapshot();
510 assert!(
511 snap.iter().any(|v| v.path == "/users/{id}" && !v.reason.is_empty()),
512 "violation should land in the shared buffer, got {:?}",
513 snap
514 );
515 }
516
517 #[tokio::test]
518 async fn strict_mode_returns_rejection_payload() {
519 let t = tap(true).await;
520 let mut headers = HeaderMap::new();
521 headers.insert("X-Trace", "abc".parse().unwrap());
522 let rejected = t.validate_request("GET", "/users/ok", None, &headers, None).await;
523 assert!(rejected.is_none());
526 }
527
528 #[tokio::test]
529 async fn unmatched_paths_are_skipped_silently() {
530 conformance_violations::clear();
531 let t = tap(false).await;
532 assert!(t.match_template("GET", "/unknown/path/deep").is_none());
533 assert!(t
534 .validate_request("GET", "/unknown/path/deep", None, &HeaderMap::new(), None)
535 .await
536 .is_none());
537 assert!(
538 !conformance_violations::snapshot()
539 .iter()
540 .any(|v| v.path.starts_with("/unknown")),
541 "unmatched paths must not produce violations"
542 );
543 }
544
545 #[test]
546 fn template_matcher_extracts_params() {
547 let params =
548 match_template("/users/42/orders/7", "/users/{u}/orders/{o}").expect("matches");
549 assert_eq!(params.get("u").map(String::as_str), Some("42"));
550 assert_eq!(params.get("o").map(String::as_str), Some("7"));
551 assert!(match_template("/users/42/extra", "/users/{u}").is_none());
552 assert!(match_template("/users", "/users/{u}").is_none());
553 }
554
555 #[tokio::test]
556 async fn undeclared_response_status_records_response_shape() {
557 conformance_violations::clear();
558 let t = tap(false).await;
559
560 t.validate_response("GET", "/users/abc", 503, None).await;
563
564 let snap = conformance_violations::snapshot();
565 assert!(
566 snap.iter().any(|v| v.category == "response-shape"),
567 "undeclared status should record a response-shape violation"
568 );
569 }
570}