aboutsummaryrefslogtreecommitdiff
path: root/src/api.rs
diff options
context:
space:
mode:
Diffstat (limited to 'src/api.rs')
-rw-r--r--src/api.rs636
1 files changed, 636 insertions, 0 deletions
diff --git a/src/api.rs b/src/api.rs
new file mode 100644
index 0000000..0321103
--- /dev/null
+++ b/src/api.rs
@@ -0,0 +1,636 @@
+use anyhow::{Context, Result};
+use reqwest::{blocking::{Client, Response}, header};
+use serde_json::json;
+use std::sync::{atomic::{AtomicBool, Ordering}, Arc};
+use std::time::Duration;
+
+use crate::models::*;
+
+/// API Client for BeepZone backend
+#[derive(Clone)]
+pub struct ApiClient {
+ client: Client,
+ base_url: String,
+ token: Option<String>,
+ db_timeout_flag: Arc<AtomicBool>,
+}
+
+impl ApiClient {
+ /// Create a new API client
+ pub fn new(base_url: String) -> Result<Self> {
+ let client = Client::builder()
+ .timeout(Duration::from_secs(30))
+ .build()
+ .context("Failed to create HTTP client")?;
+
+ Ok(Self {
+ client,
+ base_url: base_url.trim_end_matches('/').to_string(),
+ token: None,
+ db_timeout_flag: Arc::new(AtomicBool::new(false)),
+ })
+ }
+
+ fn flag_timeout_signal(&self) {
+ self.db_timeout_flag.store(true, Ordering::SeqCst);
+ }
+
+ fn observe_response_error(&self, error: &Option<String>) {
+ if Self::is_database_timeout_error(error) {
+ self.flag_timeout_signal();
+ }
+ }
+
+ fn send_request(
+ &self,
+ builder: reqwest::blocking::RequestBuilder,
+ context_msg: &'static str,
+ ) -> Result<Response> {
+ builder
+ .send()
+ .map_err(|err| {
+ self.flag_timeout_signal();
+ err
+ })
+ .context(context_msg)
+ }
+
+ /// Returns true if a timeout signal was previously raised (and clears it)
+ pub fn take_timeout_signal(&self) -> bool {
+ self.db_timeout_flag.swap(false, Ordering::SeqCst)
+ }
+
+ /// Set the authentication token
+ pub fn set_token(&mut self, token: String) {
+ self.token = Some(token);
+ }
+
+ /// Clear the authentication token
+ #[allow(dead_code)]
+ pub fn clear_token(&mut self) {
+ self.token = None;
+ }
+
+ /// Check if server is reachable
+ pub fn health_check(&self) -> Result<bool> {
+ let url = format!("{}/health", self.base_url);
+ let response = self
+ .send_request(self.client.get(&url), "Failed to perform health check")?;
+ Ok(response.status().is_success())
+ }
+
+ /// Get health details (tries to parse JSON; returns None if non-JSON)
+ pub fn health_info(&self) -> Result<Option<serde_json::Value>> {
+ let url = format!("{}/health", self.base_url);
+ let response = self
+ .send_request(self.client.get(&url), "Failed to fetch health info")?;
+ if !response.status().is_success() {
+ return Ok(None);
+ }
+ // Try to parse as JSON; if it fails, just return None (some servers return plain text)
+ let text = response.text()?;
+ match serde_json::from_str::<serde_json::Value>(&text) {
+ Ok(v) => Ok(Some(v)),
+ Err(_) => Ok(None),
+ }
+ }
+
+ /// Check if the error message indicates a database timeout
+ pub fn is_database_timeout_error(error: &Option<String>) -> bool {
+ if let Some(err) = error {
+ err.contains("Database temporarily unavailable")
+ } else {
+ false
+ }
+ }
+
+ // Authentication Methods
+
+ /// Login with username and password
+ pub fn login_password(&self, username: &str, password: &str) -> Result<LoginResponse> {
+ let url = format!("{}/auth/login", self.base_url);
+ let body = LoginRequest {
+ method: "password".to_string(),
+ username: Some(username.to_string()),
+ password: Some(password.to_string()),
+ pin: None,
+ login_string: None,
+ };
+
+ let response = self.send_request(
+ self.client.post(&url).json(&body),
+ "Failed to send login request",
+ )?;
+
+ let result: LoginResponse = response.json().context("Failed to parse login response")?;
+
+ Ok(result)
+ }
+
+ /// Login with PIN
+ #[allow(dead_code)]
+ pub fn login_pin(&self, username: &str, pin: &str) -> Result<ApiResponse<LoginResponse>> {
+ let url = format!("{}/auth/login", self.base_url);
+ let body = LoginRequest {
+ method: "pin".to_string(),
+ username: Some(username.to_string()),
+ password: None,
+ pin: Some(pin.to_string()),
+ login_string: None,
+ };
+
+ let response = self.send_request(
+ self.client.post(&url).json(&body),
+ "Failed to send PIN login request",
+ )?;
+
+ let result: ApiResponse<LoginResponse> =
+ response.json().context("Failed to parse login response")?;
+ self.observe_response_error(&result.error);
+
+ Ok(result)
+ }
+
+ /// Login with token/RFID string
+ #[allow(dead_code)]
+ pub fn login_token(&self, login_string: &str) -> Result<ApiResponse<LoginResponse>> {
+ let url = format!("{}/auth/login", self.base_url);
+ let body = LoginRequest {
+ method: "token".to_string(),
+ username: None,
+ password: None,
+ pin: None,
+ login_string: Some(login_string.to_string()),
+ };
+
+ let response = self.send_request(
+ self.client.post(&url).json(&body),
+ "Failed to send token login request",
+ )?;
+
+ let result: ApiResponse<LoginResponse> =
+ response.json().context("Failed to parse login response")?;
+ self.observe_response_error(&result.error);
+
+ Ok(result)
+ }
+
+ /// Logout current session
+ pub fn logout(&self) -> Result<ApiResponse<()>> {
+ let url = format!("{}/auth/logout", self.base_url);
+ let response = self.send_request(
+ self.make_authorized_request(reqwest::Method::POST, &url)?,
+ "Failed to send logout request",
+ )?;
+
+ let result: ApiResponse<()> = response.json()?;
+ self.observe_response_error(&result.error);
+ Ok(result)
+ }
+
+ /// Check session status
+ #[allow(dead_code)]
+ pub fn check_session(&self) -> Result<ApiResponse<SessionStatus>> {
+ let url = format!("{}/auth/status", self.base_url);
+ let response = self.send_request(
+ self.make_authorized_request(reqwest::Method::GET, &url)?,
+ "Failed to check session status",
+ )?;
+
+ let result: ApiResponse<SessionStatus> = response.json()?;
+ self.observe_response_error(&result.error);
+ Ok(result)
+ }
+
+ /// Best-effort session validity check.
+ /// Returns Ok(true) when the session appears valid, Ok(false) when clearly invalid (401/403 or explicit valid=false).
+ /// Be tolerant of different backend response shapes and assume valid on ambiguous 2xx responses.
+ pub fn check_session_valid(&self) -> Result<bool> {
+ let url = format!("{}/auth/status", self.base_url);
+ let response = self.send_request(
+ self.make_authorized_request(reqwest::Method::GET, &url)?,
+ "Failed to check session status",
+ )?;
+
+ let status = response.status();
+ let text = response.text()?;
+
+ // Explicitly invalid if unauthorized/forbidden
+ if status.as_u16() == 401 || status.as_u16() == 403 {
+ return Ok(false);
+ }
+
+ // Parse generic JSON and look for common shapes first
+ if let Ok(val) = serde_json::from_str::<serde_json::Value>(&text) {
+ // data.valid
+ if let Some(valid) = val
+ .get("data")
+ .and_then(|d| d.get("valid"))
+ .and_then(|v| v.as_bool())
+ {
+ return Ok(valid);
+ }
+ // top-level valid
+ if let Some(valid) = val.get("valid").and_then(|v| v.as_bool()) {
+ return Ok(valid);
+ }
+ // success=true is generally a good sign
+ if val.get("success").and_then(|v| v.as_bool()) == Some(true) {
+ return Ok(true);
+ }
+ }
+
+ // As a last attempt, try strict ApiResponse<SessionStatus>
+ if let Ok(parsed) = serde_json::from_str::<ApiResponse<SessionStatus>>(&text) {
+ if let Some(data) = parsed.data {
+ return Ok(data.valid);
+ }
+ // If no data provided, treat success=true as valid by default
+ return Ok(parsed.success || status.is_success());
+ }
+
+ // Last resort: if response was 2xx and not explicitly invalid, assume valid
+ Ok(status.is_success())
+ }
+
+ // Permissions & Preferences
+
+ /// Get current user's permissions
+ #[allow(dead_code)]
+ pub fn get_permissions(&self) -> Result<ApiResponse<PermissionsResponse>> {
+ let url = format!("{}/permissions", self.base_url);
+ let response = self.send_request(
+ self.make_authorized_request(reqwest::Method::GET, &url)?,
+ "Failed to get permissions",
+ )?;
+
+ let result: ApiResponse<PermissionsResponse> = response.json()?;
+ self.observe_response_error(&result.error);
+ Ok(result)
+ }
+
+ /// Get user preferences
+ #[allow(dead_code)]
+ pub fn get_preferences(
+ &self,
+ user_id: Option<i32>,
+ ) -> Result<ApiResponse<PreferencesResponse>> {
+ let url = format!("{}/preferences", self.base_url);
+ let body = PreferencesRequest {
+ action: "get".to_string(),
+ user_id,
+ preferences: None,
+ };
+
+ let response = self.send_request(
+ self.make_authorized_request(reqwest::Method::POST, &url)?
+ .json(&body),
+ "Failed to get preferences",
+ )?;
+
+ let result: ApiResponse<PreferencesResponse> = response.json()?;
+ self.observe_response_error(&result.error);
+ Ok(result)
+ }
+
+ /// Set user preferences
+ #[allow(dead_code)]
+ pub fn set_preferences(
+ &self,
+ values: serde_json::Value,
+ user_id: Option<i32>,
+ ) -> Result<ApiResponse<PreferencesResponse>> {
+ let url = format!("{}/preferences", self.base_url);
+ let body = PreferencesRequest {
+ action: "set".to_string(),
+ user_id,
+ preferences: Some(values),
+ };
+
+ let response = self.send_request(
+ self.make_authorized_request(reqwest::Method::POST, &url)?
+ .json(&body),
+ "Failed to set preferences",
+ )?;
+
+ let result: ApiResponse<PreferencesResponse> = response.json()?;
+ self.observe_response_error(&result.error);
+ Ok(result)
+ }
+
+ // Query Methods
+
+ /// Execute a generic query
+ pub fn query(&self, request: &QueryRequest) -> Result<ApiResponse<serde_json::Value>> {
+ let url = format!("{}/query", self.base_url);
+
+ // Log the serialized request for debugging
+ let body = serde_json::to_value(request)?;
+ log::debug!("Query request JSON: {}", serde_json::to_string(&body)?);
+
+ // Log the request for debugging JOINs
+ if request.joins.is_some() {
+ log::debug!(
+ "Query with JOINs: table={}, columns={:?}, joins={:?}",
+ request.table,
+ request.columns.as_ref().map(|c| c.len()),
+ request.joins.as_ref().map(|j| j.len())
+ );
+ }
+
+ let response = self.send_request(
+ self.make_authorized_request(reqwest::Method::POST, &url)?
+ .json(&body),
+ "Failed to execute query",
+ )?;
+
+ // Try to get the response text for debugging
+ let status = response.status();
+ let response_text = response.text()?;
+
+ // Log the raw response for debugging
+ if !status.is_success() {
+ log::error!("API error ({}): {}", status, response_text);
+ } else {
+ log::debug!(
+ "API response (first 500 chars): {}",
+ if response_text.len() > 500 {
+ &response_text[..500]
+ } else {
+ &response_text
+ }
+ );
+ }
+
+ // Now try to parse it
+ let result: ApiResponse<serde_json::Value> = serde_json::from_str(&response_text)
+ .with_context(|| {
+ format!(
+ "Failed to parse API response. Status: {}, Body: {}",
+ status,
+ if response_text.len() > 200 {
+ &response_text[..200]
+ } else {
+ &response_text
+ }
+ )
+ })?;
+ self.observe_response_error(&result.error);
+
+ Ok(result)
+ }
+
+ /// Select records from a table
+ pub fn select(
+ &self,
+ table: &str,
+ columns: Option<Vec<String>>,
+ where_clause: Option<serde_json::Value>,
+ order_by: Option<Vec<OrderBy>>,
+ limit: Option<u32>,
+ ) -> Result<ApiResponse<Vec<serde_json::Value>>> {
+ let request = QueryRequest {
+ action: "select".to_string(),
+ table: table.to_string(),
+ columns,
+ data: None,
+ r#where: where_clause,
+ filter: None,
+ order_by,
+ limit,
+ offset: None,
+ joins: None,
+ };
+
+ let response = self.query(&request)?;
+ if response.success {
+ let data = response.data.unwrap_or(json!([]));
+ let records: Vec<serde_json::Value> = serde_json::from_value(data)?;
+ Ok(ApiResponse {
+ success: true,
+ data: Some(records),
+ error: None,
+ message: response.message,
+ })
+ } else {
+ Ok(ApiResponse {
+ success: false,
+ data: None,
+ error: response.error,
+ message: response.message,
+ })
+ }
+ }
+
+ /// Select records from a table with JOINs
+ pub fn select_with_joins(
+ &self,
+ table: &str,
+ columns: Option<Vec<String>>,
+ where_clause: Option<serde_json::Value>,
+ filter: Option<serde_json::Value>,
+ order_by: Option<Vec<OrderBy>>,
+ limit: Option<u32>,
+ joins: Option<Vec<Join>>,
+ ) -> Result<ApiResponse<Vec<serde_json::Value>>> {
+ let request = QueryRequest {
+ action: "select".to_string(),
+ table: table.to_string(),
+ columns,
+ data: None,
+ r#where: where_clause,
+ filter,
+ order_by,
+ limit,
+ offset: None,
+ joins,
+ };
+
+ let response = self.query(&request)?;
+ if response.success {
+ let data = response.data.unwrap_or(json!([]));
+ let records: Vec<serde_json::Value> = serde_json::from_value(data)?;
+ Ok(ApiResponse {
+ success: true,
+ data: Some(records),
+ error: None,
+ message: response.message,
+ })
+ } else {
+ Ok(ApiResponse {
+ success: false,
+ data: None,
+ error: response.error,
+ message: response.message,
+ })
+ }
+ }
+
+ /// Insert a record
+ pub fn insert(&self, table: &str, values: serde_json::Value) -> Result<ApiResponse<i32>> {
+ let request = QueryRequest {
+ action: "insert".to_string(),
+ table: table.to_string(),
+ columns: None,
+ data: Some(values),
+ r#where: None,
+ filter: None,
+ order_by: None,
+ limit: None,
+ offset: None,
+ joins: None,
+ };
+
+ let response = self.query(&request)?;
+ if response.success {
+ let id: i32 = serde_json::from_value(response.data.unwrap_or(json!(0)))?;
+ Ok(ApiResponse {
+ success: true,
+ data: Some(id),
+ error: None,
+ message: response.message,
+ })
+ } else {
+ Ok(ApiResponse {
+ success: false,
+ data: None,
+ error: response.error,
+ message: response.message,
+ })
+ }
+ }
+
+ /// Update records
+ pub fn update(
+ &self,
+ table: &str,
+ values: serde_json::Value,
+ where_clause: serde_json::Value,
+ ) -> Result<ApiResponse<u32>> {
+ let request = QueryRequest {
+ action: "update".to_string(),
+ table: table.to_string(),
+ columns: None,
+ data: Some(values),
+ r#where: Some(where_clause),
+ filter: None,
+ order_by: None,
+ limit: None,
+ offset: None,
+ joins: None,
+ };
+
+ let response = self.query(&request)?;
+ if response.success {
+ let count: u32 = serde_json::from_value(response.data.unwrap_or(json!(0)))?;
+ Ok(ApiResponse {
+ success: true,
+ data: Some(count),
+ error: None,
+ message: response.message,
+ })
+ } else {
+ Ok(ApiResponse {
+ success: false,
+ data: None,
+ error: response.error,
+ message: response.message,
+ })
+ }
+ }
+
+ /// Delete records
+ pub fn delete(&self, table: &str, where_clause: serde_json::Value) -> Result<ApiResponse<u32>> {
+ let request = QueryRequest {
+ action: "delete".to_string(),
+ table: table.to_string(),
+ columns: None,
+ data: None,
+ r#where: Some(where_clause),
+ filter: None,
+ order_by: None,
+ limit: None,
+ offset: None,
+ joins: None,
+ };
+
+ let response = self.query(&request)?;
+ if response.success {
+ let count: u32 = serde_json::from_value(response.data.unwrap_or(json!(0)))?;
+ Ok(ApiResponse {
+ success: true,
+ data: Some(count),
+ error: None,
+ message: response.message,
+ })
+ } else {
+ Ok(ApiResponse {
+ success: false,
+ data: None,
+ error: response.error,
+ message: response.message,
+ })
+ }
+ }
+
+ /// Cunt records
+ pub fn count(
+ &self,
+ table: &str,
+ where_clause: Option<serde_json::Value>,
+ ) -> Result<ApiResponse<i32>> {
+ let request = QueryRequest {
+ action: "count".to_string(),
+ table: table.to_string(),
+ columns: None,
+ data: None,
+ r#where: where_clause,
+ filter: None,
+ order_by: None,
+ limit: None,
+ offset: None,
+ joins: None,
+ };
+
+ let response = self.query(&request)?;
+ if response.success {
+ let count: i32 = serde_json::from_value(response.data.unwrap_or(json!(0)))?;
+ Ok(ApiResponse {
+ success: true,
+ data: Some(count),
+ error: None,
+ message: response.message,
+ })
+ } else {
+ Ok(ApiResponse {
+ success: false,
+ data: None,
+ error: response.error,
+ message: response.message,
+ })
+ }
+ }
+
+ // Helper Methods
+
+ /// Create an authorized request with proper headers
+ fn make_authorized_request(
+ &self,
+ method: reqwest::Method,
+ url: &str,
+ ) -> Result<reqwest::blocking::RequestBuilder> {
+ let token = self.token.as_ref().context("No authentication token set")?;
+
+ let builder = self
+ .client
+ .request(method, url)
+ .header(header::AUTHORIZATION, format!("Bearer {}", token));
+
+ Ok(builder)
+ }
+
+ /// Get the based URL
+ pub fn base_url(&self) -> &str {
+ &self.base_url
+ }
+}