rok-cli 0.3.2

Developer CLI for rok-based Axum applications
use super::{write_file, Scaffold, ScaffoldArgs, ScaffoldResult};
use anyhow::Result;

pub struct WebhookScaffold;

impl Scaffold for WebhookScaffold {
    fn name(&self) -> &'static str {
        "webhook"
    }
    fn description(&self) -> &'static str {
        "Webhook handler: HMAC signature verify, outgoing dispatcher, delivery log, retry queue"
    }

    fn generate(&self, args: &ScaffoldArgs) -> Result<ScaffoldResult> {
        let mut r = ScaffoldResult::default();
        let d = args.dry_run;
        write_file(
            &mut r,
            "src/app/controllers/webhook_controller.rs",
            CONTROLLER,
            d,
        )?;
        write_file(
            &mut r,
            "src/app/middleware/webhook_signature.rs",
            SIGNATURE_MW,
            d,
        )?;
        write_file(
            &mut r,
            "src/app/jobs/dispatch_webhook_job.rs",
            DISPATCH_JOB,
            d,
        )?;
        write_file(&mut r, "migrations/create_webhook_tables.sql", MIGRATION, d)?;
        r.warnings.push("Set WEBHOOK_SECRET in .env".into());
        r.warnings
            .push("Register POST /webhooks/incoming route".into());
        Ok(r)
    }
}

const CONTROLLER: &str = r#"use axum::{extract::State, response::IntoResponse, Json};
use rok_auth::axum::Response;

pub async fn incoming(Json(payload): Json<serde_json::Value>) -> impl IntoResponse {
    // TODO: route payload to appropriate handler based on event type
    tracing::info!(?payload, "Webhook received");
    Response::json(serde_json::json!({ "received": true }))
}

pub async fn list_deliveries() -> impl IntoResponse {
    // TODO: query webhook_deliveries table
    Response::json(serde_json::json!({ "data": [] }))
}
"#;

const SIGNATURE_MW: &str = r#"use axum::{body::Bytes, extract::Request, middleware::Next, response::Response};
use hmac::{Hmac, Mac};
use sha2::Sha256;
use rok_problem::Problem;

pub async fn verify_webhook_signature(req: Request, next: Next) -> Result<Response, Problem> {
    let secret = std::env::var("WEBHOOK_SECRET").unwrap_or_default();
    // TODO: read X-Webhook-Signature header, compute HMAC-SHA256, compare
    Ok(next.run(req).await)
}
"#;

const DISPATCH_JOB: &str = r#"use rok_queue::Job;
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct DispatchWebhookJob {
    pub endpoint_id: i64,
    pub payload: serde_json::Value,
    pub attempt: u32,
}

#[async_trait::async_trait]
impl Job for DispatchWebhookJob {
    const QUEUE: &'static str = "webhooks";

    async fn handle(&self) -> anyhow::Result<()> {
        // TODO: POST payload to endpoint URL, verify response, log delivery
        Ok(())
    }
}
"#;

const MIGRATION: &str = r#"CREATE TABLE webhook_endpoints (
    id         BIGSERIAL PRIMARY KEY,
    url        TEXT NOT NULL,
    secret     TEXT NOT NULL,
    events     TEXT[] NOT NULL DEFAULT '{}',
    active     BOOLEAN NOT NULL DEFAULT true,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE TABLE webhook_deliveries (
    id          BIGSERIAL PRIMARY KEY,
    endpoint_id BIGINT NOT NULL REFERENCES webhook_endpoints(id) ON DELETE CASCADE,
    payload     JSONB NOT NULL,
    status_code INTEGER,
    response    TEXT,
    attempt     INTEGER NOT NULL DEFAULT 1,
    delivered_at TIMESTAMPTZ,
    created_at  TIMESTAMPTZ NOT NULL DEFAULT now()
);
"#;