Skip to main content

ballistics_engine/
api_client.rs

1//! HTTP client for Flask API communication
2//!
3//! This module provides an HTTP client for routing trajectory calculations
4//! through the Flask API instead of local computation, giving CLI users
5//! access to ML-enhanced predictions.
6
7use serde::{Deserialize, Serialize};
8use std::time::Duration;
9
10/// Request structure for trajectory calculation via Flask API
11#[derive(Debug, Clone, Serialize)]
12pub struct TrajectoryRequest {
13    /// Ballistic coefficient value
14    pub bc_value: f64,
15    /// BC type: "G1" or "G7"
16    pub bc_type: String,
17    /// Bullet mass in grams
18    pub bullet_mass: f64,
19    /// Muzzle velocity in m/s
20    pub muzzle_velocity: f64,
21    /// Target distance in meters
22    pub target_distance: f64,
23    /// Zero range in meters (optional)
24    #[serde(skip_serializing_if = "Option::is_none")]
25    pub zero_range: Option<f64>,
26    /// Wind speed in m/s (optional)
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub wind_speed: Option<f64>,
29    /// Wind angle in degrees (optional)
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub wind_angle: Option<f64>,
32    /// Temperature in Celsius (optional)
33    #[serde(skip_serializing_if = "Option::is_none")]
34    pub temperature: Option<f64>,
35    /// Pressure in hPa/mbar (optional)
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub pressure: Option<f64>,
38    /// Humidity percentage 0-100 (optional)
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub humidity: Option<f64>,
41    /// Altitude in meters (optional)
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub altitude: Option<f64>,
44    /// Latitude for Coriolis calculations (optional)
45    #[serde(skip_serializing_if = "Option::is_none")]
46    pub latitude: Option<f64>,
47    /// Longitude for weather zones (optional)
48    #[serde(skip_serializing_if = "Option::is_none")]
49    pub longitude: Option<f64>,
50    /// Shot direction/azimuth in degrees (optional, 0=North, 90=East)
51    #[serde(skip_serializing_if = "Option::is_none")]
52    pub shot_direction: Option<f64>,
53    /// Shooting angle in degrees (optional)
54    #[serde(skip_serializing_if = "Option::is_none")]
55    pub shooting_angle: Option<f64>,
56    /// Barrel twist rate in inches per turn (optional)
57    #[serde(skip_serializing_if = "Option::is_none")]
58    pub twist_rate: Option<f64>,
59    /// Bullet diameter in meters (optional)
60    #[serde(skip_serializing_if = "Option::is_none")]
61    pub bullet_diameter: Option<f64>,
62    /// Bullet length in meters (optional)
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub bullet_length: Option<f64>,
65    /// Ground threshold in meters (optional, negative = ignore ground impact)
66    #[serde(skip_serializing_if = "Option::is_none")]
67    pub ground_threshold: Option<f64>,
68    /// Enable weather zones (optional)
69    #[serde(skip_serializing_if = "Option::is_none")]
70    pub enable_weather_zones: Option<bool>,
71    /// Enable 3D weather corrections (optional)
72    #[serde(skip_serializing_if = "Option::is_none")]
73    pub enable_3d_weather: Option<bool>,
74    /// Wind shear model (optional: none, logarithmic, power_law, ekman_spiral)
75    #[serde(skip_serializing_if = "Option::is_none")]
76    pub wind_shear_model: Option<String>,
77    /// Weather zone interpolation method (optional: linear, cubic, step)
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub weather_zone_interpolation: Option<String>,
80    /// Sample/trajectory step interval in meters (optional)
81    /// Will be converted to yards for API as trajectory_step
82    #[serde(skip_serializing_if = "Option::is_none")]
83    pub sample_interval: Option<f64>,
84}
85
86/// Response structure from Flask API trajectory calculation
87#[derive(Debug, Clone, Deserialize)]
88pub struct TrajectoryResponse {
89    /// Array of trajectory points
90    pub trajectory: Vec<ApiTrajectoryPoint>,
91    /// Zero angle in radians
92    pub zero_angle: f64,
93    /// Total time of flight in seconds
94    pub time_of_flight: f64,
95    /// BC confidence score (0-1) if available
96    #[serde(default)]
97    pub bc_confidence: Option<f64>,
98    /// List of ML corrections applied
99    #[serde(default)]
100    pub ml_corrections_applied: Option<Vec<String>>,
101    /// Maximum ordinate (height) in meters
102    #[serde(default)]
103    pub max_ordinate: Option<f64>,
104    /// Impact velocity in m/s
105    #[serde(default)]
106    pub impact_velocity: Option<f64>,
107    /// Impact energy in Joules
108    #[serde(default)]
109    pub impact_energy: Option<f64>,
110}
111
112/// A single point in the trajectory from API response
113#[derive(Debug, Clone, Deserialize)]
114pub struct ApiTrajectoryPoint {
115    /// Range/distance in meters
116    pub range: f64,
117    /// Drop below line of sight in meters (negative = below)
118    pub drop: f64,
119    /// Wind drift in meters
120    pub drift: f64,
121    /// Velocity at this point in m/s
122    pub velocity: f64,
123    /// Kinetic energy at this point in Joules
124    pub energy: f64,
125    /// Time of flight to this point in seconds
126    pub time: f64,
127}
128
129/// Error types for API communication
130#[derive(Debug)]
131pub enum ApiError {
132    /// Network connectivity error
133    NetworkError(String),
134    /// Request timed out
135    Timeout,
136    /// Invalid or unparseable response
137    InvalidResponse(String),
138    /// HTTP error from server (status code, message)
139    ServerError(u16, String),
140    /// Request building error
141    RequestError(String),
142}
143
144impl std::fmt::Display for ApiError {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        match self {
147            ApiError::NetworkError(msg) => write!(f, "Network error: {}", msg),
148            ApiError::Timeout => write!(f, "Request timed out"),
149            ApiError::InvalidResponse(msg) => write!(f, "Invalid response: {}", msg),
150            ApiError::ServerError(code, msg) => write!(f, "Server error {}: {}", code, msg),
151            ApiError::RequestError(msg) => write!(f, "Request error: {}", msg),
152        }
153    }
154}
155
156impl std::error::Error for ApiError {}
157
158/// HTTP client for Flask API communication
159pub struct ApiClient {
160    base_url: String,
161    timeout: Duration,
162}
163
164impl ApiClient {
165    /// Create a new API client
166    ///
167    /// # Arguments
168    /// * `base_url` - Base URL of the Flask API (e.g., "https://api.ballistics.7.62x51mm.sh")
169    /// * `timeout_secs` - Request timeout in seconds
170    pub fn new(base_url: &str, timeout_secs: u64) -> Self {
171        // Normalize URL by removing trailing slash
172        let base_url = base_url.trim_end_matches('/').to_string();
173
174        Self {
175            base_url,
176            timeout: Duration::from_secs(timeout_secs),
177        }
178    }
179
180    /// Calculate trajectory via Flask API
181    ///
182    /// # Arguments
183    /// * `request` - Trajectory calculation request parameters
184    ///
185    /// # Returns
186    /// * `Ok(TrajectoryResponse)` - Successful calculation with trajectory data
187    /// * `Err(ApiError)` - Error during API communication
188    #[cfg(feature = "online")]
189    pub fn calculate_trajectory(
190        &self,
191        request: &TrajectoryRequest,
192    ) -> Result<TrajectoryResponse, ApiError> {
193        // Flask API uses GET /v1/calculate with query parameters (imperial units)
194        let url = format!("{}/v1/calculate", self.base_url);
195
196        // Convert metric values to imperial for API
197        let velocity_fps = request.muzzle_velocity / 0.3048; // m/s to fps
198        let mass_grains = request.bullet_mass / 0.0647989; // grams to grains
199        let distance_yards = request.target_distance / 0.9144; // meters to yards
200
201        let mut req = ureq::get(&url)
202            .set("Accept", "application/json")
203            .set("User-Agent", "ballistics-cli/0.13.31")
204            .timeout(self.timeout)
205            .query("bc_value", &request.bc_value.to_string())
206            .query("bc_type", &request.bc_type)
207            .query("bullet_mass", &format!("{:.1}", mass_grains))
208            .query("muzzle_velocity", &format!("{:.1}", velocity_fps))
209            .query("target_distance", &format!("{:.1}", distance_yards));
210
211        // Add optional parameters
212        if let Some(zero_range) = request.zero_range {
213            let zero_yards = zero_range / 0.9144;
214            req = req.query("zero_distance", &format!("{:.1}", zero_yards));
215        }
216        if let Some(wind_speed) = request.wind_speed {
217            let wind_mph = wind_speed * 2.23694; // m/s to mph
218            req = req.query("wind_speed", &format!("{:.1}", wind_mph));
219        }
220        if let Some(wind_angle) = request.wind_angle {
221            req = req.query("wind_angle", &format!("{:.1}", wind_angle));
222        }
223        if let Some(temp) = request.temperature {
224            let temp_f = temp * 9.0 / 5.0 + 32.0; // Celsius to Fahrenheit
225            req = req.query("temperature", &format!("{:.1}", temp_f));
226        }
227        if let Some(pressure) = request.pressure {
228            let pressure_inhg = pressure / 33.8639; // hPa to inHg
229            req = req.query("pressure", &format!("{:.2}", pressure_inhg));
230        }
231        if let Some(humidity) = request.humidity {
232            req = req.query("humidity", &format!("{:.1}", humidity));
233        }
234        if let Some(altitude) = request.altitude {
235            let altitude_ft = altitude / 0.3048; // meters to feet
236            req = req.query("altitude", &format!("{:.1}", altitude_ft));
237        }
238        if let Some(shooting_angle) = request.shooting_angle {
239            req = req.query("shooting_angle", &format!("{:.1}", shooting_angle));
240        }
241        if let Some(latitude) = request.latitude {
242            req = req.query("latitude", &format!("{:.2}", latitude));
243        }
244        if let Some(longitude) = request.longitude {
245            req = req.query("longitude", &format!("{:.2}", longitude));
246        }
247        if let Some(shot_direction) = request.shot_direction {
248            req = req.query("shot_direction", &format!("{:.1}", shot_direction));
249        }
250        if let Some(twist_rate) = request.twist_rate {
251            req = req.query("twist_rate", &format!("{:.1}", twist_rate));
252        }
253        if let Some(diameter) = request.bullet_diameter {
254            let diameter_in = diameter / 0.0254; // meters to inches
255            req = req.query("bullet_diameter", &format!("{:.3}", diameter_in));
256        }
257        if let Some(threshold) = request.ground_threshold {
258            // Ground threshold is in meters - API expects meters (will be converted by parse_ballistic_inputs)
259            // Use a very large negative value when ignoring ground impact
260            req = req.query("ground_threshold", &format!("{:.1}", threshold));
261        }
262        if let Some(enable) = request.enable_weather_zones {
263            req = req.query("enable_weather_zones", if enable { "true" } else { "false" });
264        }
265        if let Some(enable) = request.enable_3d_weather {
266            req = req.query("enable_3d_weather", if enable { "true" } else { "false" });
267        }
268        if let Some(ref model) = request.wind_shear_model {
269            req = req.query("wind_shear_model", model);
270        }
271        if let Some(ref method) = request.weather_zone_interpolation {
272            req = req.query("weather_zone_interpolation", method);
273        }
274        if let Some(sample_interval) = request.sample_interval {
275            // Convert from meters to yards for the Flask API (trajectory_step is in yards)
276            let step_yards = sample_interval / 0.9144;
277            req = req.query("trajectory_step", &format!("{:.4}", step_yards));
278        }
279
280        let response = req.call().map_err(|e| match e {
281            ureq::Error::Status(code, response) => {
282                let body = response.into_string().unwrap_or_default();
283                ApiError::ServerError(code, body)
284            }
285            ureq::Error::Transport(transport) => {
286                // Check for timeout by looking at the error message
287                let msg = transport.to_string();
288                if msg.contains("timed out") || msg.contains("timeout") {
289                    ApiError::Timeout
290                } else {
291                    ApiError::NetworkError(msg)
292                }
293            }
294        })?;
295
296        let body = response
297            .into_string()
298            .map_err(|e| ApiError::InvalidResponse(e.to_string()))?;
299
300        // Parse the Flask API response and convert to our format
301        let api_response: serde_json::Value = serde_json::from_str(&body)
302            .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))?;
303
304        // Convert Flask API response to our TrajectoryResponse format
305        self.convert_api_response(&api_response)
306    }
307
308    /// Helper to extract a value from a nested {value: x, unit: y} structure or plain number
309    #[cfg(feature = "online")]
310    fn extract_value(val: &serde_json::Value) -> Option<f64> {
311        // Try nested {value: x} first, then plain number
312        val.get("value")
313            .and_then(|v| v.as_f64())
314            .or_else(|| val.as_f64())
315    }
316
317    #[cfg(feature = "online")]
318    fn convert_api_response(&self, api_response: &serde_json::Value) -> Result<TrajectoryResponse, ApiError> {
319        // Get results object
320        let results = api_response.get("results");
321
322        // Extract trajectory points from Flask API response
323        // The Flask API returns trajectory in "trajectory" array with nested value objects
324        let trajectory_array = api_response.get("trajectory")
325            .and_then(|t| t.as_array())
326            .ok_or_else(|| ApiError::InvalidResponse("Missing trajectory array".to_string()))?;
327
328        let trajectory: Vec<ApiTrajectoryPoint> = trajectory_array
329            .iter()
330            .filter_map(|point| {
331                // Flask API returns nested {value: x, unit: y} objects in imperial units
332                let range_yards = point.get("distance")
333                    .and_then(Self::extract_value)?;
334                let drop_inches = point.get("drop")
335                    .and_then(Self::extract_value)
336                    .unwrap_or(0.0);
337                let drift_inches = point.get("wind_drift")
338                    .and_then(Self::extract_value)
339                    .unwrap_or(0.0);
340                let velocity_fps = point.get("velocity")
341                    .and_then(Self::extract_value)?;
342                let energy_ftlbs = point.get("energy")
343                    .and_then(Self::extract_value)
344                    .unwrap_or(0.0);
345                let time = point.get("time")
346                    .and_then(Self::extract_value)
347                    .unwrap_or(0.0);
348
349                Some(ApiTrajectoryPoint {
350                    range: range_yards * 0.9144,        // yards to meters
351                    drop: drop_inches * 0.0254,          // inches to meters
352                    drift: drift_inches * 0.0254,        // inches to meters
353                    velocity: velocity_fps * 0.3048,     // fps to m/s
354                    energy: energy_ftlbs * 1.35582,      // ft-lbs to Joules
355                    time,
356                })
357            })
358            .collect();
359
360        // Extract summary values from results object
361        let zero_angle = results
362            .and_then(|r| r.get("barrel_angle"))
363            .and_then(Self::extract_value)
364            .unwrap_or(0.0)
365            .to_radians();
366
367        let time_of_flight = results
368            .and_then(|r| r.get("time_of_flight"))
369            .and_then(Self::extract_value)
370            .unwrap_or_else(|| trajectory.last().map(|p| p.time).unwrap_or(0.0));
371
372        let bc_confidence = api_response.get("bc_confidence")
373            .and_then(|v| v.as_f64());
374
375        let ml_corrections = api_response.get("ml_corrections_applied")
376            .or_else(|| api_response.get("corrections_applied"))
377            .and_then(|v| v.as_array())
378            .map(|arr| {
379                arr.iter()
380                    .filter_map(|v| v.as_str().map(String::from))
381                    .collect()
382            });
383
384        // max_height is directly a number in results
385        let max_ordinate = results
386            .and_then(|r| r.get("max_height"))
387            .and_then(|v| v.as_f64())
388            .map(|h| h * 0.0254); // inches to meters
389
390        let impact_velocity = results
391            .and_then(|r| r.get("final_velocity"))
392            .and_then(Self::extract_value)
393            .map(|v| v * 0.3048); // fps to m/s
394
395        let impact_energy = results
396            .and_then(|r| r.get("final_energy"))
397            .and_then(Self::extract_value)
398            .map(|e| e * 1.35582); // ft-lbs to Joules
399
400        Ok(TrajectoryResponse {
401            trajectory,
402            zero_angle,
403            time_of_flight,
404            bc_confidence,
405            ml_corrections_applied: ml_corrections,
406            max_ordinate,
407            impact_velocity,
408            impact_energy,
409        })
410    }
411
412    /// Check API health
413    #[cfg(feature = "online")]
414    pub fn health_check(&self) -> Result<bool, ApiError> {
415        let url = format!("{}/health", self.base_url);
416
417        let response = ureq::get(&url)
418            .timeout(Duration::from_secs(5))
419            .call()
420            .map_err(|e| match e {
421                ureq::Error::Status(code, response) => {
422                    let body = response.into_string().unwrap_or_default();
423                    ApiError::ServerError(code, body)
424                }
425                ureq::Error::Transport(transport) => {
426                    let msg = transport.to_string();
427                    if msg.contains("timed out") || msg.contains("timeout") {
428                        ApiError::Timeout
429                    } else {
430                        ApiError::NetworkError(msg)
431                    }
432                }
433            })?;
434
435        Ok(response.status() == 200)
436    }
437}
438
439/// Builder for TrajectoryRequest
440#[derive(Default)]
441pub struct TrajectoryRequestBuilder {
442    bc_value: Option<f64>,
443    bc_type: Option<String>,
444    bullet_mass: Option<f64>,
445    muzzle_velocity: Option<f64>,
446    target_distance: Option<f64>,
447    zero_range: Option<f64>,
448    wind_speed: Option<f64>,
449    wind_angle: Option<f64>,
450    temperature: Option<f64>,
451    pressure: Option<f64>,
452    humidity: Option<f64>,
453    altitude: Option<f64>,
454    latitude: Option<f64>,
455    longitude: Option<f64>,
456    shot_direction: Option<f64>,
457    shooting_angle: Option<f64>,
458    twist_rate: Option<f64>,
459    bullet_diameter: Option<f64>,
460    bullet_length: Option<f64>,
461    ground_threshold: Option<f64>,
462    enable_weather_zones: Option<bool>,
463    enable_3d_weather: Option<bool>,
464    wind_shear_model: Option<String>,
465    weather_zone_interpolation: Option<String>,
466    sample_interval: Option<f64>,
467}
468
469impl TrajectoryRequestBuilder {
470    pub fn new() -> Self {
471        Self::default()
472    }
473
474    pub fn bc_value(mut self, value: f64) -> Self {
475        self.bc_value = Some(value);
476        self
477    }
478
479    pub fn bc_type(mut self, value: &str) -> Self {
480        self.bc_type = Some(value.to_string());
481        self
482    }
483
484    pub fn bullet_mass(mut self, value: f64) -> Self {
485        self.bullet_mass = Some(value);
486        self
487    }
488
489    pub fn muzzle_velocity(mut self, value: f64) -> Self {
490        self.muzzle_velocity = Some(value);
491        self
492    }
493
494    pub fn target_distance(mut self, value: f64) -> Self {
495        self.target_distance = Some(value);
496        self
497    }
498
499    pub fn zero_range(mut self, value: f64) -> Self {
500        self.zero_range = Some(value);
501        self
502    }
503
504    pub fn wind_speed(mut self, value: f64) -> Self {
505        self.wind_speed = Some(value);
506        self
507    }
508
509    pub fn wind_angle(mut self, value: f64) -> Self {
510        self.wind_angle = Some(value);
511        self
512    }
513
514    pub fn temperature(mut self, value: f64) -> Self {
515        self.temperature = Some(value);
516        self
517    }
518
519    pub fn pressure(mut self, value: f64) -> Self {
520        self.pressure = Some(value);
521        self
522    }
523
524    pub fn humidity(mut self, value: f64) -> Self {
525        self.humidity = Some(value);
526        self
527    }
528
529    pub fn altitude(mut self, value: f64) -> Self {
530        self.altitude = Some(value);
531        self
532    }
533
534    pub fn latitude(mut self, value: f64) -> Self {
535        self.latitude = Some(value);
536        self
537    }
538
539    pub fn longitude(mut self, value: f64) -> Self {
540        self.longitude = Some(value);
541        self
542    }
543
544    pub fn shot_direction(mut self, value: f64) -> Self {
545        self.shot_direction = Some(value);
546        self
547    }
548
549    pub fn shooting_angle(mut self, value: f64) -> Self {
550        self.shooting_angle = Some(value);
551        self
552    }
553
554    pub fn twist_rate(mut self, value: f64) -> Self {
555        self.twist_rate = Some(value);
556        self
557    }
558
559    pub fn bullet_diameter(mut self, value: f64) -> Self {
560        self.bullet_diameter = Some(value);
561        self
562    }
563
564    pub fn bullet_length(mut self, value: f64) -> Self {
565        self.bullet_length = Some(value);
566        self
567    }
568
569    pub fn ground_threshold(mut self, value: f64) -> Self {
570        self.ground_threshold = Some(value);
571        self
572    }
573
574    pub fn enable_weather_zones(mut self, value: bool) -> Self {
575        self.enable_weather_zones = Some(value);
576        self
577    }
578
579    pub fn enable_3d_weather(mut self, value: bool) -> Self {
580        self.enable_3d_weather = Some(value);
581        self
582    }
583
584    pub fn wind_shear_model(mut self, value: &str) -> Self {
585        self.wind_shear_model = Some(value.to_string());
586        self
587    }
588
589    pub fn weather_zone_interpolation(mut self, value: &str) -> Self {
590        self.weather_zone_interpolation = Some(value.to_string());
591        self
592    }
593
594    pub fn sample_interval(mut self, value: f64) -> Self {
595        self.sample_interval = Some(value);
596        self
597    }
598
599    /// Build the TrajectoryRequest
600    ///
601    /// # Returns
602    /// * `Ok(TrajectoryRequest)` - Valid request
603    /// * `Err(String)` - Missing required fields
604    pub fn build(self) -> Result<TrajectoryRequest, String> {
605        let bc_value = self.bc_value.ok_or("bc_value is required")?;
606        let bc_type = self.bc_type.ok_or("bc_type is required")?;
607        let bullet_mass = self.bullet_mass.ok_or("bullet_mass is required")?;
608        let muzzle_velocity = self.muzzle_velocity.ok_or("muzzle_velocity is required")?;
609        let target_distance = self.target_distance.ok_or("target_distance is required")?;
610
611        Ok(TrajectoryRequest {
612            bc_value,
613            bc_type,
614            bullet_mass,
615            muzzle_velocity,
616            target_distance,
617            zero_range: self.zero_range,
618            wind_speed: self.wind_speed,
619            wind_angle: self.wind_angle,
620            temperature: self.temperature,
621            pressure: self.pressure,
622            humidity: self.humidity,
623            altitude: self.altitude,
624            latitude: self.latitude,
625            longitude: self.longitude,
626            shot_direction: self.shot_direction,
627            shooting_angle: self.shooting_angle,
628            twist_rate: self.twist_rate,
629            bullet_diameter: self.bullet_diameter,
630            bullet_length: self.bullet_length,
631            ground_threshold: self.ground_threshold,
632            enable_weather_zones: self.enable_weather_zones,
633            enable_3d_weather: self.enable_3d_weather,
634            wind_shear_model: self.wind_shear_model,
635            weather_zone_interpolation: self.weather_zone_interpolation,
636            sample_interval: self.sample_interval,
637        })
638    }
639}
640
641#[cfg(test)]
642mod tests {
643    use super::*;
644
645    #[test]
646    fn test_request_builder_required_fields() {
647        let result = TrajectoryRequestBuilder::new()
648            .bc_value(0.238)
649            .bc_type("G7")
650            .bullet_mass(9.07) // 140gr in grams
651            .muzzle_velocity(860.0)
652            .target_distance(1000.0)
653            .build();
654
655        assert!(result.is_ok());
656        let request = result.unwrap();
657        assert_eq!(request.bc_value, 0.238);
658        assert_eq!(request.bc_type, "G7");
659    }
660
661    #[test]
662    fn test_request_builder_missing_fields() {
663        let result = TrajectoryRequestBuilder::new()
664            .bc_value(0.238)
665            .build();
666
667        assert!(result.is_err());
668    }
669
670    #[test]
671    fn test_request_builder_all_optional_fields() {
672        let result = TrajectoryRequestBuilder::new()
673            .bc_value(0.238)
674            .bc_type("G7")
675            .bullet_mass(9.07)
676            .muzzle_velocity(860.0)
677            .target_distance(1000.0)
678            .zero_range(100.0)
679            .wind_speed(5.0)
680            .wind_angle(90.0)
681            .temperature(15.0)
682            .pressure(1013.25)
683            .humidity(50.0)
684            .altitude(500.0)
685            .latitude(45.0)
686            .shooting_angle(0.0)
687            .twist_rate(10.0)
688            .bullet_diameter(0.00671)
689            .bullet_length(0.035)
690            .build();
691
692        assert!(result.is_ok());
693        let request = result.unwrap();
694        assert_eq!(request.zero_range, Some(100.0));
695        assert_eq!(request.wind_speed, Some(5.0));
696        assert_eq!(request.latitude, Some(45.0));
697    }
698
699    #[test]
700    fn test_api_client_url_normalization() {
701        let client1 = ApiClient::new("https://api.example.com/", 10);
702        assert_eq!(client1.base_url, "https://api.example.com");
703
704        let client2 = ApiClient::new("https://api.example.com", 10);
705        assert_eq!(client2.base_url, "https://api.example.com");
706    }
707
708    #[test]
709    fn test_api_error_display() {
710        assert_eq!(
711            format!("{}", ApiError::NetworkError("connection refused".to_string())),
712            "Network error: connection refused"
713        );
714        assert_eq!(format!("{}", ApiError::Timeout), "Request timed out");
715        assert_eq!(
716            format!("{}", ApiError::ServerError(500, "Internal error".to_string())),
717            "Server error 500: Internal error"
718        );
719    }
720}