pidgin-server 0.2.0

HTTP server for the Pidgin agent handoff protocol
Documentation
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;

use axum::Router;
use axum::extract::{Json, State};
use axum::http::StatusCode;
use axum::routing::{get, post};
use serde::Serialize;
use tokio::net::TcpListener;

use pidgin_lang::ast::{FieldValue, PgnPacket};
use pidgin_lang::expander::expand_to_run_packet;
use pidgin_lang::parser::parse_packet;
use pidgin_lang::registry::{
    ActionRegistry, SafetyRules, WorkflowRegistry, load_action_registry, load_safety_rules,
    load_workflow_registry,
};
use pidgin_lang::resolver::{
    ReferenceAliases, ResolutionStatus, ResolvedRef, ResolverContext, load_aliases, resolve_all,
};
use pidgin_lang::router::{explain_route, route};
use pidgin_lang::safety::{SafetyResult, check_resolved_refs_safety, check_safety};
use pidgin_lang::validator::schema::validate_schema;
use pidgin_lang::validator::syntax::validate_syntax;

pub struct AppState {
    pub host_root: PathBuf,
    pub workflows: WorkflowRegistry,
    pub actions: ActionRegistry,
    pub safety_rules: SafetyRules,
    pub aliases: ReferenceAliases,
}

impl AppState {
    pub fn load(host: &Path) -> Result<Self, ServerError> {
        let config_dir = host.join(".pidgin");
        Ok(Self {
            host_root: host
                .canonicalize()
                .map_err(|e| ServerError::Config(format!("cannot canonicalize host: {}", e)))?,
            workflows: load_workflow_registry(&config_dir.join("WORKFLOW_REGISTRY.yaml"))
                .map_err(|e| ServerError::Config(format!("workflow registry: {}", e)))?,
            actions: load_action_registry(&config_dir.join("ACTION_REGISTRY.yaml"))
                .map_err(|e| ServerError::Config(format!("action registry: {}", e)))?,
            safety_rules: load_safety_rules(&config_dir.join("SAFETY_RULES.yaml"))
                .map_err(|e| ServerError::Config(format!("safety rules: {}", e)))?,
            aliases: load_aliases(&config_dir.join("REFERENCE_ALIASES.yaml"))
                .map_err(|e| ServerError::Config(format!("reference aliases: {}", e)))?,
        })
    }
}

#[derive(Debug, thiserror::Error)]
pub enum ServerError {
    #[error("configuration error: {0}")]
    Config(String),
    #[error("internal error: {0}")]
    Internal(String),
}

#[derive(Debug, Serialize)]
pub struct ApiResponse<T: Serialize> {
    pub ok: bool,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub data: Option<T>,
    #[serde(skip_serializing_if = "Option::is_none")]
    pub error: Option<String>,
}

impl<T: Serialize> ApiResponse<T> {
    pub fn success(data: T) -> Self {
        Self {
            ok: true,
            data: Some(data),
            error: None,
        }
    }

    pub fn error(msg: String) -> Self {
        Self {
            ok: false,
            data: None,
            error: Some(msg),
        }
    }
}

#[derive(Debug, Serialize)]
pub struct CheckData {
    pub passed: bool,
    pub validation_errors: Vec<String>,
    pub safety_blocked: bool,
    pub safety_rules: Vec<String>,
    pub resolution: Vec<serde_json::Value>,
}

#[derive(Debug, Serialize)]
pub struct ExpandedData {
    pub yaml: String,
    pub route: String,
}

#[derive(Debug, Serialize)]
pub struct RunData {
    pub yaml: String,
    pub route: String,
    pub resolution: Vec<serde_json::Value>,
}

struct CheckedPacket {
    packet: PgnPacket,
    safety: SafetyResult,
    safety_rules: Vec<String>,
    validation_errors: Vec<String>,
    resolved: Vec<ResolvedRef>,
}

type ApiResult<T> = Result<Json<ApiResponse<T>>, (StatusCode, Json<ApiResponse<T>>)>;

fn api_error<T: Serialize>(
    status: StatusCode,
    msg: impl Into<String>,
) -> (StatusCode, Json<ApiResponse<T>>) {
    (status, Json(ApiResponse::error(msg.into())))
}

fn fields_json(packet: &PgnPacket) -> serde_json::Value {
    packet
        .fields
        .iter()
        .map(|(k, v)| {
            let val = match v {
                FieldValue::Scalar(s) => serde_json::Value::String(s.clone()),
                FieldValue::List(items) => serde_json::Value::Array(
                    items
                        .iter()
                        .cloned()
                        .map(serde_json::Value::String)
                        .collect(),
                ),
            };
            (k.clone(), val)
        })
        .collect()
}

fn required_inputs(packet: &PgnPacket, state: &AppState) -> Vec<String> {
    packet
        .fields
        .get("wf")
        .and_then(|v| match v {
            FieldValue::Scalar(s) => state.workflows.workflows.get(s),
            _ => None,
        })
        .map(|w| w.required_inputs.clone())
        .unwrap_or_default()
}

fn resolve_packet(packet: &PgnPacket, state: &AppState) -> Vec<ResolvedRef> {
    let ctx = ResolverContext {
        host_root: state.host_root.clone(),
        aliases: state.aliases.clone(),
        required_inputs: required_inputs(packet, state),
    };
    resolve_all(packet, &ctx)
}

fn resolution_json(resolved: &[ResolvedRef]) -> Vec<serde_json::Value> {
    resolved
        .iter()
        .map(|r| {
            serde_json::json!({
                "original": r.original,
                "namespace": r.namespace,
                "ref_id": r.ref_id,
                "status": match r.status {
                    ResolutionStatus::Resolved => "RESOLVED",
                    ResolutionStatus::Missing => "MISSING",
                    ResolutionStatus::Unresolved => "UNRESOLVED",
                    ResolutionStatus::Forbidden => "FORBIDDEN",
                },
                "required": r.required,
                "path": r.resolved_path.as_ref().map(|p| p.display().to_string()),
            })
        })
        .collect()
}

fn parse_and_check(state: &AppState, body: &str) -> Result<CheckedPacket, (StatusCode, String)> {
    let packet =
        parse_packet(body).map_err(|e| (StatusCode::BAD_REQUEST, format!("parse error: {}", e)))?;

    let mut errors = validate_syntax(&packet);
    errors.extend(validate_schema(&packet, &state.workflows));
    let validation_errors: Vec<String> = errors
        .iter()
        .map(|e| format!("[{}] {}", e.code, e.message))
        .collect();

    let safety = check_safety(
        &packet,
        &state.actions,
        &state.safety_rules,
        &state.workflows,
    );
    let safety_rules = safety.fired_rules.iter().map(|r| r.to_string()).collect();
    let resolved = resolve_packet(&packet, state);
    let resolved_fired = check_resolved_refs_safety(&resolved, &state.safety_rules.private_paths);
    if !resolved_fired.is_empty() {
        let rules = resolved_fired
            .iter()
            .map(|r| r.to_string())
            .collect::<Vec<_>>();
        return Err((
            StatusCode::FORBIDDEN,
            format!("safety after resolution: {}", rules.join(", ")),
        ));
    }

    Ok(CheckedPacket {
        packet,
        safety,
        safety_rules,
        validation_errors,
        resolved,
    })
}

async fn health(State(state): State<Arc<AppState>>) -> Json<ApiResponse<serde_json::Value>> {
    Json(ApiResponse::success(serde_json::json!({
        "status": "ok",
        "version": env!("CARGO_PKG_VERSION"),
        "host": state.host_root.display().to_string(),
    })))
}

async fn parse_handler(
    State(_state): State<Arc<AppState>>,
    body: String,
) -> ApiResult<serde_json::Value> {
    let packet = parse_packet(&body)
        .map_err(|e| api_error(StatusCode::BAD_REQUEST, format!("parse error: {}", e)))?;

    Ok(Json(ApiResponse::success(serde_json::json!({
        "run_id": packet.run_id,
        "directive": packet.directive.directive_name(),
        "field_count": packet.fields.len(),
        "fields": fields_json(&packet),
    }))))
}

async fn check_handler(State(state): State<Arc<AppState>>, body: String) -> ApiResult<CheckData> {
    let checked = parse_and_check(&state, &body).map_err(|(status, msg)| api_error(status, msg))?;

    Ok(Json(ApiResponse::success(CheckData {
        passed: checked.validation_errors.is_empty() && !checked.safety.blocked,
        validation_errors: checked.validation_errors,
        safety_blocked: checked.safety.blocked,
        safety_rules: checked.safety_rules,
        resolution: resolution_json(&checked.resolved),
    })))
}

async fn expand_handler(
    State(state): State<Arc<AppState>>,
    body: String,
) -> ApiResult<ExpandedData> {
    let checked = parse_and_check(&state, &body).map_err(|(status, msg)| api_error(status, msg))?;
    if !checked.validation_errors.is_empty() {
        return Err(api_error(
            StatusCode::BAD_REQUEST,
            format!("validation: {}", checked.validation_errors.join("; ")),
        ));
    }

    let decision = route(&checked.packet, &state.workflows, &checked.safety);
    let expanded = expand_to_run_packet(
        &checked.packet,
        &checked.resolved,
        &checked.safety,
        &state.workflows,
    );
    let yaml = serde_yaml::to_string(&expanded).map_err(|e| {
        api_error(
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("serialization: {}", e),
        )
    })?;

    Ok(Json(ApiResponse::success(ExpandedData {
        yaml,
        route: explain_route(&decision),
    })))
}

async fn run_handler(State(state): State<Arc<AppState>>, body: String) -> ApiResult<RunData> {
    let checked = parse_and_check(&state, &body).map_err(|(status, msg)| api_error(status, msg))?;
    if !checked.validation_errors.is_empty() {
        return Err(api_error(
            StatusCode::BAD_REQUEST,
            format!("validation: {}", checked.validation_errors.join("; ")),
        ));
    }
    if checked.safety.blocked {
        return Err(api_error(
            StatusCode::FORBIDDEN,
            format!("safety blocked: {}", checked.safety_rules.join(", ")),
        ));
    }

    let decision = route(&checked.packet, &state.workflows, &checked.safety);
    let expanded = expand_to_run_packet(
        &checked.packet,
        &checked.resolved,
        &checked.safety,
        &state.workflows,
    );
    let yaml = serde_yaml::to_string(&expanded).map_err(|e| {
        api_error(
            StatusCode::INTERNAL_SERVER_ERROR,
            format!("serialization: {}", e),
        )
    })?;

    Ok(Json(ApiResponse::success(RunData {
        yaml,
        route: explain_route(&decision),
        resolution: resolution_json(&checked.resolved),
    })))
}

pub fn build_router(state: Arc<AppState>) -> Router {
    Router::new()
        .route("/api/v1/health", get(health))
        .route("/api/v1/parse", post(parse_handler))
        .route("/api/v1/check", post(check_handler))
        .route("/api/v1/expand", post(expand_handler))
        .route("/api/v1/run", post(run_handler))
        .with_state(state)
}

pub async fn serve(bind: SocketAddr, host: PathBuf) -> Result<(), ServerError> {
    let app = build_router(Arc::new(AppState::load(&host)?));
    let listener = TcpListener::bind(bind)
        .await
        .map_err(|e| ServerError::Internal(format!("bind error: {}", e)))?;

    axum::serve(listener, app)
        .await
        .map_err(|e| ServerError::Internal(format!("server error: {}", e)))
}