wscall 0.1.1

Facade crate for the WSCALL protocol, server, and client
Documentation
use std::sync::Arc;

use serde::Deserialize;
use serde_json::json;
use tokio::sync::Mutex;
use wscall::server::validation::{Validate, non_negative_i32, not_blank, positive_i32};
use wscall::{ApiError, ExceptionContext, FileAttachment, ValidateParams, WscallServer};

const DEMO_CHACHA20_KEY: [u8; 32] = [0x42; 32];

wscall_server::wscall_regex_validator!(
    validate_username,
    r"^[a-zA-Z][a-zA-Z0-9_]{2,15}$",
    "invalid_username"
);

#[derive(Debug, Deserialize)]
struct EchoParams {
    message: String,
    #[serde(default)]
    sample_file: Option<serde_json::Value>,
}

impl ValidateParams for EchoParams {
    fn validate(&self) -> Result<(), ApiError> {
        if self.message.trim().is_empty() {
            return Err(ApiError::bad_request("message cannot be empty"));
        }
        Ok(())
    }
}

#[derive(Debug, Deserialize, Validate)]
struct TypedEchoParams {
    #[validate(custom(function = "not_blank"))]
    message: String,
    #[serde(default)]
    sample_file: Option<serde_json::Value>,
}

#[derive(Debug, Deserialize, Validate)]
struct RegisterUserParams {
    #[validate(custom(function = "not_blank"))]
    display_name: String,
    #[validate(custom(function = "validate_username"))]
    username: String,
    #[validate(email)]
    email: String,
    #[validate(custom(function = "positive_i32"))]
    age: i32,
    #[validate(custom(function = "non_negative_i32"))]
    login_count: i32,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let history = Arc::new(Mutex::new(Vec::<serde_json::Value>::new()));
    let mut server = WscallServer::new().with_chacha20_key(DEMO_CHACHA20_KEY);

    server.on_connected(|ctx| async move {
        println!(
            "client connected: connection_id={}, peer_ip={:?}",
            ctx.connection_id(),
            ctx.peer_ip()
        );
    });

    server.on_disconnected(|ctx| async move {
        println!(
            "client disconnected: connection_id={}, reason={}",
            ctx.connection_id(),
            ctx.reason()
        );
    });

    server.filter(|ctx| async move {
        if ctx.route().trim().is_empty() {
            return Err(ApiError::bad_request("route cannot be empty"));
        }
        Ok(ctx)
    });

    server.exception_handler(|exception: ExceptionContext| async move {
        let details = json!({
            "target": exception.target,
            "request_id": exception.request_id,
            "connection_id": exception.connection_id,
        });

        match exception.error.code.as_str() {
            "not_found" => exception.error.with_details(details).into_payload(),
            _ => ApiError::internal("server failed to process the message")
                .with_details(details)
                .into_payload(),
        }
    });

    server.route("system.echo", |ctx| async move {
        let typed: EchoParams = ctx.bind_and_validate()?;
        let raw_message = ctx
            .require_param("message")?
            .as_str()
            .ok_or_else(|| ApiError::bad_request("message must be a string"))?;

        Ok(json!({
            "route": ctx.route(),
            "params": ctx.params().clone(),
            "message": raw_message,
            "typed_message": typed.message,
            "sample_file": typed.sample_file,
            "attachments": ctx.attachment_summaries(),
        }))
    });

    server.typed_route("system.echo.bound", |ctx, params: EchoParams| async move {
        Ok(json!({
            "route": ctx.route(),
            "typed_message": params.message,
            "sample_file": params.sample_file,
        }))
    });

    server.validated_route(
        "system.echo.typed",
        |ctx, params: TypedEchoParams| async move {
            Ok(json!({
                "route": ctx.route(),
                "message": params.message,
                "sample_file": params.sample_file,
                "request_id": ctx.request_id(),
            }))
        },
    );

    server.validated_route(
        "user.register",
        |_ctx, params: RegisterUserParams| async move {
            Ok(json!({
                "accepted": true,
                "display_name": params.display_name,
                "username": params.username,
                "email": params.email,
                "age": params.age,
                "login_count": params.login_count,
            }))
        },
    );

    server.route("files.inspect", |ctx| async move {
        let files = ctx
            .attachments()
            .iter()
            .map(|attachment| {
                json!({
                    "id": attachment.id,
                    "name": attachment.name,
                    "content_type": attachment.content_type,
                    "size": attachment.size,
                })
            })
            .collect::<Vec<_>>();

        Ok(json!({
            "params": ctx.params().clone(),
            "files": files,
        }))
    });

    let history_for_api = Arc::clone(&history);
    server.route("chat.history", move |_ctx| {
        let history = Arc::clone(&history_for_api);
        async move {
            let items = history.lock().await.clone();
            Ok(json!({ "messages": items }))
        }
    });

    let history_for_event = Arc::clone(&history);
    server.event_handler("chat.message", move |ctx| {
        let history = Arc::clone(&history_for_event);
        async move {
            let record = json!({
                "from": ctx
                    .metadata()
                    .get("client_name")
                    .and_then(|value| value.as_str())
                    .unwrap_or("anonymous"),
                "message": ctx
                    .data()
                    .get("message")
                    .and_then(|value| value.as_str())
                    .unwrap_or(""),
                "connection_id": ctx.connection_id(),
            });

            history.lock().await.push(record.clone());
            ctx.server()
                .broadcast_event("chat.message", record.clone(), Vec::<FileAttachment>::new())
                .await?;

            Ok(json!({ "stored": true, "echo": record }))
        }
    });

    server.listen("127.0.0.1:9001").await?;
    Ok(())
}