use std::env; use std::fs; use std::path::PathBuf; /// Convert OpenAPI 3.1 spec to 3.0 for progenitor compatibility fn convert_31_to_30(spec: &mut serde_json::Value) { // Change version from 3.1.x to 3.0.3 if let Some(obj) = spec.as_object_mut() { obj.insert("openapi".to_string(), serde_json::json!("3.0.3")); } // Recursively convert anyOf with null to nullable convert_anyof_to_nullable(spec); } /// Remove paths with multipart/form-data content type (not supported by progenitor) fn filter_multipart_endpoints(spec: &mut serde_json::Value) { if let Some(paths) = spec.get_mut("paths").and_then(|v| v.as_object_mut()) { let mut paths_to_remove = Vec::new(); for (path_name, path_item) in paths.iter() { if let Some(operations) = path_item.as_object() { for (_method, operation) in operations.iter() { if let Some(request_body) = operation.get("requestBody") { if let Some(content) = request_body.get("content") { if let Some(content_obj) = content.as_object() { if content_obj.contains_key("multipart/form-data") { eprintln!("Filtering out endpoint with multipart/form-data: {}", path_name); paths_to_remove.push(path_name.clone()); break; } } } } } } } // Remove the paths for path in paths_to_remove { paths.remove(&path); } } } fn convert_anyof_to_nullable(value: &mut serde_json::Value) { match value { serde_json::Value::Object(obj) => { // Check if this object has anyOf with null and process it let has_null_in_anyof = obj.get("anyOf") .and_then(|v| v.as_array()) .map(|array| { array.iter().any(|v| { v.get("type") .and_then(|t| t.as_str()) .map(|s| s == "null") .unwrap_or(false) }) }) .unwrap_or(false); if has_null_in_anyof { // Clone the anyOf array to avoid borrow issues if let Some(any_of) = obj.get("anyOf").cloned() { if let Some(array) = any_of.as_array() { let non_null_schemas: Vec<_> = array.iter().filter(|v| { v.get("type") .and_then(|t| t.as_str()) .map(|s| s != "null") .unwrap_or(true) }).cloned().collect(); obj.remove("anyOf"); if non_null_schemas.len() == 1 { // Single non-null type: inline it with nullable: true if let Some(non_null_obj) = non_null_schemas[0].as_object() { for (k, v) in non_null_obj.iter() { obj.insert(k.clone(), v.clone()); } } } else { // Multiple non-null types: keep anyOf with nulls removed obj.insert("anyOf".to_string(), serde_json::json!(non_null_schemas)); } obj.insert("nullable".to_string(), serde_json::json!(true)); } } } // Recursively process all values for (_key, val) in obj.iter_mut() { convert_anyof_to_nullable(val); } } serde_json::Value::Array(arr) => { for item in arr.iter_mut() { convert_anyof_to_nullable(item); } } _ => {} } } fn main() { // Get the OpenAPI spec path from hindsight-docs/static (single source of truth) let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap()); let openapi_path = manifest_dir .parent() .unwrap() .parent() .unwrap() .join("hindsight-docs") .join("static") .join("openapi.json"); // Tell Cargo to rebuild if the OpenAPI spec changes println!("cargo:rerun-if-changed={}", openapi_path.display()); // Read the OpenAPI spec let spec_content = fs::read_to_string(&openapi_path) .expect("Failed to read openapi.json. Make sure it exists in the project root."); // Parse as generic JSON first to convert 3.1 to 3.0 let mut spec_json: serde_json::Value = serde_json::from_str(&spec_content) .expect("Failed to parse openapi.json"); // Convert OpenAPI 3.1.0 to 3.0.3 for progenitor compatibility if let Some(version) = spec_json.get("openapi").and_then(|v| v.as_str()) { if version.starts_with("3.1") { eprintln!("Converting OpenAPI 3.1 to 3.0 for compatibility..."); convert_31_to_30(&mut spec_json); } } // Filter out multipart/form-data endpoints (progenitor doesn't support them) filter_multipart_endpoints(&mut spec_json); // Now parse as OpenAPI struct let spec: openapiv3::OpenAPI = serde_json::from_value(spec_json) .expect("Failed to parse converted OpenAPI spec"); // Generate the client let mut generator = progenitor::Generator::default(); // Generate code let tokens = generator.generate_tokens(&spec) .expect("Failed to generate client code from OpenAPI spec"); // Write to the output directory let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); let dest_path = out_dir.join("hindsight_client_generated.rs"); let syntax_tree = syn::parse2(tokens) .expect("Failed to parse generated tokens"); let mut formatted = prettyplease::unparse(&syntax_tree); // Fix progenitor bug with optional header parameters // The generated code tries to call .to_string() on Option<&str> which doesn't work // We need to unwrap the Option first formatted = fix_optional_header_params(&formatted); fs::write(&dest_path, formatted) .expect("Failed to write generated client code"); println!("Generated client at: {}", dest_path.display()); } /// Fix progenitor's generated code for optional header parameters /// Replaces patterns like `value.to_string().try_into()?` where value is Option<&str> /// with `value.unwrap_or_default().to_string().try_into()?` fn fix_optional_header_params(code: &str) -> String { use regex::Regex; // Pattern: header_map.append("authorization", value.to_string().try_into()?); // Should become: header_map.append("authorization", value.unwrap_or_default().to_string().try_into()?); let re = Regex::new(r#"header_map\.append\("authorization", value\.to_string\(\)\.try_into\(\)\?\)"#) .expect("Invalid regex"); re.replace_all(code, r#"header_map.append("authorization", value.unwrap_or_default().to_string().try_into()?)"#) .to_string() }