use anyhow::{Context, Result}; use reqwest::Client; use serde::{Deserialize, Serialize}; use std::time::Duration; /// Request payload for device registration #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct RegisterDeviceRequest { pub hardware_id: String, } /// Request payload for device heartbeat #[derive(Debug, Serialize)] #[serde(rename_all = "camelCase")] pub struct HeartbeatRequest { pub hardware_id: String, } /// Response from successful device registration #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RegisterDeviceResponse { pub message: String, pub device: DeviceInfo, } /// Device information returned from registration #[derive(Debug, Deserialize)] #[serde(rename_all = "camelCase")] pub struct DeviceInfo { pub id: String, pub user_profile_id: String, pub hardware_id: String, pub status: String, pub registered_at: String, } /// API error response structure #[derive(Debug, Deserialize)] pub struct ApiErrorResponse { pub message: String, #[serde(default)] pub error: Option, #[serde(default)] pub status_code: Option, } /// API client for communicating with the Meteor backend pub struct ApiClient { client: Client, base_url: String, } impl ApiClient { /// Creates a new API client with the given base URL pub fn new(base_url: String) -> Self { let client = Client::builder() .timeout(Duration::from_secs(30)) .build() .expect("Failed to create HTTP client"); Self { client, base_url } } /// Creates a new API client with default localhost URL pub fn default() -> Self { Self::new("http://localhost:3000".to_string()) } /// Registers a device with the backend API pub async fn register_device( &self, hardware_id: String, jwt_token: String, ) -> Result { let request_payload = RegisterDeviceRequest { hardware_id }; let url = format!("{}/api/v1/devices/register", self.base_url); println!("🌐 Registering device with backend at: {}", url); println!("📱 Hardware ID: {}", request_payload.hardware_id); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", jwt_token)) .header("Content-Type", "application/json") .json(&request_payload) .send() .await .context("Failed to send registration request")?; let status = response.status(); let response_text = response .text() .await .context("Failed to read response body")?; println!("📡 Response status: {}", status); println!("📡 Response body: {}", response_text); if status.is_success() { let registration_response: RegisterDeviceResponse = serde_json::from_str(&response_text) .context("Failed to parse successful registration response")?; println!("✅ Device registered successfully!"); println!(" Device ID: {}", registration_response.device.id); println!(" User Profile ID: {}", registration_response.device.user_profile_id); Ok(registration_response) } else { // Try to parse error response if let Ok(error_response) = serde_json::from_str::(&response_text) { anyhow::bail!( "Registration failed (HTTP {}): {}", status.as_u16(), error_response.message ); } else { anyhow::bail!( "Registration failed (HTTP {}): {}", status.as_u16(), response_text ); } } } /// Health check to verify backend connectivity pub async fn health_check(&self) -> Result<()> { let url = format!("{}/health", self.base_url); println!("🏥 Checking backend health at: {}", url); let response = self .client .get(&url) .send() .await .context("Failed to reach backend health endpoint")?; if response.status().is_success() { println!("✅ Backend is healthy"); Ok(()) } else { anyhow::bail!("Backend health check failed: HTTP {}", response.status()); } } /// Sends a heartbeat to the backend API pub async fn send_heartbeat( &self, hardware_id: String, jwt_token: String, ) -> Result<()> { let request_payload = HeartbeatRequest { hardware_id }; let url = format!("{}/api/v1/devices/heartbeat", self.base_url); println!("💓 Sending heartbeat to backend at: {}", url); println!("📱 Hardware ID: {}", request_payload.hardware_id); let response = self .client .post(&url) .header("Authorization", format!("Bearer {}", jwt_token)) .header("Content-Type", "application/json") .json(&request_payload) .send() .await .context("Failed to send heartbeat request")?; let status = response.status(); if status.is_success() { println!("✅ Heartbeat sent successfully!"); Ok(()) } else { let response_text = response .text() .await .unwrap_or_else(|_| "Unable to read response body".to_string()); // Try to parse error response for better error messages if let Ok(error_response) = serde_json::from_str::(&response_text) { anyhow::bail!( "Heartbeat failed (HTTP {}): {}", status.as_u16(), error_response.message ); } else { anyhow::bail!( "Heartbeat failed (HTTP {}): {}", status.as_u16(), response_text ); } } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_register_device_request_serialization() { let request = RegisterDeviceRequest { hardware_id: "TEST_DEVICE_123".to_string(), }; let json = serde_json::to_string(&request).unwrap(); assert!(json.contains("hardwareId")); assert!(json.contains("TEST_DEVICE_123")); } #[test] fn test_heartbeat_request_serialization() { let request = HeartbeatRequest { hardware_id: "TEST_DEVICE_456".to_string(), }; let json = serde_json::to_string(&request).unwrap(); assert!(json.contains("hardwareId")); assert!(json.contains("TEST_DEVICE_456")); } #[test] fn test_register_device_response_deserialization() { let json_response = r#" { "message": "Device registered successfully", "device": { "id": "device-123", "userProfileId": "user-456", "hardwareId": "TEST_DEVICE_123", "status": "active", "registeredAt": "2023-07-30T12:00:00Z" } } "#; let response: RegisterDeviceResponse = serde_json::from_str(json_response).unwrap(); assert_eq!(response.message, "Device registered successfully"); assert_eq!(response.device.id, "device-123"); assert_eq!(response.device.user_profile_id, "user-456"); assert_eq!(response.device.hardware_id, "TEST_DEVICE_123"); } #[test] fn test_api_error_response_deserialization() { let json_error = r#" { "message": "Device with hardware ID 'TEST_DEVICE' has already been claimed.", "error": "Conflict" } "#; let error: ApiErrorResponse = serde_json::from_str(json_error).unwrap(); assert!(error.message.contains("already been claimed")); assert_eq!(error.error, Some("Conflict".to_string())); } }