# notifica-crate — Implementation Task
## Purpose
`notifica-crate` is a **Rust client SDK** installed inside any project that needs to broadcast
notifications to the **Notifica service**. Think of it like a Stripe or SendGrid SDK — you add it
as a dependency, configure it once, and call it to fire notifications.
```
Your Service → notifica-crate → HTTP POST → Notifica Service → Email / Push / Webhook
```
The crate handles:
- Building correct payloads for the Notifica webhook API
- Making the HTTP call to `POST /webhook/{tenant_id}/{event_name}`
- Returning typed errors on failure
It does **not** contain any delivery logic (SMTP, Expo, etc.) — that lives in the Notifica service.
---
## Notifica API Contract (what this SDK wraps)
```
POST http://{host}:8000/webhook/{tenant_id}/{event_name}
Content-Type: application/json
{
"email": {
"target_email": "user@example.com", // required
"target_name": "John", // optional
"sender_name": "MyApp", // optional
"subject": "Welcome!", // optional
"params": { "code": "1234" } // optional — template variables
},
"push": ["expo-token-1", "expo-token-2"],
"request": { "key": "value" },
"automation": { "key": "value" }
}
→ 200 OK "done"
```
All four payload fields are optional — send only what the event needs.
---
## Guiding Principles
1. **Ergonomic first** — builder pattern, sensible defaults, minimal boilerplate for callers.
2. **Thin** — wraps the HTTP API, no business logic, no provider SDKs.
3. **Async by default, sync optional** — `tokio`-based; `blocking` feature flag for sync contexts.
4. **Typed errors** — callers must be able to distinguish network errors from bad configuration.
5. **Zero unsafe** — no `unsafe` blocks.
6. **Full documentation** — every public item has a doc comment with an example.
---
## Module Structure
```
notifica-crate/
├── Cargo.toml
├── TASK.md
├── README.md
├── CHANGELOG.md
└── src/
├── lib.rs ← public re-exports, crate-level docs & quick-start example
├── client.rs ← NotificaClient (builder + send logic)
├── error.rs ← NotificaError, NotificaResult
└── payload/
├── mod.rs ← Notification builder, WebhookPayload struct
├── email.rs ← EmailPayload builder
├── push.rs ← PushPayload (Vec<String>)
└── request.rs ← RequestPayload (HashMap<String,String>)
```
---
## 1. Dependencies (`Cargo.toml`)
```toml
[package]
name = "notifica-crate"
version = "0.1.0"
edition = "2024"
description = "Rust client SDK for the Notifica notification service"
license = "MIT"
repository = "https://github.com/flippico/notifica-crate"
keywords = ["notification", "email", "push", "webhook", "client"]
categories = ["web-programming::http-client", "asynchronous"]
[features]
default = []
blocking = ["reqwest/blocking"]
[dependencies]
reqwest = { version = "0.12", features = ["json"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
thiserror = "2"
tokio = { version = "1", features = ["rt-multi-thread"] }
[dev-dependencies]
mockito = "1"
tokio = { version = "1", features = ["rt-multi-thread", "macros"] }
```
---
## 2. Error Types (`src/error.rs`)
```rust
#[derive(Debug, thiserror::Error)]
pub enum NotificaError {
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("notifica service returned unexpected status {status}: {body}")]
UnexpectedResponse { status: u16, body: String },
#[error("client is not configured: {0}")]
Configuration(String),
}
pub type NotificaResult<T> = Result<T, NotificaError>;
```
---
## 3. Payload Types (`src/payload/`)
### `WebhookPayload` — the serialized request body
```rust
/// The JSON body sent to `POST /webhook/{tenant_id}/{event_name}`.
#[derive(Debug, Default, Serialize)]
pub struct WebhookPayload {
#[serde(skip_serializing_if = "Option::is_none")]
pub email: Option<EmailPayload>,
#[serde(skip_serializing_if = "Option::is_none")]
pub push: Option<Vec<String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub request: Option<HashMap<String, String>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub automation: Option<HashMap<String, String>>,
}
```
### `EmailPayload` — email channel payload
```rust
#[derive(Debug, Serialize)]
pub struct EmailPayload {
pub target_email: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub target_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sender_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subject: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub params: Option<HashMap<String, String>>,
}
impl EmailPayload {
pub fn new(target_email: impl Into<String>) -> Self { ... }
pub fn target_name(mut self, v: impl Into<String>) -> Self { ... }
pub fn sender_name(mut self, v: impl Into<String>) -> Self { ... }
pub fn subject(mut self, v: impl Into<String>) -> Self { ... }
pub fn param(mut self, key: impl Into<String>, value: impl Into<String>) -> Self { ... }
}
```
### `Notification` — top-level builder for callers
```rust
/// Builder for a notification sent to a specific event.
///
/// # Example
/// ```rust
/// let n = Notification::new()
/// .email(EmailPayload::new("user@example.com").subject("Welcome!"))
/// .push(vec!["device-token".into()])
/// .request([("key", "value")]);
/// ```
pub struct Notification {
payload: WebhookPayload,
}
impl Notification {
pub fn new() -> Self { ... }
pub fn email(mut self, p: EmailPayload) -> Self { ... }
pub fn push(mut self, tokens: Vec<String>) -> Self { ... }
pub fn request(mut self, data: impl Into<HashMap<String, String>>) -> Self { ... }
pub fn automation(mut self, data: impl Into<HashMap<String, String>>) -> Self { ... }
pub(crate) fn into_payload(self) -> WebhookPayload { self.payload }
}
```
---
## 4. Client (`src/client.rs`)
```rust
/// Async client for the Notifica webhook API.
///
/// # Example
/// ```rust
/// let client = NotificaClient::new("http://localhost:8000", "my-tenant");
///
/// client
/// .send("user_registered", Notification::new()
/// .email(EmailPayload::new("user@example.com")
/// .subject("Welcome!")
/// .param("name", "Alice")))
/// .await?;
/// ```
pub struct NotificaClient {
base_url: String,
tenant_id: String,
http: reqwest::Client,
}
impl NotificaClient {
/// Create a client with default reqwest settings.
pub fn new(base_url: impl Into<String>, tenant_id: impl Into<String>) -> Self { ... }
/// Create a client with a custom reqwest::Client (e.g. with auth middleware or timeouts).
pub fn with_http_client(
base_url: impl Into<String>,
tenant_id: impl Into<String>,
http: reqwest::Client,
) -> Self { ... }
/// Send a notification for the given event name.
///
/// Maps to: `POST {base_url}/webhook/{tenant_id}/{event_name}`
pub async fn send(
&self,
event_name: impl Into<String>,
notification: Notification,
) -> NotificaResult<()> { ... }
}
```
### `send()` implementation steps
1. Serialize `notification.into_payload()` to JSON (`serde_json`).
2. Build URL: `{base_url}/webhook/{tenant_id}/{event_name}`.
3. `POST` with `Content-Type: application/json`.
4. On `200` → return `Ok(())`.
5. On any other status → return `Err(NotificaError::UnexpectedResponse { status, body })`.
6. On network error → propagate as `Err(NotificaError::Http(...))`.
---
## 5. Public API (`src/lib.rs`)
```rust
pub mod client;
pub mod error;
pub mod payload;
pub use client::NotificaClient;
pub use error::{NotificaError, NotificaResult};
pub use payload::{EmailPayload, Notification, WebhookPayload};
```
---
## 6. Usage Example (what a consumer writes)
```rust
use notifica_crate::{NotificaClient, Notification, EmailPayload};
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let notifica = NotificaClient::new(
std::env::var("NOTIFICA_URL")?, // e.g. "http://notifica:8000"
std::env::var("NOTIFICA_TENANT")?, // e.g. "my-app"
);
// Fire-and-forget: user registered → send welcome email + push
notifica.send(
"user_registered",
Notification::new()
.email(
EmailPayload::new("alice@example.com")
.target_name("Alice")
.subject("Welcome to MyApp!")
.param("name", "Alice"),
)
.push(vec!["expo-device-token-xyz".into()]),
).await?;
Ok(())
}
```
---
## 7. Testing Requirements
### Unit tests (inside `src/`)
- [ ] `EmailPayload` builder chains produce correct struct
- [ ] `Notification` builder produces correct `WebhookPayload`
- [ ] `skip_serializing_if = "Option::is_none"` — absent fields are not included in JSON output
- [ ] `NotificaError` variants display meaningful messages
### Integration tests (`tests/`) — use `mockito` to mock the Notifica service
- [ ] `send()` with full payload → correct JSON body, correct URL, returns `Ok(())`
- [ ] `send()` with only email → `push`/`request`/`automation` absent from JSON
- [ ] Server returns non-200 → `Err(NotificaError::UnexpectedResponse { status, .. })`
- [ ] Network failure (no server) → `Err(NotificaError::Http(...))`
---
## 8. Documentation Requirements
- `README.md`: one-paragraph purpose, installation snippet, quick-start example, env vars table
- `CHANGELOG.md`: conventional commits format
- Every `pub` item: doc comment with at least one `/// # Example` block
- `cargo doc --no-deps` → zero warnings before publish
---
## 9. Definition of Done
- [ ] `cargo build` passes with `-D warnings`
- [ ] `cargo test` — all unit + integration tests pass
- [ ] `cargo clippy -- -D clippy::all` passes
- [ ] `cargo doc --no-deps` — zero warnings
- [ ] `cargo publish --dry-run` succeeds
- [ ] `README.md` and `CHANGELOG.md` written
- [ ] At least one real-world usage example in `examples/send_notification.rs`
---
## 10. Out of Scope
- Queue/pub-sub publishing (`flippico-cache`) — that is internal transport, not part of the public SDK
- Any email/push/HTTP delivery logic — lives in the Notifica service
- Configuration file loading — the service owns that
- Authentication/API keys — currently the Notifica service has no auth layer; add when needed