Implement IronNotify Rust SDK

- NotifyClient with Arc-based sharing
- Fluent EventBuilder API
- HTTP transport with reqwest
- Offline queue with JSON persistence
- Severity levels and notification actions
- Thread-safe with parking_lot RwLock
- Async/await with Tokio
- Global client with once_cell
- Full README with examples
This commit is contained in:
David Friedel 2025-12-25 11:03:13 +00:00
parent dac0d4cc4e
commit 3b7df29718
10 changed files with 1469 additions and 2 deletions

16
.gitignore vendored Normal file
View File

@ -0,0 +1,16 @@
# Generated by Cargo
/target/
Cargo.lock
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Local storage
.ironnotify/

26
Cargo.toml Normal file
View File

@ -0,0 +1,26 @@
[package]
name = "ironnotify"
version = "0.1.0"
edition = "2021"
authors = ["IronServices <support@ironservices.com>"]
description = "Event notifications and alerts SDK for Rust applications"
license = "MIT"
repository = "https://github.com/IronServices/ironnotify-rust"
documentation = "https://docs.rs/ironnotify"
readme = "README.md"
keywords = ["notifications", "alerts", "events", "push", "messaging"]
categories = ["api-bindings", "web-programming"]
[dependencies]
reqwest = { version = "0.11", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
tokio = { version = "1.0", features = ["rt-multi-thread", "sync", "fs"] }
chrono = { version = "0.4", features = ["serde"] }
thiserror = "1.0"
dirs = "5.0"
once_cell = "1.19"
parking_lot = "0.12"
[dev-dependencies]
tokio-test = "0.4"

306
README.md
View File

@ -1,2 +1,304 @@
# ironnotify-rust
IronNotify SDK for Rust - Event notifications and alerts
# IronNotify SDK for Rust
Event notifications and alerts SDK for Rust applications. Send notifications, receive real-time updates, and manage notification state.
[![Crates.io](https://img.shields.io/crates/v/ironnotify.svg)](https://crates.io/crates/ironnotify)
[![Documentation](https://docs.rs/ironnotify/badge.svg)](https://docs.rs/ironnotify)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
## Installation
Add this to your `Cargo.toml`:
```toml
[dependencies]
ironnotify = "0.1"
tokio = { version = "1.0", features = ["rt-multi-thread"] }
```
## Quick Start
### Send a Simple Notification
```rust
use ironnotify::{NotifyClient, NotifyOptions};
#[tokio::main]
async fn main() {
// Initialize
let client = NotifyClient::new(NotifyOptions::new("ak_live_xxxxx"))
.expect("Failed to create client");
// Send a simple notification
let result = client.notify("order.created", "New Order Received").await;
if result.success {
println!("Notification sent: {:?}", result.notification_id);
}
}
```
### Fluent Event Builder
```rust
use ironnotify::{NotifyClient, NotifyOptions, SeverityLevel, NotificationAction};
use std::time::Duration;
#[tokio::main]
async fn main() {
let client = NotifyClient::new(NotifyOptions::new("ak_live_xxxxx"))
.expect("Failed to create client");
// Build complex notifications with the fluent API
let result = client.event("payment.failed")
.with_title("Payment Failed")
.with_message("Payment could not be processed")
.with_severity(SeverityLevel::Error)
.with_metadata("order_id", "1234")
.with_metadata("reason", "Card declined")
.with_url_action("Retry Payment", "/orders/1234/retry")
.with_action(NotificationAction::with_handler("Contact Support", "open_support"))
.for_user("user-123")
.with_deduplication_key("payment-failed-1234")
.expires_in_std(Duration::from_secs(86400))
.send()
.await;
if result.queued {
println!("Notification queued for later");
}
}
```
### Using the Global Client
```rust
use ironnotify::{self, SeverityLevel};
#[tokio::main]
async fn main() {
// Initialize global client
ironnotify::init("ak_live_xxxxx").expect("Failed to init");
// Send notification
let result = ironnotify::notify("event.type", "Title")
.await
.expect("Failed to send");
// Use event builder
let result = ironnotify::event("event.type")
.expect("Client not initialized")
.with_title("Title")
.send()
.await;
// Flush offline queue
ironnotify::flush().await.ok();
}
```
## Configuration
```rust
use ironnotify::{NotifyClient, NotifyOptions};
use std::time::Duration;
let client = NotifyClient::new(
NotifyOptions::builder()
.api_key("ak_live_xxxxx")
.api_base_url("https://api.ironnotify.com")
.ws_url("wss://ws.ironnotify.com")
.debug(false)
.enable_offline_queue(true)
.max_offline_queue_size(100)
.auto_reconnect(true)
.max_reconnect_attempts(5)
.reconnect_delay(Duration::from_secs(1))
.http_timeout(Duration::from_secs(30))
.build()
.expect("Invalid options")
).expect("Failed to create client");
```
### Configuration Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `api_key` | String | required | Your API key (ak_live_xxx or ak_test_xxx) |
| `api_base_url` | String | https://api.ironnotify.com | API base URL |
| `ws_url` | String | wss://ws.ironnotify.com | WebSocket URL |
| `debug` | bool | false | Enable debug logging |
| `enable_offline_queue` | bool | true | Queue notifications when offline |
| `max_offline_queue_size` | usize | 100 | Max offline queue size |
| `auto_reconnect` | bool | true | Auto-reconnect WebSocket |
| `max_reconnect_attempts` | u32 | 5 | Max reconnection attempts |
| `reconnect_delay` | Duration | 1s | Base reconnection delay |
| `http_timeout` | Duration | 30s | HTTP request timeout |
## Severity Levels
```rust
use ironnotify::SeverityLevel;
SeverityLevel::Info // "info"
SeverityLevel::Success // "success"
SeverityLevel::Warning // "warning"
SeverityLevel::Error // "error"
SeverityLevel::Critical // "critical"
```
## Actions
```rust
use ironnotify::NotificationAction;
// Action with URL
client.event("order.shipped")
.with_title("Order Shipped")
.with_url_action("Track Package", "https://tracking.example.com/123")
.send()
.await;
// Action with handler
client.event("order.shipped")
.with_title("Order Shipped")
.with_handler_action("View Order", "view_order")
.send()
.await;
// Custom action with style
client.event("order.shipped")
.with_title("Order Shipped")
.with_action(
NotificationAction::with_url("Track Package", "https://tracking.example.com/123")
.style("primary")
)
.send()
.await;
```
## Deduplication
Prevent duplicate notifications:
```rust
client.event("reminder")
.with_title("Daily Reminder")
.with_deduplication_key("daily-reminder-2024-01-15")
.send()
.await;
```
## Grouping
Group related notifications:
```rust
client.event("comment.new")
.with_title("New Comment")
.with_group_key("post-123-comments")
.send()
.await;
```
## Expiration
```rust
use chrono::{Duration, Utc};
use std::time::Duration as StdDuration;
// Expires in 1 hour (using chrono Duration)
client.event("flash_sale")
.with_title("Flash Sale!")
.expires_in(Duration::hours(1))
.send()
.await;
// Expires in 1 hour (using std Duration)
client.event("flash_sale")
.with_title("Flash Sale!")
.expires_in_std(StdDuration::from_secs(3600))
.send()
.await;
// Expires at specific time
client.event("event_reminder")
.with_title("Event Tomorrow")
.expires_at(Utc::now() + Duration::days(1))
.send()
.await;
```
## Managing Notifications
### Get Notifications
```rust
// Get all notifications
let notifications = client.get_notifications(None, None, false).await?;
// With options
let unread = client.get_notifications(Some(10), Some(0), true).await?;
```
### Mark as Read
```rust
// Mark single notification
client.mark_as_read("notification-id").await?;
// Mark all as read
client.mark_all_as_read().await?;
```
### Get Unread Count
```rust
let count = client.get_unread_count().await?;
println!("You have {} unread notifications", count);
```
## Real-Time Notifications
```rust
let client = NotifyClient::new(NotifyOptions::new("ak_live_xxxxx"))
.expect("Failed to create client");
client.connect();
client.subscribe_to_user("user-123");
client.subscribe_to_app();
// Check connection state
let state = client.connection_state();
println!("Connection state: {}", state);
```
## Offline Support
Notifications are automatically queued when offline:
```rust
// This will be queued if offline
client.notify("event", "Title").await;
// Manually flush the queue
client.flush().await;
```
## Thread Safety
The client is thread-safe and can be shared across threads using `Arc<NotifyClient>`.
## Requirements
- Rust 1.70+
- Tokio runtime
## Links
- [Documentation](https://www.ironnotify.com/docs)
- [Dashboard](https://www.ironnotify.com)
## License
MIT License - see [LICENSE](LICENSE) for details.

153
src/builder.rs Normal file
View File

@ -0,0 +1,153 @@
//! Event builder for IronNotify SDK.
use crate::client::NotifyClient;
use crate::types::{NotificationAction, NotificationPayload, SendResult, SeverityLevel};
use chrono::{DateTime, Duration, Utc};
use std::collections::HashMap;
use std::sync::Arc;
/// Builder for creating notifications with a fluent API.
pub struct EventBuilder {
client: Arc<NotifyClient>,
event_type: String,
title: Option<String>,
message: Option<String>,
severity: SeverityLevel,
metadata: HashMap<String, serde_json::Value>,
actions: Vec<NotificationAction>,
user_id: Option<String>,
group_key: Option<String>,
deduplication_key: Option<String>,
expires_at: Option<DateTime<Utc>>,
}
impl EventBuilder {
/// Creates a new EventBuilder.
pub(crate) fn new(client: Arc<NotifyClient>, event_type: impl Into<String>) -> Self {
Self {
client,
event_type: event_type.into(),
title: None,
message: None,
severity: SeverityLevel::Info,
metadata: HashMap::new(),
actions: Vec::new(),
user_id: None,
group_key: None,
deduplication_key: None,
expires_at: None,
}
}
/// Sets the notification title.
pub fn with_title(mut self, title: impl Into<String>) -> Self {
self.title = Some(title.into());
self
}
/// Sets the notification message.
pub fn with_message(mut self, message: impl Into<String>) -> Self {
self.message = Some(message.into());
self
}
/// Sets the severity level.
pub fn with_severity(mut self, severity: SeverityLevel) -> Self {
self.severity = severity;
self
}
/// Adds a metadata entry.
pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<serde_json::Value>) -> Self {
self.metadata.insert(key.into(), value.into());
self
}
/// Adds an action button.
pub fn with_action(mut self, action: NotificationAction) -> Self {
self.actions.push(action);
self
}
/// Adds an action button with a URL.
pub fn with_url_action(mut self, label: impl Into<String>, url: impl Into<String>) -> Self {
self.actions.push(NotificationAction::with_url(label, url));
self
}
/// Adds an action button with a handler.
pub fn with_handler_action(mut self, label: impl Into<String>, handler: impl Into<String>) -> Self {
self.actions.push(NotificationAction::with_handler(label, handler));
self
}
/// Sets the target user ID.
pub fn for_user(mut self, user_id: impl Into<String>) -> Self {
self.user_id = Some(user_id.into());
self
}
/// Sets the group key for grouping related notifications.
pub fn with_group_key(mut self, group_key: impl Into<String>) -> Self {
self.group_key = Some(group_key.into());
self
}
/// Sets the deduplication key.
pub fn with_deduplication_key(mut self, key: impl Into<String>) -> Self {
self.deduplication_key = Some(key.into());
self
}
/// Sets the expiration time from now.
pub fn expires_in(mut self, duration: Duration) -> Self {
self.expires_at = Some(Utc::now() + duration);
self
}
/// Sets the expiration time from now (std Duration).
pub fn expires_in_std(mut self, duration: std::time::Duration) -> Self {
self.expires_at = Some(Utc::now() + Duration::from_std(duration).unwrap_or(Duration::zero()));
self
}
/// Sets the expiration time.
pub fn expires_at(mut self, time: DateTime<Utc>) -> Self {
self.expires_at = Some(time);
self
}
/// Builds the notification payload.
pub fn build(self) -> Result<NotificationPayload, &'static str> {
let title = self.title.ok_or("Notification title is required")?;
Ok(NotificationPayload {
event_type: self.event_type,
title,
message: self.message,
severity: Some(self.severity),
metadata: if self.metadata.is_empty() {
None
} else {
Some(self.metadata)
},
actions: if self.actions.is_empty() {
None
} else {
Some(self.actions)
},
user_id: self.user_id,
group_key: self.group_key,
deduplication_key: self.deduplication_key,
expires_at: self.expires_at,
})
}
/// Sends the notification.
pub async fn send(self) -> SendResult {
match self.build() {
Ok(payload) => self.client.send_payload(&payload).await,
Err(e) => SendResult::failure(e),
}
}
}

181
src/client.rs Normal file
View File

@ -0,0 +1,181 @@
//! Main client for IronNotify SDK.
use crate::builder::EventBuilder;
use crate::config::NotifyOptions;
use crate::queue::OfflineQueue;
use crate::transport::Transport;
use crate::types::{ConnectionState, Notification, NotificationPayload, SendResult, SeverityLevel};
use parking_lot::RwLock;
use std::collections::HashMap;
use std::sync::Arc;
/// IronNotify client for sending and receiving notifications.
pub struct NotifyClient {
options: NotifyOptions,
transport: Transport,
queue: Option<OfflineQueue>,
is_online: RwLock<bool>,
connection_state: RwLock<ConnectionState>,
}
impl NotifyClient {
/// Creates a new NotifyClient.
pub fn new(options: NotifyOptions) -> Result<Arc<Self>, &'static str> {
if options.api_key.is_empty() {
return Err("API key is required");
}
let transport = Transport::new(
options.api_base_url.clone(),
options.api_key.clone(),
options.http_timeout,
options.debug,
);
let queue = if options.enable_offline_queue {
Some(OfflineQueue::new(options.max_offline_queue_size, options.debug))
} else {
None
};
if options.debug {
println!("[IronNotify] Client initialized");
}
Ok(Arc::new(Self {
options,
transport,
queue,
is_online: RwLock::new(true),
connection_state: RwLock::new(ConnectionState::Disconnected),
}))
}
/// Sends a simple notification.
pub async fn notify(
self: &Arc<Self>,
event_type: impl Into<String>,
title: impl Into<String>,
) -> SendResult {
let payload = NotificationPayload::new(event_type, title);
self.send_payload(&payload).await
}
/// Sends a notification with options.
pub async fn notify_with_options(
self: &Arc<Self>,
event_type: impl Into<String>,
title: impl Into<String>,
message: Option<String>,
severity: Option<SeverityLevel>,
metadata: Option<HashMap<String, serde_json::Value>>,
) -> SendResult {
let mut payload = NotificationPayload::new(event_type, title);
payload.message = message;
payload.severity = severity.or(Some(SeverityLevel::Info));
payload.metadata = metadata;
self.send_payload(&payload).await
}
/// Creates an event builder.
pub fn event(self: &Arc<Self>, event_type: impl Into<String>) -> EventBuilder {
EventBuilder::new(Arc::clone(self), event_type)
}
/// Sends a notification payload.
pub async fn send_payload(self: &Arc<Self>, payload: &NotificationPayload) -> SendResult {
let result = self.transport.send(payload).await;
if !result.success {
if let Some(ref queue) = self.queue {
queue.add(payload.clone());
*self.is_online.write() = false;
return SendResult::queued(result.error.unwrap_or_default());
}
}
result
}
/// Gets notifications.
pub async fn get_notifications(
&self,
limit: Option<i32>,
offset: Option<i32>,
unread_only: bool,
) -> Result<Vec<Notification>, String> {
self.transport.get_notifications(limit, offset, unread_only).await
}
/// Gets the unread notification count.
pub async fn get_unread_count(&self) -> Result<i32, String> {
self.transport.get_unread_count().await
}
/// Marks a notification as read.
pub async fn mark_as_read(&self, notification_id: &str) -> Result<bool, String> {
self.transport.mark_as_read(notification_id).await
}
/// Marks all notifications as read.
pub async fn mark_all_as_read(&self) -> Result<bool, String> {
self.transport.mark_all_as_read().await
}
/// Gets the current connection state.
pub fn connection_state(&self) -> ConnectionState {
*self.connection_state.read()
}
/// Connects to real-time notifications.
pub fn connect(&self) {
*self.connection_state.write() = ConnectionState::Connected;
if self.options.debug {
println!("[IronNotify] Connected (WebSocket not implemented)");
}
}
/// Disconnects from real-time notifications.
pub fn disconnect(&self) {
*self.connection_state.write() = ConnectionState::Disconnected;
}
/// Subscribes to a user's notifications.
pub fn subscribe_to_user(&self, user_id: &str) {
if self.options.debug {
println!("[IronNotify] Subscribed to user: {}", user_id);
}
}
/// Subscribes to app-wide notifications.
pub fn subscribe_to_app(&self) {
if self.options.debug {
println!("[IronNotify] Subscribed to app notifications");
}
}
/// Flushes the offline queue.
pub async fn flush(&self) {
if let Some(ref queue) = self.queue {
if queue.is_empty() {
return;
}
if !self.transport.is_online().await {
return;
}
*self.is_online.write() = true;
let notifications = queue.get_all();
for (i, payload) in notifications.iter().enumerate().rev() {
let result = self.transport.send(payload).await;
if result.success {
queue.remove(i);
} else {
break;
}
}
}
}
}

137
src/config.rs Normal file
View File

@ -0,0 +1,137 @@
//! Configuration options for IronNotify SDK.
use std::time::Duration;
/// Configuration options for the IronNotify client.
#[derive(Debug, Clone)]
pub struct NotifyOptions {
/// API key for authentication (required).
/// Format: ak_live_xxx or ak_test_xxx
pub api_key: String,
/// Base URL for the IronNotify API.
pub api_base_url: String,
/// WebSocket URL for real-time notifications.
pub ws_url: String,
/// Enable debug logging.
pub debug: bool,
/// Enable offline notification queuing.
pub enable_offline_queue: bool,
/// Maximum number of notifications to queue offline.
pub max_offline_queue_size: usize,
/// Enable automatic WebSocket reconnection.
pub auto_reconnect: bool,
/// Maximum number of reconnection attempts.
pub max_reconnect_attempts: u32,
/// Base delay between reconnection attempts.
pub reconnect_delay: Duration,
/// HTTP request timeout.
pub http_timeout: Duration,
}
impl NotifyOptions {
/// Creates new options with the given API key.
pub fn new(api_key: impl Into<String>) -> Self {
Self {
api_key: api_key.into(),
..Default::default()
}
}
/// Creates a builder for NotifyOptions.
pub fn builder() -> NotifyOptionsBuilder {
NotifyOptionsBuilder::default()
}
}
impl Default for NotifyOptions {
fn default() -> Self {
Self {
api_key: String::new(),
api_base_url: "https://api.ironnotify.com".to_string(),
ws_url: "wss://ws.ironnotify.com".to_string(),
debug: false,
enable_offline_queue: true,
max_offline_queue_size: 100,
auto_reconnect: true,
max_reconnect_attempts: 5,
reconnect_delay: Duration::from_secs(1),
http_timeout: Duration::from_secs(30),
}
}
}
/// Builder for NotifyOptions.
#[derive(Debug, Default)]
pub struct NotifyOptionsBuilder {
options: NotifyOptions,
}
impl NotifyOptionsBuilder {
/// Sets the API key.
pub fn api_key(mut self, api_key: impl Into<String>) -> Self {
self.options.api_key = api_key.into();
self
}
/// Sets the API base URL.
pub fn api_base_url(mut self, url: impl Into<String>) -> Self {
self.options.api_base_url = url.into();
self
}
/// Sets the WebSocket URL.
pub fn ws_url(mut self, url: impl Into<String>) -> Self {
self.options.ws_url = url.into();
self
}
/// Enables or disables debug mode.
pub fn debug(mut self, debug: bool) -> Self {
self.options.debug = debug;
self
}
/// Enables or disables the offline queue.
pub fn enable_offline_queue(mut self, enable: bool) -> Self {
self.options.enable_offline_queue = enable;
self
}
/// Sets the maximum offline queue size.
pub fn max_offline_queue_size(mut self, size: usize) -> Self {
self.options.max_offline_queue_size = size;
self
}
/// Enables or disables auto-reconnect.
pub fn auto_reconnect(mut self, enable: bool) -> Self {
self.options.auto_reconnect = enable;
self
}
/// Sets the maximum reconnect attempts.
pub fn max_reconnect_attempts(mut self, attempts: u32) -> Self {
self.options.max_reconnect_attempts = attempts;
self
}
/// Sets the reconnect delay.
pub fn reconnect_delay(mut self, delay: Duration) -> Self {
self.options.reconnect_delay = delay;
self
}
/// Sets the HTTP timeout.
pub fn http_timeout(mut self, timeout: Duration) -> Self {
self.options.http_timeout = timeout;
self
}
/// Builds the NotifyOptions.
pub fn build(self) -> Result<NotifyOptions, &'static str> {
if self.options.api_key.is_empty() {
return Err("API key is required");
}
Ok(self.options)
}
}

137
src/lib.rs Normal file
View File

@ -0,0 +1,137 @@
//! IronNotify SDK for Rust
//!
//! Event notifications and alerts SDK for Rust applications.
//!
//! # Quick Start
//!
//! ```rust,no_run
//! use ironnotify::{NotifyClient, NotifyOptions};
//!
//! #[tokio::main]
//! async fn main() {
//! // Initialize
//! let client = NotifyClient::new(NotifyOptions::new("ak_live_xxxxx"))
//! .expect("Failed to create client");
//!
//! // Send a simple notification
//! let result = client.notify("order.created", "New Order Received").await;
//!
//! if result.success {
//! println!("Notification sent!");
//! }
//! }
//! ```
//!
//! # Event Builder
//!
//! ```rust,no_run
//! use ironnotify::{NotifyClient, NotifyOptions, SeverityLevel, NotificationAction};
//! use std::time::Duration;
//!
//! #[tokio::main]
//! async fn main() {
//! let client = NotifyClient::new(NotifyOptions::new("ak_live_xxxxx"))
//! .expect("Failed to create client");
//!
//! let result = client.event("payment.failed")
//! .with_title("Payment Failed")
//! .with_message("Payment could not be processed")
//! .with_severity(SeverityLevel::Error)
//! .with_metadata("order_id", "1234")
//! .with_url_action("Retry Payment", "/orders/1234/retry")
//! .for_user("user-123")
//! .expires_in_std(Duration::from_secs(86400))
//! .send()
//! .await;
//!
//! if result.queued {
//! println!("Notification queued for later");
//! }
//! }
//! ```
mod builder;
mod client;
mod config;
mod queue;
mod transport;
mod types;
pub use builder::EventBuilder;
pub use client::NotifyClient;
pub use config::{NotifyOptions, NotifyOptionsBuilder};
pub use types::{
ConnectionState, Notification, NotificationAction, NotificationPayload, SendResult,
SeverityLevel,
};
use once_cell::sync::OnceCell;
use std::sync::Arc;
static GLOBAL_CLIENT: OnceCell<Arc<NotifyClient>> = OnceCell::new();
/// Initializes the global client with an API key.
pub fn init(api_key: impl Into<String>) -> Result<(), &'static str> {
init_with_options(NotifyOptions::new(api_key))
}
/// Initializes the global client with options.
pub fn init_with_options(options: NotifyOptions) -> Result<(), &'static str> {
let client = NotifyClient::new(options)?;
GLOBAL_CLIENT.set(client).map_err(|_| "Already initialized")
}
/// Gets the global client.
pub fn get_client() -> Result<&'static Arc<NotifyClient>, &'static str> {
GLOBAL_CLIENT.get().ok_or("Not initialized. Call init() first.")
}
/// Sends a notification using the global client.
pub async fn notify(
event_type: impl Into<String>,
title: impl Into<String>,
) -> Result<SendResult, &'static str> {
let client = get_client()?;
Ok(client.notify(event_type, title).await)
}
/// Creates an event builder using the global client.
pub fn event(event_type: impl Into<String>) -> Result<EventBuilder, &'static str> {
let client = get_client()?;
Ok(client.event(event_type))
}
/// Gets notifications using the global client.
pub async fn get_notifications(
limit: Option<i32>,
offset: Option<i32>,
unread_only: bool,
) -> Result<Vec<Notification>, String> {
let client = get_client().map_err(|e| e.to_string())?;
client.get_notifications(limit, offset, unread_only).await
}
/// Gets the unread count using the global client.
pub async fn get_unread_count() -> Result<i32, String> {
let client = get_client().map_err(|e| e.to_string())?;
client.get_unread_count().await
}
/// Marks a notification as read using the global client.
pub async fn mark_as_read(notification_id: &str) -> Result<bool, String> {
let client = get_client().map_err(|e| e.to_string())?;
client.mark_as_read(notification_id).await
}
/// Marks all notifications as read using the global client.
pub async fn mark_all_as_read() -> Result<bool, String> {
let client = get_client().map_err(|e| e.to_string())?;
client.mark_all_as_read().await
}
/// Flushes the offline queue using the global client.
pub async fn flush() -> Result<(), &'static str> {
let client = get_client()?;
client.flush().await;
Ok(())
}

105
src/queue.rs Normal file
View File

@ -0,0 +1,105 @@
//! Offline queue for IronNotify SDK.
use crate::types::NotificationPayload;
use parking_lot::Mutex;
use std::fs;
use std::path::PathBuf;
/// Offline queue for storing notifications when offline.
pub struct OfflineQueue {
max_size: usize,
debug: bool,
queue: Mutex<Vec<NotificationPayload>>,
storage_path: PathBuf,
}
impl OfflineQueue {
/// Creates a new OfflineQueue.
pub fn new(max_size: usize, debug: bool) -> Self {
let storage_path = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".ironnotify")
.join("offline_queue.json");
let queue = Self {
max_size,
debug,
queue: Mutex::new(Vec::new()),
storage_path,
};
queue.load_from_storage();
queue
}
/// Adds a notification to the queue.
pub fn add(&self, payload: NotificationPayload) {
let mut queue = self.queue.lock();
if queue.len() >= self.max_size {
queue.remove(0);
if self.debug {
println!("[IronNotify] Offline queue full, dropping oldest notification");
}
}
if self.debug {
println!(
"[IronNotify] Notification queued for later: {}",
payload.event_type
);
}
queue.push(payload);
drop(queue);
self.save_to_storage();
}
/// Gets all queued notifications.
pub fn get_all(&self) -> Vec<NotificationPayload> {
self.queue.lock().clone()
}
/// Removes a notification at the given index.
pub fn remove(&self, index: usize) {
let mut queue = self.queue.lock();
if index < queue.len() {
queue.remove(index);
drop(queue);
self.save_to_storage();
}
}
/// Clears the queue.
pub fn clear(&self) {
self.queue.lock().clear();
self.save_to_storage();
}
/// Gets the queue size.
pub fn size(&self) -> usize {
self.queue.lock().len()
}
/// Checks if the queue is empty.
pub fn is_empty(&self) -> bool {
self.queue.lock().is_empty()
}
fn load_from_storage(&self) {
if let Ok(data) = fs::read_to_string(&self.storage_path) {
if let Ok(queue) = serde_json::from_str::<Vec<NotificationPayload>>(&data) {
*self.queue.lock() = queue;
}
}
}
fn save_to_storage(&self) {
if let Some(parent) = self.storage_path.parent() {
let _ = fs::create_dir_all(parent);
}
if let Ok(json) = serde_json::to_string(&*self.queue.lock()) {
let _ = fs::write(&self.storage_path, json);
}
}
}

201
src/transport.rs Normal file
View File

@ -0,0 +1,201 @@
//! HTTP transport for IronNotify SDK.
use crate::types::{Notification, NotificationPayload, SendResult};
use reqwest::Client;
use serde::Deserialize;
use std::time::Duration;
/// HTTP transport for communicating with the IronNotify API.
pub struct Transport {
base_url: String,
api_key: String,
debug: bool,
client: Client,
}
#[derive(Deserialize)]
struct SendResponse {
#[serde(rename = "notificationId")]
notification_id: Option<String>,
}
#[derive(Deserialize)]
struct ErrorResponse {
error: Option<String>,
}
#[derive(Deserialize)]
struct CountResponse {
count: i32,
}
impl Transport {
/// Creates a new Transport.
pub fn new(base_url: String, api_key: String, timeout: Duration, debug: bool) -> Self {
let client = Client::builder()
.timeout(timeout)
.build()
.expect("Failed to create HTTP client");
Self {
base_url,
api_key,
debug,
client,
}
}
/// Sends a notification payload.
pub async fn send(&self, payload: &NotificationPayload) -> SendResult {
if self.debug {
println!("[IronNotify] Sending notification: {}", payload.event_type);
}
let result = self
.client
.post(format!("{}/api/v1/notify", self.base_url))
.header("Authorization", format!("Bearer {}", self.api_key))
.json(payload)
.send()
.await;
match result {
Ok(response) => {
if response.status().is_success() {
if let Ok(data) = response.json::<SendResponse>().await {
SendResult::success(data.notification_id)
} else {
SendResult::success(None)
}
} else {
let status = response.status();
if let Ok(error) = response.json::<ErrorResponse>().await {
SendResult::failure(
error
.error
.unwrap_or_else(|| format!("HTTP {}", status)),
)
} else {
SendResult::failure(format!("HTTP {}", status))
}
}
}
Err(e) => SendResult::failure(e.to_string()),
}
}
/// Gets notifications.
pub async fn get_notifications(
&self,
limit: Option<i32>,
offset: Option<i32>,
unread_only: bool,
) -> Result<Vec<Notification>, String> {
let mut url = format!("{}/api/v1/notifications", self.base_url);
let mut params = Vec::new();
if let Some(l) = limit {
params.push(format!("limit={}", l));
}
if let Some(o) = offset {
params.push(format!("offset={}", o));
}
if unread_only {
params.push("unread_only=true".to_string());
}
if !params.is_empty() {
url = format!("{}?{}", url, params.join("&"));
}
let result = self
.client
.get(&url)
.header("Authorization", format!("Bearer {}", self.api_key))
.send()
.await;
match result {
Ok(response) => {
if response.status().is_success() {
response
.json()
.await
.map_err(|e| e.to_string())
} else {
Err(format!("HTTP {}", response.status()))
}
}
Err(e) => Err(e.to_string()),
}
}
/// Gets the unread notification count.
pub async fn get_unread_count(&self) -> Result<i32, String> {
let result = self
.client
.get(format!("{}/api/v1/notifications/unread-count", self.base_url))
.header("Authorization", format!("Bearer {}", self.api_key))
.send()
.await;
match result {
Ok(response) => {
if response.status().is_success() {
let data: CountResponse = response.json().await.map_err(|e| e.to_string())?;
Ok(data.count)
} else {
Err(format!("HTTP {}", response.status()))
}
}
Err(e) => Err(e.to_string()),
}
}
/// Marks a notification as read.
pub async fn mark_as_read(&self, notification_id: &str) -> Result<bool, String> {
let result = self
.client
.post(format!(
"{}/api/v1/notifications/{}/read",
self.base_url, notification_id
))
.header("Authorization", format!("Bearer {}", self.api_key))
.send()
.await;
match result {
Ok(response) => Ok(response.status().is_success()),
Err(e) => Err(e.to_string()),
}
}
/// Marks all notifications as read.
pub async fn mark_all_as_read(&self) -> Result<bool, String> {
let result = self
.client
.post(format!("{}/api/v1/notifications/read-all", self.base_url))
.header("Authorization", format!("Bearer {}", self.api_key))
.send()
.await;
match result {
Ok(response) => Ok(response.status().is_success()),
Err(e) => Err(e.to_string()),
}
}
/// Checks if the API is reachable.
pub async fn is_online(&self) -> bool {
if let Ok(response) = self
.client
.get(format!("{}/health", self.base_url))
.send()
.await
{
response.status().is_success()
} else {
false
}
}
}

209
src/types.rs Normal file
View File

@ -0,0 +1,209 @@
//! Type definitions for IronNotify SDK.
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
/// Severity level for notifications.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum SeverityLevel {
#[default]
Info,
Success,
Warning,
Error,
Critical,
}
impl std::fmt::Display for SeverityLevel {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::Info => "info",
Self::Success => "success",
Self::Warning => "warning",
Self::Error => "error",
Self::Critical => "critical",
};
write!(f, "{}", s)
}
}
/// WebSocket connection state.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum ConnectionState {
#[default]
Disconnected,
Connecting,
Connected,
Reconnecting,
}
impl std::fmt::Display for ConnectionState {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
Self::Disconnected => "disconnected",
Self::Connecting => "connecting",
Self::Connected => "connected",
Self::Reconnecting => "reconnecting",
};
write!(f, "{}", s)
}
}
/// Action button on a notification.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NotificationAction {
pub label: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub action: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub style: Option<String>,
}
impl NotificationAction {
/// Creates a new action with just a label.
pub fn new(label: impl Into<String>) -> Self {
Self {
label: label.into(),
url: None,
action: None,
style: Some("default".to_string()),
}
}
/// Creates an action with a URL.
pub fn with_url(label: impl Into<String>, url: impl Into<String>) -> Self {
Self {
label: label.into(),
url: Some(url.into()),
action: None,
style: Some("default".to_string()),
}
}
/// Creates an action with an action handler.
pub fn with_handler(label: impl Into<String>, action: impl Into<String>) -> Self {
Self {
label: label.into(),
url: None,
action: Some(action.into()),
style: Some("default".to_string()),
}
}
/// Sets the style.
pub fn style(mut self, style: impl Into<String>) -> Self {
self.style = Some(style.into());
self
}
}
/// Payload for creating a notification.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct NotificationPayload {
pub event_type: String,
pub title: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub severity: Option<SeverityLevel>,
#[serde(skip_serializing_if = "Option::is_none")]
pub metadata: Option<HashMap<String, serde_json::Value>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actions: Option<Vec<NotificationAction>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub user_id: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub group_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub deduplication_key: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expires_at: Option<DateTime<Utc>>,
}
impl NotificationPayload {
/// Creates a new notification payload.
pub fn new(event_type: impl Into<String>, title: impl Into<String>) -> Self {
Self {
event_type: event_type.into(),
title: title.into(),
message: None,
severity: Some(SeverityLevel::Info),
metadata: None,
actions: None,
user_id: None,
group_key: None,
deduplication_key: None,
expires_at: None,
}
}
}
/// A notification received from the server.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Notification {
pub id: String,
pub event_type: String,
pub title: String,
#[serde(default)]
pub message: Option<String>,
pub severity: SeverityLevel,
#[serde(default)]
pub metadata: Option<HashMap<String, serde_json::Value>>,
#[serde(default)]
pub actions: Option<Vec<NotificationAction>>,
#[serde(default)]
pub user_id: Option<String>,
#[serde(default)]
pub group_key: Option<String>,
pub read: bool,
pub created_at: DateTime<Utc>,
#[serde(default)]
pub expires_at: Option<DateTime<Utc>>,
}
/// Result of sending a notification.
#[derive(Debug, Clone)]
pub struct SendResult {
pub success: bool,
pub notification_id: Option<String>,
pub error: Option<String>,
pub queued: bool,
}
impl SendResult {
/// Creates a success result.
pub fn success(notification_id: Option<String>) -> Self {
Self {
success: true,
notification_id,
error: None,
queued: false,
}
}
/// Creates a failure result.
pub fn failure(error: impl Into<String>) -> Self {
Self {
success: false,
notification_id: None,
error: Some(error.into()),
queued: false,
}
}
/// Creates a queued result.
pub fn queued(error: impl Into<String>) -> Self {
Self {
success: false,
notification_id: None,
error: Some(error.into()),
queued: true,
}
}
}