1use std::collections::HashMap;
15
16use axum::http::HeaderMap;
17use serde_json::Map as JsonMap;
18use serde_json::Value;
19
20use mockforge_openapi::openapi_routes::OpenApiRouteRegistry;
21use mockforge_openapi::schema_ref_resolver::merge_components_into;
22use mockforge_openapi::spec::OpenApiSpec;
23
24pub struct ConformanceTap {
26 registry: OpenApiRouteRegistry,
29 strict: bool,
30}
31
32impl ConformanceTap {
33 pub fn from_spec_value(spec: Value, strict: bool) -> Result<Self, String> {
36 let openapi = OpenApiSpec::from_json(spec).map_err(|e| format!("invalid spec: {e}"))?;
37 Ok(Self {
38 registry: OpenApiRouteRegistry::new(openapi),
39 strict,
40 })
41 }
42
43 pub fn is_strict(&self) -> bool {
44 self.strict
45 }
46
47 pub fn match_template(
50 &self,
51 method: &str,
52 concrete_path: &str,
53 ) -> Option<(String, HashMap<String, String>)> {
54 self.registry.routes().iter().find_map(|route| {
56 if !route.method.eq_ignore_ascii_case(method) {
57 return None;
58 }
59 match_template(concrete_path, &route.path).map(|params| (route.path.clone(), params))
60 })
61 }
62
63 fn matched_route(
65 &self,
66 method: &str,
67 concrete_path: &str,
68 ) -> Option<(String, &mockforge_openapi::route::OpenApiRoute)> {
69 let (template, _) = self.match_template(method, concrete_path)?;
70 let route = self.registry.get_route(&template, method)?;
71 Some((template, route))
72 }
73
74 pub async fn validate_request(
81 &self,
82 method: &str,
83 concrete_path: &str,
84 query: Option<&str>,
85 headers: &HeaderMap,
86 body: Option<&[u8]>,
87 ) -> Option<(u16, Value)> {
88 let Some((template, path_params)) = self.match_template(method, concrete_path) else {
89 return None; };
91
92 let mut query_map = JsonMap::new();
93 if let Some(q) = query {
94 for pair in q.split('&') {
95 let mut kv = pair.splitn(2, '=');
96 if let (Some(k), Some(v)) = (kv.next(), kv.next()) {
97 if let (Ok(k), Ok(v)) = (urlencoding_decode(k), urlencoding_decode(v)) {
98 query_map.insert(k, Value::String(v));
99 }
100 }
101 }
102 }
103
104 let mut header_values = JsonMap::new();
105 for (k, v) in headers {
106 if let Ok(vs) = v.to_str() {
107 header_values.insert(k.to_string(), Value::String(vs.to_string()));
108 }
109 }
110 if let Some((_, route)) = self.matched_route(method, concrete_path) {
115 for p in route.operation.parameters.iter() {
116 if let Some(openapiv3::Parameter::Header { parameter_data, .. }) = match p {
117 openapiv3::ReferenceOr::Item(param) => Some(param),
118 _ => None,
119 } {
120 if !header_values.contains_key(¶meter_data.name) {
121 if let Some(v) = headers.get(¶meter_data.name) {
122 if let Ok(vs) = v.to_str() {
123 header_values.insert(
124 parameter_data.name.clone(),
125 Value::String(vs.to_string()),
126 );
127 }
128 }
129 }
130 }
131 }
132 }
133
134 let path_param_map: JsonMap<String, Value> =
136 path_params.iter().map(|(k, v)| (k.clone(), Value::String(v.clone()))).collect();
137
138 let (body_json, body_present) = parse_body(body);
139
140 match self.registry.run_validation_with_recording_ex(
141 &template,
142 method,
143 &path_param_map,
144 &query_map,
145 &header_values,
146 &JsonMap::new(), body_json.as_ref(),
148 body_present,
149 ) {
150 Ok(()) => None,
151 Err((status, payload)) => self.strict.then_some((status, payload)),
152 }
153 }
154
155 pub async fn validate_response(
163 &self,
164 method: &str,
165 concrete_path: &str,
166 status: u16,
167 body: Option<&[u8]>,
168 ) {
169 let Some((template, _)) = self.match_template(method, concrete_path) else {
170 return;
171 };
172 let Some(route) = self.registry.get_route(&template, method) else {
173 return;
174 };
175
176 let responses = &route.operation.responses;
178 let status_declared = responses.responses.keys().any(|code| match code {
179 openapiv3::StatusCode::Code(c) => *c == status,
180 openapiv3::StatusCode::Range(start) => status >= *start && status < *start + 100,
181 });
182 let has_default = responses.default.is_some();
183 if !status_declared && !has_default {
184 record_response_shape(
185 method,
186 concrete_path,
187 status,
188 &format!("response status {status} is not declared for {method} {template}"),
189 );
190 return;
191 }
192
193 let Some(schema_value) = declared_json_schema(responses, status) else {
195 return;
196 };
197 let Some(bytes) = body else { return };
198 let Ok(body_json) = serde_json::from_slice::<Value>(bytes) else {
199 return; };
201
202 let merged = merge_components_into(schema_value.clone(), &self.registry.spec().spec);
203 let result =
204 mockforge_openapi::openapi_routes::validation::validate_json_value(&body_json, &merged);
205 if !result.errors.is_empty() {
206 let detail =
207 result.errors.iter().map(|e| e.message.clone()).collect::<Vec<_>>().join("; ");
208 record_response_shape(
209 method,
210 concrete_path,
211 status,
212 &format!("response body violates {method} {template} {status} schema: {detail}"),
213 );
214 }
215 }
216}
217
218fn record_response_shape(method: &str, path: &str, status: u16, reason: &str) {
219 use mockforge_foundation::conformance_violations::{self, ServerConformanceViolation};
220 conformance_violations::record(ServerConformanceViolation {
221 timestamp: chrono::Utc::now(),
222 method: method.to_string(),
223 path: path.to_string(),
224 client_ip: "unknown".to_string(),
225 status,
226 reason: reason.to_string(),
227 category: "response-shape".to_string(),
228 occurrences: 1,
229 client_mockforge_version: None,
230 client_sent_at: None,
231 summary: String::new(),
232 categories: vec!["response-shape".to_string()],
233 });
234}
235
236fn declared_json_schema(responses: &openapiv3::Responses, status: u16) -> Option<Value> {
237 use openapiv3::{ReferenceOr, Response, StatusCode};
238 let response_ref: Option<&ReferenceOr<Response>> =
239 responses.responses.iter().find_map(|(code, r)| match code {
240 StatusCode::Code(c) if *c == status => Some(r),
241 StatusCode::Range(start) if status >= *start && status < *start + 100 => Some(r),
242 _ => None,
243 });
244 let response = match response_ref? {
245 ReferenceOr::Item(r) => r,
246 ReferenceOr::Reference { .. } => return None, };
248 let media = response.content.get("application/json")?;
249 match &media.schema {
250 Some(ReferenceOr::Item(schema)) => Some(serde_json::to_value(schema).ok()?),
251 Some(ReferenceOr::Reference { reference }) => {
252 Some(serde_json::json!({ "$ref": reference }))
255 }
256 None => None,
257 }
258}
259
260fn match_template(concrete: &str, template: &str) -> Option<HashMap<String, String>> {
263 let mut params = HashMap::new();
264 let mut c_parts = concrete.split('/');
265 for t in template.split('/') {
266 let c = c_parts.next()?;
267 if t.starts_with('{') && t.ends_with('}') {
268 params.insert(t[1..t.len() - 1].to_string(), c.to_string());
269 } else if t != c {
270 return None;
271 }
272 }
273 if c_parts.next().is_some() {
274 return None; }
276 Some(params)
277}
278
279fn urlencoding_decode(v: &str) -> Result<String, ()> {
280 urlencoding::decode(v).map(|c| c.into_owned()).map_err(|_| ())
281}
282
283fn parse_body(body: Option<&[u8]>) -> (Option<Value>, bool) {
284 match body {
285 None => (None, false),
286 Some([]) => (None, false),
287 Some(b) => (serde_json::from_slice::<Value>(b).ok(), true),
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use mockforge_foundation::conformance_violations;
295
296 fn demo_spec() -> Value {
297 serde_json::json!({
298 "openapi": "3.0.0",
299 "info": { "title": "T", "version": "1" },
300 "paths": {
301 "/users/{id}": {
302 "get": {
303 "parameters": [
304 { "name": "id", "in": "path", "required": true,
305 "schema": { "type": "string" } },
306 { "name": "X-Trace", "in": "header", "required": true,
307 "schema": { "type": "string" } }
308 ],
309 "responses": {
310 "200": { "description": "ok",
311 "content": { "application/json": {
312 "schema": { "type": "object",
313 "required": ["id"],
314 "properties": { "id": { "type": "string" } } } } } }
315 }
316 }
317 },
318 "/users": {
319 "post": {
320 "requestBody": {
321 "required": true,
322 "content": { "application/json": {
323 "schema": { "type": "object",
324 "required": ["email"],
325 "properties": { "email": { "type": "string" } } } } }
326 },
327 "responses": { "200": { "description": "ok" } }
328 }
329 }
330 }
331 })
332 }
333
334 async fn tap(strict: bool) -> ConformanceTap {
335 ConformanceTap::from_spec_value(demo_spec(), strict).expect("spec builds")
336 }
337
338 #[tokio::test]
339 async fn missing_required_header_records_query_side_violation() {
340 conformance_violations::clear();
341 let t = tap(false).await;
342
343 let rejected = t.validate_request("GET", "/users/abc", None, &HeaderMap::new(), None).await;
345 assert!(rejected.is_none(), "observational mode must not reject");
346
347 let snap = conformance_violations::snapshot();
348 assert!(
349 snap.iter().any(|v| v.path == "/users/{id}" && !v.reason.is_empty()),
350 "violation should land in the shared buffer, got {:?}",
351 snap
352 );
353 }
354
355 #[tokio::test]
356 async fn strict_mode_returns_rejection_payload() {
357 let t = tap(true).await;
358 let mut headers = HeaderMap::new();
359 headers.insert("X-Trace", "abc".parse().unwrap());
360 let rejected = t.validate_request("GET", "/users/ok", None, &headers, None).await;
361 assert!(rejected.is_none());
364 }
365
366 #[tokio::test]
367 async fn unmatched_paths_are_skipped_silently() {
368 conformance_violations::clear();
369 let t = tap(false).await;
370 assert!(t.match_template("GET", "/unknown/path/deep").is_none());
371 assert!(t
372 .validate_request("GET", "/unknown/path/deep", None, &HeaderMap::new(), None)
373 .await
374 .is_none());
375 assert!(
376 !conformance_violations::snapshot()
377 .iter()
378 .any(|v| v.path.starts_with("/unknown")),
379 "unmatched paths must not produce violations"
380 );
381 }
382
383 #[test]
384 fn template_matcher_extracts_params() {
385 let params =
386 match_template("/users/42/orders/7", "/users/{u}/orders/{o}").expect("matches");
387 assert_eq!(params.get("u").map(String::as_str), Some("42"));
388 assert_eq!(params.get("o").map(String::as_str), Some("7"));
389 assert!(match_template("/users/42/extra", "/users/{u}").is_none());
390 assert!(match_template("/users", "/users/{u}").is_none());
391 }
392
393 #[tokio::test]
394 async fn undeclared_response_status_records_response_shape() {
395 conformance_violations::clear();
396 let t = tap(false).await;
397
398 t.validate_response("GET", "/users/abc", 503, None).await;
401
402 let snap = conformance_violations::snapshot();
403 assert!(
404 snap.iter().any(|v| v.category == "response-shape"),
405 "undeclared status should record a response-shape violation"
406 );
407 }
408}