use super::types::{CorrectionProposal, Mismatch, PatchOperation, Recommendation};
use mockforge_openapi::OpenApiSpec;
use serde_json::{json, Value};
use std::collections::HashMap;
pub struct CorrectionProposer;
impl CorrectionProposer {
pub fn generate_proposals(
mismatches: &[Mismatch],
recommendations: &[Recommendation],
spec: &OpenApiSpec,
) -> Vec<CorrectionProposal> {
let mut proposals = Vec::new();
let mut rec_by_mismatch: HashMap<String, Vec<&Recommendation>> = HashMap::new();
for rec in recommendations {
rec_by_mismatch.entry(rec.mismatch_id.clone()).or_default().push(rec);
}
for (idx, mismatch) in mismatches.iter().enumerate() {
let mismatch_id = format!("mismatch_{}", idx);
let recommendations_for_mismatch =
rec_by_mismatch.get(&mismatch_id).map(|v| v.as_slice()).unwrap_or(&[]);
let proposals_for_mismatch =
Self::generate_proposals_for_mismatch(mismatch, recommendations_for_mismatch, spec);
proposals.extend(proposals_for_mismatch);
}
proposals
}
fn generate_proposals_for_mismatch(
mismatch: &Mismatch,
recommendations: &[&Recommendation],
spec: &OpenApiSpec,
) -> Vec<CorrectionProposal> {
let mut proposals = Vec::new();
match mismatch.mismatch_type {
super::types::MismatchType::MissingRequiredField => {
if let Some(proposal) = Self::propose_add_required_field(mismatch, spec) {
proposals.push(proposal);
}
}
super::types::MismatchType::TypeMismatch => {
if let Some(proposal) = Self::propose_fix_type(mismatch, spec) {
proposals.push(proposal);
}
}
super::types::MismatchType::UnexpectedField => {
if let Some(proposal) = Self::propose_remove_field(mismatch, spec) {
proposals.push(proposal);
} else if let Some(proposal) = Self::propose_add_optional_field(mismatch, spec) {
proposals.push(proposal);
}
}
super::types::MismatchType::FormatMismatch => {
if let Some(proposal) = Self::propose_add_format(mismatch, spec) {
proposals.push(proposal);
}
}
super::types::MismatchType::EndpointNotFound => {
if let Some(proposal) = Self::propose_add_endpoint(mismatch, spec) {
proposals.push(proposal);
}
}
_ => {
if let Some(proposal) = Self::propose_generic_fix(mismatch, recommendations, spec) {
proposals.push(proposal);
}
}
}
proposals
}
fn extract_field_from_spec(
spec: &OpenApiSpec,
request_path: &str,
method: Option<&str>,
field_name: &str,
) -> Value {
let path_item = spec.spec.paths.paths.get(request_path).and_then(|p| p.as_item());
let operation = path_item.and_then(|item| {
let m = method.unwrap_or("get");
match m.to_uppercase().as_str() {
"GET" => item.get.as_ref(),
"POST" => item.post.as_ref(),
"PUT" => item.put.as_ref(),
"DELETE" => item.delete.as_ref(),
"PATCH" => item.patch.as_ref(),
_ => None,
}
});
if let Some(op) = operation {
if let Some(openapiv3::ReferenceOr::Item(ref body_item)) = op.request_body {
if let Some(media) = body_item.content.get("application/json") {
if let Some(ref schema) = media.schema {
if let Ok(schema_val) = serde_json::to_value(schema) {
if let Some(field_val) =
schema_val.pointer(&format!("/item/properties/{}", field_name))
{
return field_val.clone();
}
if let Some(field_val) =
schema_val.pointer(&format!("/properties/{}", field_name))
{
return field_val.clone();
}
}
}
}
}
if let Some(openapiv3::ReferenceOr::Item(ref resp_item)) =
op.responses.responses.get(&openapiv3::StatusCode::Code(200))
{
if let Some(media) = resp_item.content.get("application/json") {
if let Some(ref schema) = media.schema {
if let Ok(schema_val) = serde_json::to_value(schema) {
if let Some(field_val) =
schema_val.pointer(&format!("/item/properties/{}", field_name))
{
return field_val.clone();
}
if let Some(field_val) =
schema_val.pointer(&format!("/properties/{}", field_name))
{
return field_val.clone();
}
}
}
}
}
}
json!(null)
}
fn propose_add_required_field(
mismatch: &Mismatch,
spec: &OpenApiSpec,
) -> Option<CorrectionProposal> {
let path_parts: Vec<&str> = mismatch.path.split('/').filter(|s| !s.is_empty()).collect();
if path_parts.is_empty() {
return None;
}
let field_name = path_parts.last().unwrap();
let expected_type = mismatch.expected.as_ref()?;
let patch_path = Self::build_patch_path(&mismatch.path, mismatch.method.as_deref());
let field_schema = match expected_type.as_str() {
"string" => json!({
"type": "string"
}),
"integer" => json!({
"type": "integer"
}),
"number" => json!({
"type": "number"
}),
"boolean" => json!({
"type": "boolean"
}),
"array" => json!({
"type": "array",
"items": {}
}),
"object" => json!({
"type": "object",
"properties": {}
}),
_ => json!({
"type": "string"
}),
};
let before = Self::extract_field_from_spec(
spec,
&mismatch.path,
mismatch.method.as_deref(),
field_name,
);
let after = field_schema.clone();
Some(CorrectionProposal {
id: format!("correction_{}", mismatch.path.replace('/', "_")),
patch_path: format!("{}/properties/{}", patch_path, field_name),
operation: PatchOperation::Add,
value: Some(field_schema),
from: None,
confidence: mismatch.confidence,
description: format!(
"Add required field '{}' of type '{}' to schema",
field_name, expected_type
),
reasoning: Some(format!(
"Front-end consistently sends '{}' but it's not defined in the contract",
field_name
)),
affected_endpoints: mismatch
.method
.as_ref()
.map(|m| vec![format!("{} {}", m, mismatch.path)])
.unwrap_or_default(),
before: Some(before),
after: Some(after),
})
}
fn propose_fix_type(mismatch: &Mismatch, _spec: &OpenApiSpec) -> Option<CorrectionProposal> {
let expected_type = mismatch.expected.as_ref()?;
let actual_type = mismatch.actual.as_ref()?;
let patch_path = Self::build_patch_path(&mismatch.path, mismatch.method.as_deref());
let new_type_schema = match expected_type.as_str() {
"string" => json!({"type": "string"}),
"integer" => json!({"type": "integer"}),
"number" => json!({"type": "number"}),
"boolean" => json!({"type": "boolean"}),
"array" => json!({"type": "array", "items": {}}),
"object" => json!({"type": "object", "properties": {}}),
_ => json!({"type": "string"}),
};
let before = json!({"type": actual_type});
let after = new_type_schema.clone();
Some(CorrectionProposal {
id: format!("correction_type_{}", mismatch.path.replace('/', "_")),
patch_path: format!("{}/type", patch_path),
operation: PatchOperation::Replace,
value: Some(Value::String(expected_type.clone())),
from: None,
confidence: mismatch.confidence,
description: format!("Change field type from '{}' to '{}'", actual_type, expected_type),
reasoning: Some(format!(
"Front-end sends '{}' as '{}' but contract expects '{}'",
mismatch.path, actual_type, expected_type
)),
affected_endpoints: mismatch
.method
.as_ref()
.map(|m| vec![format!("{} {}", m, mismatch.path)])
.unwrap_or_default(),
before: Some(before),
after: Some(after),
})
}
fn propose_remove_field(
mismatch: &Mismatch,
_spec: &OpenApiSpec,
) -> Option<CorrectionProposal> {
let patch_path = Self::build_patch_path(&mismatch.path, mismatch.method.as_deref());
Some(CorrectionProposal {
id: format!("correction_remove_{}", mismatch.path.replace('/', "_")),
patch_path: patch_path.clone(),
operation: PatchOperation::Remove,
value: None,
from: None,
confidence: mismatch.confidence * 0.8, description: format!("Remove unexpected field '{}' from request", mismatch.path),
reasoning: Some(format!(
"Field '{}' is sent by front-end but not defined in contract",
mismatch.path
)),
affected_endpoints: mismatch
.method
.as_ref()
.map(|m| vec![format!("{} {}", m, mismatch.path)])
.unwrap_or_default(),
before: Some(json!({"field": mismatch.path})),
after: Some(json!(null)),
})
}
fn propose_add_optional_field(
mismatch: &Mismatch,
spec: &OpenApiSpec,
) -> Option<CorrectionProposal> {
Self::propose_add_required_field(mismatch, spec).map(|mut proposal| {
proposal.operation = PatchOperation::Add;
proposal.confidence = mismatch.confidence * 0.7; proposal.description = format!(
"Add optional field '{}' to schema (alternative to removing from request)",
mismatch.path
);
proposal.reasoning = Some(format!(
"Front-end sends '{}' but it's not in contract. Consider adding as optional field.",
mismatch.path
));
proposal
})
}
fn propose_add_format(mismatch: &Mismatch, _spec: &OpenApiSpec) -> Option<CorrectionProposal> {
let format_value =
mismatch.context.get("format").and_then(|v| v.as_str()).map(|s| s.to_string());
let format_value_clone = format_value.clone();
format_value.as_ref()?;
let patch_path = Self::build_patch_path(&mismatch.path, mismatch.method.as_deref());
Some(CorrectionProposal {
id: format!("correction_format_{}", mismatch.path.replace('/', "_")),
patch_path: format!("{}/format", patch_path),
operation: PatchOperation::Add,
value: Some(Value::String(format_value.unwrap())),
from: None,
confidence: mismatch.confidence,
description: format!("Add format constraint to field '{}'", mismatch.path),
reasoning: Some(format!("Field '{}' should have format validation", mismatch.path)),
affected_endpoints: mismatch
.method
.as_ref()
.map(|m| vec![format!("{} {}", m, mismatch.path)])
.unwrap_or_default(),
before: Some(json!({"format": null})),
after: Some(json!({"format": format_value_clone})),
})
}
fn propose_add_endpoint(
mismatch: &Mismatch,
_spec: &OpenApiSpec,
) -> Option<CorrectionProposal> {
let method = mismatch.method.as_ref()?;
let path = &mismatch.path;
let patch_path = format!("/paths/{}", Self::escape_json_pointer(path));
let endpoint_schema = json!({
method.to_lowercase(): {
"summary": format!("{} {}", method, path),
"responses": {
"200": {
"description": "Success"
}
}
}
});
let endpoint_schema_clone = endpoint_schema.clone();
Some(CorrectionProposal {
id: format!("correction_endpoint_{}_{}", method, path.replace('/', "_")),
patch_path,
operation: PatchOperation::Add,
value: Some(endpoint_schema),
from: None,
confidence: mismatch.confidence,
description: format!("Add endpoint {} {} to contract", method, path),
reasoning: Some(format!(
"Front-end calls {} {} but endpoint is not defined in contract",
method, path
)),
affected_endpoints: vec![format!("{} {}", method, path)],
before: Some(json!(null)),
after: Some(endpoint_schema_clone),
})
}
fn propose_generic_fix(
mismatch: &Mismatch,
recommendations: &[&Recommendation],
_spec: &OpenApiSpec,
) -> Option<CorrectionProposal> {
let recommendation = recommendations.first()?;
let patch_path = Self::build_patch_path(&mismatch.path, mismatch.method.as_deref());
Some(CorrectionProposal {
id: format!("correction_generic_{}", mismatch.path.replace('/', "_")),
patch_path,
operation: PatchOperation::Replace,
value: recommendation.example.clone(),
from: None,
confidence: recommendation.confidence,
description: recommendation.recommendation.clone(),
reasoning: recommendation.reasoning.clone(),
affected_endpoints: mismatch
.method
.as_ref()
.map(|m| vec![format!("{} {}", m, mismatch.path)])
.unwrap_or_default(),
before: None,
after: recommendation.example.clone(),
})
}
fn build_patch_path(request_path: &str, method: Option<&str>) -> String {
let escaped_path = Self::escape_json_pointer(request_path);
if let Some(m) = method {
format!(
"/paths/{}/{}/requestBody/content/application~1json/schema",
escaped_path,
m.to_lowercase()
)
} else {
format!("/paths/{}/schema", escaped_path)
}
}
fn escape_json_pointer(path: &str) -> String {
path.replace('~', "~0").replace('/', "~1")
}
pub fn generate_patch_file(proposals: &[CorrectionProposal], spec_version: &str) -> Value {
let patch_operations: Vec<Value> = proposals
.iter()
.map(|proposal| {
let mut op = json!({
"op": format!("{:?}", proposal.operation).to_lowercase(),
"path": proposal.patch_path,
});
match proposal.operation {
PatchOperation::Add | PatchOperation::Replace => {
if let Some(value) = &proposal.value {
op["value"] = value.clone();
}
}
PatchOperation::Move | PatchOperation::Copy => {
if let Some(from) = &proposal.from {
op["from"] = json!(from);
}
}
_ => {}
}
op["metadata"] = json!({
"id": proposal.id,
"confidence": proposal.confidence,
"description": proposal.description,
"reasoning": proposal.reasoning,
"affected_endpoints": proposal.affected_endpoints,
});
op
})
.collect();
json!({
"format": "json-patch",
"spec_version": spec_version,
"operations": patch_operations,
"metadata": {
"generated_at": chrono::Utc::now().to_rfc3339(),
"total_corrections": proposals.len(),
"average_confidence": proposals.iter().map(|p| p.confidence).sum::<f64>() / proposals.len() as f64,
}
})
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_extract_field_from_spec_no_match() {
let spec = OpenApiSpec {
spec: openapiv3::OpenAPI::default(),
file_path: None,
raw_document: None,
};
let result =
CorrectionProposer::extract_field_from_spec(&spec, "/api/users", Some("POST"), "name");
assert_eq!(result, json!(null));
}
#[test]
fn test_escape_json_pointer() {
assert_eq!(CorrectionProposer::escape_json_pointer("/api/users"), "~1api~1users");
assert_eq!(CorrectionProposer::escape_json_pointer("~test"), "~0test");
}
#[test]
fn test_build_patch_path() {
let path = CorrectionProposer::build_patch_path("/api/users", Some("POST"));
assert!(path.contains("~1api~1users"));
assert!(path.contains("post"));
}
}