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
7#[cfg(feature = "online")]
8use crate::constants::GRAMS_PER_GRAIN;
9use serde::{Deserialize, Serialize};
10use std::time::Duration;
11
12/// Request structure for trajectory calculation via Flask API
13#[derive(Debug, Clone, Serialize)]
14pub struct TrajectoryRequest {
15    /// Ballistic coefficient value
16    pub bc_value: f64,
17    /// BC type: "G1" or "G7"
18    pub bc_type: String,
19    /// Bullet mass in grams
20    pub bullet_mass: f64,
21    /// Muzzle velocity in m/s
22    pub muzzle_velocity: f64,
23    /// Target distance in meters
24    pub target_distance: f64,
25    /// Zero range in meters (optional)
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub zero_range: Option<f64>,
28    /// Wind speed in m/s (optional)
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub wind_speed: Option<f64>,
31    /// Wind angle in degrees (optional)
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub wind_angle: Option<f64>,
34    /// Temperature in Celsius (optional)
35    #[serde(skip_serializing_if = "Option::is_none")]
36    pub temperature: Option<f64>,
37    /// Pressure in hPa/mbar (optional)
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub pressure: Option<f64>,
40    /// Humidity percentage 0-100 (optional)
41    #[serde(skip_serializing_if = "Option::is_none")]
42    pub humidity: Option<f64>,
43    /// Altitude in meters (optional)
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub altitude: Option<f64>,
46    /// Latitude for Coriolis calculations (optional)
47    #[serde(skip_serializing_if = "Option::is_none")]
48    pub latitude: Option<f64>,
49    /// Longitude for weather zones (optional)
50    #[serde(skip_serializing_if = "Option::is_none")]
51    pub longitude: Option<f64>,
52    /// Shot direction/azimuth in degrees (optional, 0=North, 90=East)
53    #[serde(skip_serializing_if = "Option::is_none")]
54    pub shot_direction: Option<f64>,
55    /// Shooting angle in degrees (optional)
56    #[serde(skip_serializing_if = "Option::is_none")]
57    pub shooting_angle: Option<f64>,
58    /// Barrel twist rate in inches per turn (optional)
59    #[serde(skip_serializing_if = "Option::is_none")]
60    pub twist_rate: Option<f64>,
61    /// Bullet diameter in meters (optional)
62    #[serde(skip_serializing_if = "Option::is_none")]
63    pub bullet_diameter: Option<f64>,
64    /// Bullet length in meters (optional)
65    #[serde(skip_serializing_if = "Option::is_none")]
66    pub bullet_length: Option<f64>,
67    /// Ground threshold in meters (optional, negative = ignore ground impact)
68    #[serde(skip_serializing_if = "Option::is_none")]
69    pub ground_threshold: Option<f64>,
70    /// Enable weather zones (optional)
71    #[serde(skip_serializing_if = "Option::is_none")]
72    pub enable_weather_zones: Option<bool>,
73    /// Enable 3D weather corrections (optional)
74    #[serde(skip_serializing_if = "Option::is_none")]
75    pub enable_3d_weather: Option<bool>,
76    /// Wind shear model (optional: none, logarithmic, power_law, ekman_spiral)
77    #[serde(skip_serializing_if = "Option::is_none")]
78    pub wind_shear_model: Option<String>,
79    /// Weather zone interpolation method (optional: linear, cubic, step)
80    #[serde(skip_serializing_if = "Option::is_none")]
81    pub weather_zone_interpolation: Option<String>,
82    /// Sample/trajectory step interval in meters (optional)
83    /// Will be converted to yards for API as trajectory_step
84    #[serde(skip_serializing_if = "Option::is_none")]
85    pub sample_interval: Option<f64>,
86}
87
88/// Response structure from Flask API trajectory calculation
89#[derive(Debug, Clone, Deserialize)]
90pub struct TrajectoryResponse {
91    /// Array of trajectory points
92    pub trajectory: Vec<ApiTrajectoryPoint>,
93    /// Zero angle in radians
94    pub zero_angle: f64,
95    /// Total time of flight in seconds
96    pub time_of_flight: f64,
97    /// BC confidence score (0-1) if available
98    #[serde(default)]
99    pub bc_confidence: Option<f64>,
100    /// List of ML corrections applied
101    #[serde(default)]
102    pub ml_corrections_applied: Option<Vec<String>>,
103    /// Maximum ordinate (height) in meters
104    #[serde(default)]
105    pub max_ordinate: Option<f64>,
106    /// Impact velocity in m/s
107    #[serde(default)]
108    pub impact_velocity: Option<f64>,
109    /// Impact energy in Joules
110    #[serde(default)]
111    pub impact_energy: Option<f64>,
112}
113
114/// A single point in the trajectory from API response
115#[derive(Debug, Clone, Deserialize)]
116pub struct ApiTrajectoryPoint {
117    /// Range/distance in meters
118    pub range: f64,
119    /// Drop below line of sight in meters (negative = below)
120    pub drop: f64,
121    /// Wind drift in meters
122    pub drift: f64,
123    /// Velocity at this point in m/s
124    pub velocity: f64,
125    /// Kinetic energy at this point in Joules
126    pub energy: f64,
127    /// Time of flight to this point in seconds
128    pub time: f64,
129}
130
131/// Request structure for velocity truing via Flask API
132#[derive(Debug, Clone, Serialize)]
133pub struct TrueVelocityRequest {
134    /// Measured drop in MILs at the target range
135    pub measured_drop_mil: f64,
136    /// Range at which drop was measured (yards)
137    pub range_yd: f64,
138    /// Ballistic coefficient
139    pub bc: f64,
140    /// Drag model ("G1" or "G7")
141    pub drag_model: String,
142    /// Bullet weight in grains
143    pub weight_gr: f64,
144    /// Bullet caliber/diameter in inches
145    pub caliber: f64,
146    /// Zero range in yards (default: 100)
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub zero_range_yd: Option<f64>,
149    /// Chronograph velocity in fps (optional, for comparison)
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub chrono_velocity_fps: Option<f64>,
152    /// Altitude in feet (default: 0)
153    #[serde(skip_serializing_if = "Option::is_none")]
154    pub altitude_ft: Option<f64>,
155    /// Temperature in Fahrenheit (default: 59)
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub temperature_f: Option<f64>,
158    /// Barometric pressure in inHg (default: 29.92)
159    #[serde(skip_serializing_if = "Option::is_none")]
160    pub pressure_inhg: Option<f64>,
161    /// Humidity percentage (default: 50)
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub humidity: Option<f64>,
164    /// Sight height in inches (default: 2.0)
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub sight_height_in: Option<f64>,
167    /// Enable BC enhancement (default: true)
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub use_bc_enhancement: Option<bool>,
170}
171
172/// Response structure from Flask API velocity truing
173#[derive(Debug, Clone, Deserialize)]
174pub struct TrueVelocityResponse {
175    /// Effective muzzle velocity in fps
176    pub effective_velocity_fps: f64,
177    /// Velocity adjustment from chrono (if chrono provided)
178    #[serde(default)]
179    pub velocity_adjustment_fps: Option<f64>,
180    /// Adjustment as percentage (if chrono provided)
181    #[serde(default)]
182    pub adjustment_percent: Option<f64>,
183    /// Confidence in the result ("high", "medium", "low")
184    pub confidence: String,
185    /// Number of iterations to converge
186    pub iterations: i32,
187    /// Final error in MILs after convergence
188    pub final_error_mil: f64,
189    /// Calculated drop at the effective velocity
190    pub calculated_drop_mil: f64,
191}
192
193/// Error types for API communication
194#[derive(Debug)]
195pub enum ApiError {
196    /// Network connectivity error
197    NetworkError(String),
198    /// Request timed out
199    Timeout,
200    /// Invalid or unparseable response
201    InvalidResponse(String),
202    /// HTTP error from server (status code, message)
203    ServerError(u16, String),
204    /// Request building error
205    RequestError(String),
206}
207
208impl std::fmt::Display for ApiError {
209    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
210        match self {
211            ApiError::NetworkError(msg) => write!(f, "Network error: {}", msg),
212            ApiError::Timeout => write!(f, "Request timed out"),
213            ApiError::InvalidResponse(msg) => write!(f, "Invalid response: {}", msg),
214            ApiError::ServerError(code, msg) => write!(f, "Server error {}: {}", code, msg),
215            ApiError::RequestError(msg) => write!(f, "Request error: {}", msg),
216        }
217    }
218}
219
220impl ApiError {
221    /// A CLI-friendly hint for auth/subscription failures, if applicable.
222    pub fn cli_hint(&self) -> Option<&'static str> {
223        match self {
224            ApiError::ServerError(401, _) => Some(
225                "Unauthorized - run `ballistics login` (create a token at https://ballisticsinsight.com/account).",
226            ),
227            ApiError::ServerError(402, _) => Some(
228                "This endpoint needs an active Ballistics Insight subscription: https://ballisticsinsight.com/subscribe",
229            ),
230            _ => None,
231        }
232    }
233}
234
235impl std::error::Error for ApiError {}
236
237/// HTTP client for Flask API communication
238pub struct ApiClient {
239    base_url: String,
240    timeout: Duration,
241    #[cfg_attr(not(feature = "online"), allow(dead_code))]
242    token: Option<String>,
243}
244
245impl ApiClient {
246    /// Create a new API client
247    ///
248    /// # Arguments
249    /// * `base_url` - Base URL of the Flask API (e.g., <https://api.ballistics.7.62x51mm.sh>)
250    /// * `timeout_secs` - Request timeout in seconds
251    pub fn new(base_url: &str, timeout_secs: u64) -> Self {
252        // Normalize URL by removing trailing slash
253        let base_url = base_url.trim_end_matches('/').to_string();
254
255        Self {
256            base_url,
257            timeout: Duration::from_secs(timeout_secs),
258            token: None,
259        }
260    }
261
262    /// Attach a Personal Access Token; sent as `Authorization: Bearer <token>` on requests.
263    pub fn with_token(mut self, token: Option<String>) -> Self {
264        self.token = token;
265        self
266    }
267
268    /// The Authorization header pair to attach, if a token is configured.
269    #[cfg(feature = "online")]
270    fn auth_header(&self) -> Option<(&'static str, String)> {
271        self.token
272            .as_ref()
273            .map(|t| ("Authorization", format!("Bearer {}", t)))
274    }
275
276    /// Calculate trajectory via Flask API
277    ///
278    /// # Arguments
279    /// * `request` - Trajectory calculation request parameters
280    ///
281    /// # Returns
282    /// * `Ok(TrajectoryResponse)` - Successful calculation with trajectory data
283    /// * `Err(ApiError)` - Error during API communication
284    #[cfg(feature = "online")]
285    pub fn calculate_trajectory(
286        &self,
287        request: &TrajectoryRequest,
288    ) -> Result<TrajectoryResponse, ApiError> {
289        // Flask API uses GET /v1/calculate with query parameters (imperial units)
290        let url = format!("{}/v1/calculate", self.base_url);
291
292        // Convert metric values to imperial for API
293        let velocity_fps = request.muzzle_velocity / 0.3048; // m/s to fps
294        let mass_grains = request.bullet_mass / GRAMS_PER_GRAIN; // grams to grains
295        let distance_yards = request.target_distance / 0.9144; // meters to yards
296
297        let mut req = ureq::get(&url)
298            .config()
299            .timeout_global(Some(self.timeout))
300            .build()
301            .header("Accept", "application/json")
302            .header(
303                "User-Agent",
304                &format!("ballistics-cli/{}", env!("CARGO_PKG_VERSION")),
305            )
306            .query("bc_value", request.bc_value.to_string())
307            .query("bc_type", &request.bc_type)
308            .query("bullet_mass", format!("{:.1}", mass_grains))
309            .query("muzzle_velocity", format!("{:.1}", velocity_fps))
310            .query("target_distance", format!("{:.1}", distance_yards));
311
312        if let Some((name, value)) = self.auth_header() {
313            req = req.header(name, &value);
314        }
315
316        // Add optional parameters
317        if let Some(zero_range) = request.zero_range {
318            let zero_yards = zero_range / 0.9144;
319            req = req.query("zero_distance", format!("{:.1}", zero_yards));
320        }
321        if let Some(wind_speed) = request.wind_speed {
322            let wind_mph = wind_speed * 2.23694; // m/s to mph
323            req = req.query("wind_speed", format!("{:.1}", wind_mph));
324        }
325        if let Some(wind_angle) = request.wind_angle {
326            req = req.query("wind_angle", format!("{:.1}", wind_angle));
327        }
328        if let Some(temp) = request.temperature {
329            let temp_f = temp * 9.0 / 5.0 + 32.0; // Celsius to Fahrenheit
330            req = req.query("temperature", format!("{:.1}", temp_f));
331        }
332        if let Some(pressure) = request.pressure {
333            let pressure_inhg = pressure / 33.8639; // hPa to inHg
334            req = req.query("pressure", format!("{:.2}", pressure_inhg));
335        }
336        if let Some(humidity) = request.humidity {
337            req = req.query("humidity", format!("{:.1}", humidity));
338        }
339        if let Some(altitude) = request.altitude {
340            let altitude_ft = altitude / 0.3048; // meters to feet
341            req = req.query("altitude", format!("{:.1}", altitude_ft));
342        }
343        if let Some(shooting_angle) = request.shooting_angle {
344            req = req.query("shooting_angle", format!("{:.1}", shooting_angle));
345        }
346        if let Some(latitude) = request.latitude {
347            req = req.query("latitude", format!("{:.2}", latitude));
348        }
349        if let Some(longitude) = request.longitude {
350            req = req.query("longitude", format!("{:.2}", longitude));
351        }
352        if let Some(shot_direction) = request.shot_direction {
353            req = req.query("shot_direction", format!("{:.1}", shot_direction));
354        }
355        if let Some(twist_rate) = request.twist_rate {
356            req = req.query("twist_rate", format!("{:.1}", twist_rate));
357        }
358        if let Some(diameter) = request.bullet_diameter {
359            let diameter_in = diameter / 0.0254; // meters to inches
360            req = req.query("bullet_diameter", format!("{:.3}", diameter_in));
361        }
362        if let Some(threshold) = request.ground_threshold {
363            // Ground threshold is in meters. --ignore-ground-impact sets f64::NEG_INFINITY, which
364            // format!("{:.1}") would render as the literal "-inf" (rejected by most server-side
365            // numeric validators, silently dropping the ignore-ground intent); send a large finite
366            // negative sentinel for THAT case only. Finite values pass through. Any other
367            // non-finite value (NaN or +Inf) is invalid input, not an ignore-ground request, so
368            // omit the parameter rather than silently mapping it to the sentinel.
369            if threshold.is_finite() {
370                req = req.query("ground_threshold", format!("{:.1}", threshold));
371            } else if threshold == f64::NEG_INFINITY {
372                req = req.query("ground_threshold", "-1000000000.0");
373            }
374        }
375        if let Some(enable) = request.enable_weather_zones {
376            req = req.query(
377                "enable_weather_zones",
378                if enable { "true" } else { "false" },
379            );
380        }
381        if let Some(enable) = request.enable_3d_weather {
382            req = req.query("enable_3d_weather", if enable { "true" } else { "false" });
383        }
384        if let Some(ref model) = request.wind_shear_model {
385            req = req.query("wind_shear_model", model);
386        }
387        if let Some(ref method) = request.weather_zone_interpolation {
388            req = req.query("weather_zone_interpolation", method);
389        }
390        if let Some(sample_interval) = request.sample_interval {
391            // Convert from meters to yards for the Flask API (trajectory_step is in yards)
392            let step_yards = sample_interval / 0.9144;
393            req = req.query("trajectory_step", format!("{:.4}", step_yards));
394        }
395
396        let mut response = req.call().map_err(map_ureq_error)?;
397
398        // In ureq 3.x, 4xx/5xx responses are no longer returned as Err by default;
399        // .call() yields Ok(response) for any status. Inspect the status ourselves and
400        // map non-2xx to ServerError(code, body) to preserve the previous behavior.
401        let status = response.status();
402        if !status.is_success() {
403            let body = response.body_mut().read_to_string().unwrap_or_default();
404            return Err(ApiError::ServerError(status.as_u16(), body));
405        }
406
407        let body = response
408            .body_mut()
409            .read_to_string()
410            .map_err(|e| ApiError::InvalidResponse(e.to_string()))?;
411
412        // Parse the Flask API response and convert to our format
413        let api_response: serde_json::Value = serde_json::from_str(&body)
414            .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))?;
415
416        // Convert Flask API response to our TrajectoryResponse format
417        self.convert_api_response(&api_response)
418    }
419
420    /// Helper to extract a value from a nested {value: x, unit: y} structure or plain number
421    #[cfg(feature = "online")]
422    fn extract_value(val: &serde_json::Value) -> Option<f64> {
423        // Try nested {value: x} first, then plain number
424        val.get("value")
425            .and_then(|v| v.as_f64())
426            .or_else(|| val.as_f64())
427    }
428
429    #[cfg(feature = "online")]
430    fn convert_api_response(
431        &self,
432        api_response: &serde_json::Value,
433    ) -> Result<TrajectoryResponse, ApiError> {
434        // Get results object
435        let results = api_response.get("results");
436
437        // Extract trajectory points from Flask API response
438        // The Flask API returns trajectory in "trajectory" array with nested value objects
439        let trajectory_array = api_response
440            .get("trajectory")
441            .and_then(|t| t.as_array())
442            .ok_or_else(|| ApiError::InvalidResponse("Missing trajectory array".to_string()))?;
443
444        let trajectory: Vec<ApiTrajectoryPoint> = trajectory_array
445            .iter()
446            .filter_map(|point| {
447                // Flask API returns nested {value: x, unit: y} objects in imperial units
448                let range_yards = point.get("distance").and_then(Self::extract_value)?;
449                let drop_inches = point
450                    .get("drop")
451                    .and_then(Self::extract_value)
452                    .unwrap_or(0.0);
453                let drift_inches = point
454                    .get("wind_drift")
455                    .and_then(Self::extract_value)
456                    .unwrap_or(0.0);
457                let velocity_fps = point.get("velocity").and_then(Self::extract_value)?;
458                let energy_ftlbs = point
459                    .get("energy")
460                    .and_then(Self::extract_value)
461                    .unwrap_or(0.0);
462                let time = point
463                    .get("time")
464                    .and_then(Self::extract_value)
465                    .unwrap_or(0.0);
466
467                Some(ApiTrajectoryPoint {
468                    range: range_yards * 0.9144,     // yards to meters
469                    drop: drop_inches * 0.0254,      // inches to meters
470                    drift: drift_inches * 0.0254,    // inches to meters
471                    velocity: velocity_fps * 0.3048, // fps to m/s
472                    energy: energy_ftlbs * 1.35582,  // ft-lbs to Joules
473                    time,
474                })
475            })
476            .collect();
477
478        // Extract summary values from results object
479        let zero_angle = results
480            .and_then(|r| r.get("barrel_angle"))
481            .and_then(Self::extract_value)
482            .unwrap_or(0.0)
483            .to_radians();
484
485        let time_of_flight = results
486            .and_then(|r| r.get("time_of_flight"))
487            .and_then(Self::extract_value)
488            .unwrap_or_else(|| trajectory.last().map(|p| p.time).unwrap_or(0.0));
489
490        let bc_confidence = api_response.get("bc_confidence").and_then(|v| v.as_f64());
491
492        let ml_corrections = api_response
493            .get("ml_corrections_applied")
494            .or_else(|| api_response.get("corrections_applied"))
495            .and_then(|v| v.as_array())
496            .map(|arr| {
497                arr.iter()
498                    .filter_map(|v| v.as_str().map(String::from))
499                    .collect()
500            });
501
502        // max_height is directly a number in results
503        let max_ordinate = results
504            .and_then(|r| r.get("max_height"))
505            .and_then(|v| v.as_f64())
506            .map(|h| h * 0.0254); // inches to meters
507
508        let impact_velocity = results
509            .and_then(|r| r.get("final_velocity"))
510            .and_then(Self::extract_value)
511            .map(|v| v * 0.3048); // fps to m/s
512
513        let impact_energy = results
514            .and_then(|r| r.get("final_energy"))
515            .and_then(Self::extract_value)
516            .map(|e| e * 1.35582); // ft-lbs to Joules
517
518        Ok(TrajectoryResponse {
519            trajectory,
520            zero_angle,
521            time_of_flight,
522            bc_confidence,
523            ml_corrections_applied: ml_corrections,
524            max_ordinate,
525            impact_velocity,
526            impact_energy,
527        })
528    }
529
530    /// Check API health
531    #[cfg(feature = "online")]
532    pub fn health_check(&self) -> Result<bool, ApiError> {
533        let url = format!("{}/health", self.base_url);
534
535        let response = ureq::get(&url)
536            .config()
537            .timeout_global(Some(Duration::from_secs(5)))
538            .build()
539            .call()
540            .map_err(map_ureq_error)?;
541
542        Ok(response.status().as_u16() == 200)
543    }
544
545    /// Calculate true/effective muzzle velocity via Flask API
546    ///
547    /// # Arguments
548    /// * `request` - Velocity truing request parameters
549    ///
550    /// # Returns
551    /// * `Ok(TrueVelocityResponse)` - Successful calculation with effective velocity
552    /// * `Err(ApiError)` - Error during API communication
553    #[cfg(feature = "online")]
554    pub fn true_velocity(
555        &self,
556        request: &TrueVelocityRequest,
557    ) -> Result<TrueVelocityResponse, ApiError> {
558        let url = format!("{}/v1/true-velocity", self.base_url);
559
560        let body = serde_json::to_string(request)
561            .map_err(|e| ApiError::RequestError(format!("Failed to serialize request: {}", e)))?;
562
563        let mut req = ureq::post(&url)
564            .config()
565            .timeout_global(Some(self.timeout))
566            .build()
567            .header("Content-Type", "application/json")
568            .header("Accept", "application/json")
569            .header(
570                "User-Agent",
571                &format!("ballistics-cli/{}", env!("CARGO_PKG_VERSION")),
572            );
573        if let Some((name, value)) = self.auth_header() {
574            req = req.header(name, &value);
575        }
576        let mut response = req.send(&body).map_err(map_ureq_error)?;
577
578        // In ureq 3.x, 4xx/5xx responses are returned as Ok; check status explicitly.
579        let status = response.status();
580        if !status.is_success() {
581            let body = response.body_mut().read_to_string().unwrap_or_default();
582            return Err(ApiError::ServerError(status.as_u16(), body));
583        }
584
585        let response_body = response
586            .body_mut()
587            .read_to_string()
588            .map_err(|e| ApiError::InvalidResponse(format!("Failed to read response: {}", e)))?;
589
590        serde_json::from_str(&response_body)
591            .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))
592    }
593
594    /// Generic authenticated POST of a JSON body to `<base_url><path>`, returning the parsed JSON
595    /// response. Used by the online reverse-solver subcommands; sends the Bearer token.
596    #[cfg(feature = "online")]
597    pub fn post_json(
598        &self,
599        path: &str,
600        body: &serde_json::Value,
601    ) -> Result<serde_json::Value, ApiError> {
602        let url = format!("{}{}", self.base_url, path);
603        let body_str = serde_json::to_string(body)
604            .map_err(|e| ApiError::RequestError(format!("Failed to serialize request: {}", e)))?;
605        let mut req = ureq::post(&url)
606            .config()
607            .timeout_global(Some(self.timeout))
608            .build()
609            .header("Content-Type", "application/json")
610            .header("Accept", "application/json")
611            .header(
612                "User-Agent",
613                &format!("ballistics-cli/{}", env!("CARGO_PKG_VERSION")),
614            );
615        if let Some((name, value)) = self.auth_header() {
616            req = req.header(name, &value);
617        }
618        let mut response = req.send(&body_str).map_err(map_ureq_error)?;
619        let status = response.status();
620        let text = response
621            .body_mut()
622            .read_to_string()
623            .map_err(|e| ApiError::InvalidResponse(format!("Failed to read response: {}", e)))?;
624        if !status.is_success() {
625            return Err(ApiError::ServerError(status.as_u16(), text));
626        }
627        serde_json::from_str(&text)
628            .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))
629    }
630
631    /// Generic authenticated GET with query params to `<base_url><path>`, returning the parsed
632    /// JSON response. Sibling of `post_json` for GET reverse-solver endpoints. Sends the Bearer.
633    #[cfg(feature = "online")]
634    pub fn get_json(
635        &self,
636        path: &str,
637        params: &[(String, String)],
638    ) -> Result<serde_json::Value, ApiError> {
639        let url = format!("{}{}", self.base_url, path);
640        let mut req = ureq::get(&url)
641            .config()
642            .timeout_global(Some(self.timeout))
643            .build()
644            .header("Accept", "application/json")
645            .header(
646                "User-Agent",
647                &format!("ballistics-cli/{}", env!("CARGO_PKG_VERSION")),
648            );
649        for (k, v) in params {
650            req = req.query(k.as_str(), v.as_str());
651        }
652        if let Some((name, value)) = self.auth_header() {
653            req = req.header(name, &value);
654        }
655        let mut response = req.call().map_err(map_ureq_error)?;
656        let status = response.status();
657        let text = response
658            .body_mut()
659            .read_to_string()
660            .map_err(|e| ApiError::InvalidResponse(format!("Failed to read response: {}", e)))?;
661        if !status.is_success() {
662            return Err(ApiError::ServerError(status.as_u16(), text));
663        }
664        serde_json::from_str(&text)
665            .map_err(|e| ApiError::InvalidResponse(format!("JSON parse error: {}", e)))
666    }
667}
668
669/// Map a ureq 3.x transport/protocol error to an `ApiError`.
670///
671/// Note: in ureq 3.x, HTTP 4xx/5xx responses are NOT returned as errors by default
672/// (`.call()`/`.send()` yield `Ok(response)` for any status), so the status is checked
673/// on the returned response rather than here. This helper only sees genuine
674/// transport-level failures.
675#[cfg(feature = "online")]
676fn map_ureq_error(e: ureq::Error) -> ApiError {
677    match e {
678        // Any configured timeout (global/connect/etc.) maps to Timeout.
679        ureq::Error::Timeout(_) => ApiError::Timeout,
680        // Socket/IO errors: distinguish timeouts surfaced as IO errors, otherwise network.
681        ureq::Error::Io(io_err) => {
682            if io_err.kind() == std::io::ErrorKind::TimedOut {
683                ApiError::Timeout
684            } else {
685                ApiError::NetworkError(io_err.to_string())
686            }
687        }
688        // Other transport-level failures (DNS, connect, proxy, redirects, TLS, etc.).
689        other => ApiError::NetworkError(other.to_string()),
690    }
691}
692
693/// Builder for TrajectoryRequest
694#[derive(Default)]
695pub struct TrajectoryRequestBuilder {
696    bc_value: Option<f64>,
697    bc_type: Option<String>,
698    bullet_mass: Option<f64>,
699    muzzle_velocity: Option<f64>,
700    target_distance: Option<f64>,
701    zero_range: Option<f64>,
702    wind_speed: Option<f64>,
703    wind_angle: Option<f64>,
704    temperature: Option<f64>,
705    pressure: Option<f64>,
706    humidity: Option<f64>,
707    altitude: Option<f64>,
708    latitude: Option<f64>,
709    longitude: Option<f64>,
710    shot_direction: Option<f64>,
711    shooting_angle: Option<f64>,
712    twist_rate: Option<f64>,
713    bullet_diameter: Option<f64>,
714    bullet_length: Option<f64>,
715    ground_threshold: Option<f64>,
716    enable_weather_zones: Option<bool>,
717    enable_3d_weather: Option<bool>,
718    wind_shear_model: Option<String>,
719    weather_zone_interpolation: Option<String>,
720    sample_interval: Option<f64>,
721}
722
723impl TrajectoryRequestBuilder {
724    pub fn new() -> Self {
725        Self::default()
726    }
727
728    pub fn bc_value(mut self, value: f64) -> Self {
729        self.bc_value = Some(value);
730        self
731    }
732
733    pub fn bc_type(mut self, value: &str) -> Self {
734        self.bc_type = Some(value.to_string());
735        self
736    }
737
738    pub fn bullet_mass(mut self, value: f64) -> Self {
739        self.bullet_mass = Some(value);
740        self
741    }
742
743    pub fn muzzle_velocity(mut self, value: f64) -> Self {
744        self.muzzle_velocity = Some(value);
745        self
746    }
747
748    pub fn target_distance(mut self, value: f64) -> Self {
749        self.target_distance = Some(value);
750        self
751    }
752
753    pub fn zero_range(mut self, value: f64) -> Self {
754        self.zero_range = Some(value);
755        self
756    }
757
758    pub fn wind_speed(mut self, value: f64) -> Self {
759        self.wind_speed = Some(value);
760        self
761    }
762
763    pub fn wind_angle(mut self, value: f64) -> Self {
764        self.wind_angle = Some(value);
765        self
766    }
767
768    pub fn temperature(mut self, value: f64) -> Self {
769        self.temperature = Some(value);
770        self
771    }
772
773    pub fn pressure(mut self, value: f64) -> Self {
774        self.pressure = Some(value);
775        self
776    }
777
778    pub fn humidity(mut self, value: f64) -> Self {
779        self.humidity = Some(value);
780        self
781    }
782
783    pub fn altitude(mut self, value: f64) -> Self {
784        self.altitude = Some(value);
785        self
786    }
787
788    pub fn latitude(mut self, value: f64) -> Self {
789        self.latitude = Some(value);
790        self
791    }
792
793    pub fn longitude(mut self, value: f64) -> Self {
794        self.longitude = Some(value);
795        self
796    }
797
798    pub fn shot_direction(mut self, value: f64) -> Self {
799        self.shot_direction = Some(value);
800        self
801    }
802
803    pub fn shooting_angle(mut self, value: f64) -> Self {
804        self.shooting_angle = Some(value);
805        self
806    }
807
808    pub fn twist_rate(mut self, value: f64) -> Self {
809        self.twist_rate = Some(value);
810        self
811    }
812
813    pub fn bullet_diameter(mut self, value: f64) -> Self {
814        self.bullet_diameter = Some(value);
815        self
816    }
817
818    pub fn bullet_length(mut self, value: f64) -> Self {
819        self.bullet_length = Some(value);
820        self
821    }
822
823    pub fn ground_threshold(mut self, value: f64) -> Self {
824        self.ground_threshold = Some(value);
825        self
826    }
827
828    pub fn enable_weather_zones(mut self, value: bool) -> Self {
829        self.enable_weather_zones = Some(value);
830        self
831    }
832
833    pub fn enable_3d_weather(mut self, value: bool) -> Self {
834        self.enable_3d_weather = Some(value);
835        self
836    }
837
838    pub fn wind_shear_model(mut self, value: &str) -> Self {
839        self.wind_shear_model = Some(value.to_string());
840        self
841    }
842
843    pub fn weather_zone_interpolation(mut self, value: &str) -> Self {
844        self.weather_zone_interpolation = Some(value.to_string());
845        self
846    }
847
848    pub fn sample_interval(mut self, value: f64) -> Self {
849        self.sample_interval = Some(value);
850        self
851    }
852
853    /// Build the TrajectoryRequest
854    ///
855    /// # Returns
856    /// * `Ok(TrajectoryRequest)` - Valid request
857    /// * `Err(String)` - Missing required fields
858    pub fn build(self) -> Result<TrajectoryRequest, String> {
859        let bc_value = self.bc_value.ok_or("bc_value is required")?;
860        let bc_type = self.bc_type.ok_or("bc_type is required")?;
861        let bullet_mass = self.bullet_mass.ok_or("bullet_mass is required")?;
862        let muzzle_velocity = self.muzzle_velocity.ok_or("muzzle_velocity is required")?;
863        let target_distance = self.target_distance.ok_or("target_distance is required")?;
864
865        Ok(TrajectoryRequest {
866            bc_value,
867            bc_type,
868            bullet_mass,
869            muzzle_velocity,
870            target_distance,
871            zero_range: self.zero_range,
872            wind_speed: self.wind_speed,
873            wind_angle: self.wind_angle,
874            temperature: self.temperature,
875            pressure: self.pressure,
876            humidity: self.humidity,
877            altitude: self.altitude,
878            latitude: self.latitude,
879            longitude: self.longitude,
880            shot_direction: self.shot_direction,
881            shooting_angle: self.shooting_angle,
882            twist_rate: self.twist_rate,
883            bullet_diameter: self.bullet_diameter,
884            bullet_length: self.bullet_length,
885            ground_threshold: self.ground_threshold,
886            enable_weather_zones: self.enable_weather_zones,
887            enable_3d_weather: self.enable_3d_weather,
888            wind_shear_model: self.wind_shear_model,
889            weather_zone_interpolation: self.weather_zone_interpolation,
890            sample_interval: self.sample_interval,
891        })
892    }
893}
894
895#[cfg(test)]
896mod tests {
897    use super::*;
898
899    #[test]
900    fn test_request_builder_required_fields() {
901        let result = TrajectoryRequestBuilder::new()
902            .bc_value(0.238)
903            .bc_type("G7")
904            .bullet_mass(9.07) // 140gr in grams
905            .muzzle_velocity(860.0)
906            .target_distance(1000.0)
907            .build();
908
909        assert!(result.is_ok());
910        let request = result.unwrap();
911        assert_eq!(request.bc_value, 0.238);
912        assert_eq!(request.bc_type, "G7");
913    }
914
915    #[test]
916    fn test_request_builder_missing_fields() {
917        let result = TrajectoryRequestBuilder::new().bc_value(0.238).build();
918
919        assert!(result.is_err());
920    }
921
922    #[test]
923    fn test_request_builder_all_optional_fields() {
924        let result = TrajectoryRequestBuilder::new()
925            .bc_value(0.238)
926            .bc_type("G7")
927            .bullet_mass(9.07)
928            .muzzle_velocity(860.0)
929            .target_distance(1000.0)
930            .zero_range(100.0)
931            .wind_speed(5.0)
932            .wind_angle(90.0)
933            .temperature(15.0)
934            .pressure(1013.25)
935            .humidity(50.0)
936            .altitude(500.0)
937            .latitude(45.0)
938            .shooting_angle(0.0)
939            .twist_rate(10.0)
940            .bullet_diameter(0.00671)
941            .bullet_length(0.035)
942            .build();
943
944        assert!(result.is_ok());
945        let request = result.unwrap();
946        assert_eq!(request.zero_range, Some(100.0));
947        assert_eq!(request.wind_speed, Some(5.0));
948        assert_eq!(request.latitude, Some(45.0));
949    }
950
951    #[test]
952    fn test_api_client_url_normalization() {
953        let client1 = ApiClient::new("https://api.example.com/", 10);
954        assert_eq!(client1.base_url, "https://api.example.com");
955
956        let client2 = ApiClient::new("https://api.example.com", 10);
957        assert_eq!(client2.base_url, "https://api.example.com");
958    }
959
960    #[test]
961    fn test_api_error_display() {
962        assert_eq!(
963            format!(
964                "{}",
965                ApiError::NetworkError("connection refused".to_string())
966            ),
967            "Network error: connection refused"
968        );
969        assert_eq!(format!("{}", ApiError::Timeout), "Request timed out");
970        assert_eq!(
971            format!(
972                "{}",
973                ApiError::ServerError(500, "Internal error".to_string())
974            ),
975            "Server error 500: Internal error"
976        );
977    }
978
979    #[cfg(feature = "online")]
980    #[test]
981    fn auth_header_present_when_token_set() {
982        let c = ApiClient::new("https://x", 5).with_token(Some("bpat_abc".into()));
983        assert_eq!(
984            c.auth_header(),
985            Some(("Authorization", "Bearer bpat_abc".to_string()))
986        );
987    }
988
989    #[cfg(feature = "online")]
990    #[test]
991    fn auth_header_absent_without_token() {
992        let c = ApiClient::new("https://x", 5);
993        assert!(c.auth_header().is_none());
994    }
995
996    #[test]
997    fn cli_hint_maps_auth_errors() {
998        assert!(ApiError::ServerError(401, "x".into())
999            .cli_hint()
1000            .unwrap()
1001            .contains("login"));
1002        assert!(ApiError::ServerError(402, "x".into())
1003            .cli_hint()
1004            .unwrap()
1005            .contains("subscription"));
1006        assert!(ApiError::ServerError(500, "x".into()).cli_hint().is_none());
1007    }
1008}