mixi2 0.1.1

Async Rust SDK for the mixi2 Application API.
Documentation
use std::{
    env,
    error::Error,
    io::{Error as IoError, ErrorKind},
    net::SocketAddr,
    sync::Arc,
};

use async_trait::async_trait;
use axum::{
    Router,
    body::Bytes,
    extract::{Request, State},
    http::{HeaderMap, StatusCode},
    middleware::{self, Next},
    response::{IntoResponse, Response},
    routing::{get, post},
};
use base64::{Engine as _, engine::general_purpose::STANDARD as BASE64_STANDARD};
use ed25519_dalek::VerifyingKey;
use mixi2::{
    BoxError, EventHandler, WebhookService,
    social::mixi::application::{r#const::v1::EventType, model::v1::Event},
};
use tokio::net::TcpListener;

struct PrintHandler;

#[async_trait]
impl EventHandler for PrintHandler {
    async fn handle(&self, event: &Event) -> Result<(), BoxError> {
        println!(
            "received event: event_type={}",
            event_type_label(event.event_type)
        );
        Ok(())
    }
}

#[derive(Clone)]
struct AppState {
    handler: Arc<PrintHandler>,
    service: WebhookService<PrintHandler>,
}

async fn log_non_event_requests(req: Request, next: Next) -> Response {
    let method = req.method().clone();
    let path = req.uri().path().to_owned();
    let response = next.run(req).await;

    if path != "/events" {
        println!("webhook request: {method} {path} -> {}", response.status());
    }

    response
}

fn parse_public_key(value: &str) -> Result<VerifyingKey, Box<dyn Error>> {
    let bytes = BASE64_STANDARD.decode(value).map_err(|error| {
        IoError::new(
            ErrorKind::InvalidInput,
            format!("MIXI2_WEBHOOK_PUBLIC_KEY must be base64: {error}"),
        )
    })?;
    let bytes: [u8; 32] = bytes.as_slice().try_into().map_err(|_slice| {
        IoError::new(
            ErrorKind::InvalidInput,
            "MIXI2_WEBHOOK_PUBLIC_KEY must decode to a 32-byte Ed25519 public key",
        )
    })?;

    VerifyingKey::from_bytes(&bytes).map_err(|error| {
        Box::new(IoError::new(
            ErrorKind::InvalidInput,
            format!("MIXI2_WEBHOOK_PUBLIC_KEY is not a valid Ed25519 public key: {error}"),
        )) as Box<dyn Error>
    })
}

async fn healthz_handler() -> StatusCode {
    StatusCode::OK
}

async fn webhook_handler(
    State(state): State<AppState>,
    headers: HeaderMap,
    body: Bytes,
) -> Response {
    let timestamp = header_value(&headers, "x-mixi2-application-event-timestamp");
    let signature = header_value(&headers, "x-mixi2-application-event-signature");
    let signature_length = if signature == "unavailable" {
        String::from("unavailable")
    } else {
        signature.len().to_string()
    };
    let body_hex = hex::encode(&body);
    let body_base64 = BASE64_STANDARD.encode(&body);

    match state.service.verify_and_decode(&headers, &body) {
        Ok(events) => {
            let event_types = if events.is_empty() {
                String::from("[]")
            } else {
                let labels = events
                    .iter()
                    .map(|event| event_type_label(event.event_type))
                    .collect::<Vec<_>>();
                format!("[{}]", labels.join(", "))
            };
            println!(
                "webhook request: POST /events -> 204 No Content, timestamp={timestamp}, signature={signature}, signature.len()={signature_length}, body.len()={}, body.hex={body_hex}, body.base64={body_base64}, events.len()={}, event_type={event_types}",
                body.len(),
                events.len(),
            );

            for event in events {
                if event.event_type == EventType::Ping as i32 {
                    continue;
                }

                if let Err(error) = state.handler.handle(&event).await {
                    eprintln!("failed to handle event: {error}");
                }
            }

            StatusCode::NO_CONTENT.into_response()
        }
        Err(error) => {
            let error_message = error.to_string();
            let response = error.into_response();
            println!(
                "webhook request: POST /events -> {}, reason={error_message}, timestamp={timestamp}, signature={signature}, signature.len()={signature_length}, body.len()={}, body.hex={body_hex}, body.base64={body_base64}, events.len()=unavailable, event_type=unavailable",
                response.status(),
                body.len(),
            );
            response
        }
    }
}

fn event_type_label(event_type: i32) -> String {
    EventType::try_from(event_type).map_or_else(
        |_error| format!("Unknown({event_type})"),
        |event_type| format!("{event_type:?}({})", event_type as i32),
    )
}

fn header_value(headers: &HeaderMap, name: &str) -> String {
    headers
        .get(name)
        .and_then(|value| value.to_str().ok())
        .map_or_else(|| String::from("unavailable"), ToOwned::to_owned)
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
    dotenvy::dotenv().ok();

    let public_key = parse_public_key(&env::var("MIXI2_WEBHOOK_PUBLIC_KEY")?)?;
    let address: SocketAddr = env::var("MIXI2_WEBHOOK_ADDR")?.parse()?;

    let handler = Arc::new(PrintHandler);
    let service = WebhookService::new(public_key, Arc::clone(&handler));
    let state = AppState { handler, service };
    let app = Router::new()
        .route("/healthz", get(healthz_handler))
        .route("/events", post(webhook_handler))
        .layer(middleware::from_fn(log_non_event_requests))
        .with_state(state);
    let listener = TcpListener::bind(address).await?;
    println!("webhook server listening on http://{address}");
    axum::serve(listener, app).await?;

    Ok(())
}