mockforge_intelligence/ai_contract_diff/
diff_analyzer.rs1use super::types::{
7 CapturedRequest, ContractDiffResult, DiffMetadata, Mismatch, MismatchSeverity, MismatchType,
8};
9use mockforge_foundation::schema_diff::validation_diff;
10use mockforge_foundation::Result;
11use mockforge_openapi::OpenApiSpec;
12use serde_json::Value;
13use std::collections::HashMap;
14
15fn path_matches_with_params(pattern: &str, path: &str) -> bool {
21 let pattern_parts: Vec<&str> = pattern.split('/').filter(|s| !s.is_empty()).collect();
22 let path_parts: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
23
24 if pattern_parts.len() != path_parts.len() {
25 return false;
26 }
27
28 for (pattern_part, path_part) in pattern_parts.iter().zip(path_parts.iter()) {
29 if pattern_part.starts_with('{') && pattern_part.ends_with('}') {
31 continue;
33 }
34
35 if pattern_part != path_part {
36 return false;
37 }
38 }
39
40 true
41}
42
43pub struct DiffAnalyzer {
45 config: super::types::ContractDiffConfig,
47}
48
49impl DiffAnalyzer {
50 pub fn new(config: super::types::ContractDiffConfig) -> Self {
52 Self { config }
53 }
54
55 pub async fn analyze_request(
57 &self,
58 request: &CapturedRequest,
59 spec: &OpenApiSpec,
60 ) -> Result<ContractDiffResult> {
61 let mut mismatches = Vec::new();
62
63 let endpoint_match = self.find_endpoint_in_spec(&request.path, &request.method, spec);
65
66 if endpoint_match.is_none() {
68 mismatches.push(Mismatch {
69 mismatch_type: MismatchType::EndpointNotFound,
70 path: request.path.clone(),
71 method: Some(request.method.clone()),
72 expected: Some("Endpoint defined in OpenAPI spec".to_string()),
73 actual: Some("Endpoint not found in spec".to_string()),
74 description: format!(
75 "Endpoint {} {} not found in contract specification",
76 request.method, request.path
77 ),
78 severity: MismatchSeverity::Critical,
79 confidence: 1.0, context: HashMap::new(),
81 });
82 }
83
84 if let Some(body) = &request.body {
86 if let Some(endpoint) = &endpoint_match {
87 let body_mismatches =
88 self.analyze_request_body(body, endpoint, &request.path, spec)?;
89 mismatches.extend(body_mismatches);
90 }
91 }
92
93 let header_mismatches = self.analyze_headers(&request.headers, endpoint_match.as_ref());
95 mismatches.extend(header_mismatches);
96
97 let query_mismatches = self.analyze_query_params(
99 &request.query_params,
100 endpoint_match.as_ref(),
101 &request.path,
102 );
103 mismatches.extend(query_mismatches);
104
105 let overall_confidence =
107 super::confidence_scorer::ConfidenceScorer::calculate_overall_confidence(&mismatches);
108
109 let metadata = DiffMetadata {
111 analyzed_at: chrono::Utc::now(),
112 request_source: request.source.clone(),
113 contract_version: spec.spec.info.version.clone().into(),
114 contract_format: "openapi-3.0".to_string(), endpoint_path: request.path.clone(),
116 http_method: request.method.clone(),
117 request_count: 1,
118 llm_provider: Some(self.config.llm_provider.clone()),
119 llm_model: Some(self.config.llm_model.clone()),
120 };
121
122 Ok(ContractDiffResult {
123 matches: mismatches.is_empty(),
124 confidence: overall_confidence,
125 mismatches,
126 recommendations: Vec::new(), corrections: Vec::new(), metadata,
129 })
130 }
131
132 fn find_endpoint_in_spec(
134 &self,
135 path: &str,
136 method: &str,
137 spec: &OpenApiSpec,
138 ) -> Option<openapiv3::Operation> {
139 let normalized_path = path.split('?').next().unwrap_or(path).trim_end_matches('/');
141
142 for (spec_path, path_item_ref) in &spec.spec.paths.paths {
144 let spec_path_normalized = spec_path.trim_end_matches('/');
145
146 if spec_path_normalized == normalized_path {
147 if let openapiv3::ReferenceOr::Item(path_item) = path_item_ref {
148 return match method.to_uppercase().as_str() {
149 "GET" => path_item.get.clone(),
150 "POST" => path_item.post.clone(),
151 "PUT" => path_item.put.clone(),
152 "DELETE" => path_item.delete.clone(),
153 "PATCH" => path_item.patch.clone(),
154 _ => None,
155 };
156 }
157 }
158 }
159
160 for (spec_path, path_item_ref) in &spec.spec.paths.paths {
162 let spec_path_normalized = spec_path.trim_end_matches('/');
163
164 if path_matches_with_params(spec_path_normalized, normalized_path) {
165 if let openapiv3::ReferenceOr::Item(path_item) = path_item_ref {
166 return match method.to_uppercase().as_str() {
167 "GET" => path_item.get.clone(),
168 "POST" => path_item.post.clone(),
169 "PUT" => path_item.put.clone(),
170 "DELETE" => path_item.delete.clone(),
171 "PATCH" => path_item.patch.clone(),
172 _ => None,
173 };
174 }
175 }
176 }
177
178 None
179 }
180
181 fn analyze_request_body(
183 &self,
184 body: &Value,
185 operation: &openapiv3::Operation,
186 path: &str,
187 spec: &OpenApiSpec,
188 ) -> Result<Vec<Mismatch>> {
189 let mut mismatches = Vec::new();
190
191 if let Some(openapiv3::ReferenceOr::Item(request_body)) = &operation.request_body {
193 if let Some(content) = request_body.content.get("application/json") {
195 if let Some(schema_ref) = &content.schema {
196 let schema_value = self.openapi_schema_to_json(schema_ref, spec)?;
198
199 let validation_errors = validation_diff(&schema_value, body);
201
202 for error in &validation_errors {
204 let mismatch_type = match error.error_type.as_str() {
205 "missing_required" => MismatchType::MissingRequiredField,
206 "type_mismatch" => MismatchType::TypeMismatch,
207 "additional_property" => MismatchType::UnexpectedField,
208 "length_mismatch" => MismatchType::ConstraintViolation,
209 _ => MismatchType::SchemaMismatch,
210 };
211
212 let severity = match mismatch_type {
213 MismatchType::MissingRequiredField => MismatchSeverity::Critical,
214 MismatchType::TypeMismatch => MismatchSeverity::High,
215 MismatchType::UnexpectedField => MismatchSeverity::Low,
216 _ => MismatchSeverity::Medium,
217 };
218
219 mismatches.push(Mismatch {
220 mismatch_type,
221 path: format!("{}{}", path, error.path),
222 method: None,
223 expected: Some(error.expected.clone()),
224 actual: Some(error.found.clone()),
225 description: error.message.clone().unwrap_or_else(|| {
226 format!("Validation error: {}", error.error_type)
227 }),
228 severity,
229 confidence: 0.9, context: error
231 .schema_info
232 .as_ref()
233 .map(|info| {
234 let mut ctx = HashMap::new();
235 ctx.insert(
236 "data_type".to_string(),
237 Value::String(info.data_type.clone()),
238 );
239 if let Some(required) = info.required {
240 ctx.insert("required".to_string(), Value::Bool(required));
241 }
242 if let Some(format) = &info.format {
243 ctx.insert(
244 "format".to_string(),
245 Value::String(format.clone()),
246 );
247 }
248 ctx
249 })
250 .unwrap_or_default(),
251 });
252 }
253 }
254 }
255 }
256
257 Ok(mismatches)
258 }
259
260 fn analyze_headers(
262 &self,
263 headers: &HashMap<String, String>,
264 operation: Option<&openapiv3::Operation>,
265 ) -> Vec<Mismatch> {
266 let mut mismatches = Vec::new();
267
268 if let Some(op) = operation {
269 if let Some(security) = &op.security {
271 for sec_req in security {
272 for (name, _) in sec_req {
275 let header_name_lower = name.to_lowercase();
276 let found =
277 headers.iter().any(|(k, _)| k.to_lowercase() == header_name_lower);
278
279 if !found {
280 mismatches.push(Mismatch {
281 mismatch_type: MismatchType::HeaderMismatch,
282 path: "headers".to_string(),
283 method: None,
284 expected: Some(format!("Header: {}", name)),
285 actual: Some("Header missing".to_string()),
286 description: format!(
287 "Required security header '{}' is missing",
288 name
289 ),
290 severity: MismatchSeverity::High,
291 confidence: 1.0,
292 context: HashMap::new(),
293 });
294 }
295 }
296 }
297 }
298 }
299
300 mismatches
301 }
302
303 fn analyze_query_params(
305 &self,
306 query_params: &HashMap<String, String>,
307 operation: Option<&openapiv3::Operation>,
308 path: &str,
309 ) -> Vec<Mismatch> {
310 let mut mismatches = Vec::new();
311
312 if let Some(op) = operation {
313 for param in &op.parameters {
315 if let openapiv3::ReferenceOr::Item(openapiv3::Parameter::Query {
316 parameter_data,
317 ..
318 }) = param
319 {
320 let param_name = ¶meter_data.name;
321 let required = parameter_data.required;
322
323 let found = query_params.contains_key(param_name);
324
325 if required && !found {
326 mismatches.push(Mismatch {
327 mismatch_type: MismatchType::QueryParamMismatch,
328 path: format!("{}?{}", path, param_name),
329 method: None,
330 expected: Some(format!("Required query parameter: {}", param_name)),
331 actual: Some("Parameter missing".to_string()),
332 description: format!(
333 "Required query parameter '{}' is missing",
334 param_name
335 ),
336 severity: MismatchSeverity::High,
337 confidence: 1.0,
338 context: HashMap::new(),
339 });
340 }
341 }
342 }
343 }
344
345 mismatches
346 }
347
348 fn openapi_schema_to_json(
352 &self,
353 schema: &openapiv3::ReferenceOr<openapiv3::Schema>,
354 spec: &OpenApiSpec,
355 ) -> Result<Value> {
356 match schema {
357 openapiv3::ReferenceOr::Item(schema) => {
358 self.openapi_schema_to_json_from_schema(schema, spec)
359 }
360 openapiv3::ReferenceOr::Reference { reference } => {
361 if let Some(resolved_schema) = spec.resolve_schema_ref(reference) {
363 self.openapi_schema_to_json_from_schema(&resolved_schema, spec)
364 } else {
365 tracing::warn!("Could not resolve schema reference: {}", reference);
367 Ok(Value::Object(serde_json::Map::new()))
368 }
369 }
370 }
371 }
372
373 #[allow(clippy::only_used_in_recursion)]
377 fn openapi_schema_to_json_from_schema(
378 &self,
379 schema: &openapiv3::Schema,
380 spec: &OpenApiSpec,
381 ) -> Result<Value> {
382 let mut json_schema = serde_json::Map::new();
383
384 match &schema.schema_kind {
386 openapiv3::SchemaKind::Type(openapiv3::Type::String(_)) => {
387 json_schema.insert("type".to_string(), Value::String("string".to_string()));
388 }
389 openapiv3::SchemaKind::Type(openapiv3::Type::Number(_)) => {
390 json_schema.insert("type".to_string(), Value::String("number".to_string()));
391 }
392 openapiv3::SchemaKind::Type(openapiv3::Type::Integer(_)) => {
393 json_schema.insert("type".to_string(), Value::String("integer".to_string()));
394 }
395 openapiv3::SchemaKind::Type(openapiv3::Type::Boolean(_)) => {
396 json_schema.insert("type".to_string(), Value::String("boolean".to_string()));
397 }
398 openapiv3::SchemaKind::Type(openapiv3::Type::Array(array_type)) => {
399 json_schema.insert("type".to_string(), Value::String("array".to_string()));
400 if let Some(items) = &array_type.items {
402 let items_json = match items {
403 openapiv3::ReferenceOr::Item(item_schema) => {
404 self.openapi_schema_to_json_from_schema(item_schema, spec)?
405 }
406 openapiv3::ReferenceOr::Reference { reference } => {
407 if let Some(resolved) = spec.resolve_schema_ref(reference.as_str()) {
409 self.openapi_schema_to_json_from_schema(&resolved, spec)?
410 } else {
411 tracing::warn!(
412 "Could not resolve array item reference: {}",
413 reference
414 );
415 Value::Object(serde_json::Map::new())
416 }
417 }
418 };
419 json_schema.insert("items".to_string(), items_json);
420 }
421 }
422 openapiv3::SchemaKind::Type(openapiv3::Type::Object(_)) => {
423 json_schema.insert("type".to_string(), Value::String("object".to_string()));
424 }
425 _ => {}
426 }
427
428 if let openapiv3::SchemaKind::Type(openapiv3::Type::Object(obj_type)) = &schema.schema_kind
430 {
431 let mut props = serde_json::Map::new();
432 for (name, prop_schema_ref) in &obj_type.properties {
433 let prop_json = match prop_schema_ref {
435 openapiv3::ReferenceOr::Item(boxed_schema) => {
436 self.openapi_schema_to_json_from_schema(boxed_schema.as_ref(), spec)
438 }
439 openapiv3::ReferenceOr::Reference { reference } => {
440 if let Some(resolved_schema) = spec.resolve_schema_ref(reference) {
442 self.openapi_schema_to_json_from_schema(&resolved_schema, spec)
443 } else {
444 tracing::debug!(
445 "Could not resolve property reference for '{}': {}",
446 name,
447 reference
448 );
449 Ok(Value::Object(serde_json::Map::new()))
450 }
451 }
452 };
453 if let Ok(prop_json) = prop_json {
454 props.insert(name.clone(), prop_json);
455 }
456 }
457 if !props.is_empty() {
458 json_schema.insert("properties".to_string(), Value::Object(props));
459 }
460
461 if !obj_type.required.is_empty() {
463 let required_array: Vec<Value> =
464 obj_type.required.iter().map(|s| Value::String(s.clone())).collect();
465 json_schema.insert("required".to_string(), Value::Array(required_array));
466 }
467 }
468
469 Ok(Value::Object(json_schema))
470 }
471}
472
473#[cfg(test)]
474mod tests {
475 use super::*;
476
477 #[test]
478 fn test_diff_analyzer_creation() {
479 let config = crate::ai_contract_diff::ContractDiffConfig::default();
480 let _analyzer = DiffAnalyzer::new(config);
481 }
483}