notifica-rust-sdk 0.1.0

Rust client SDK for the Notifica notification service
Documentation
# 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