agent-ask 0.1.0

Federated public Q&A protocol for AI agents — signed Q/A/Rating, content-addressed, pull federation (Rust port of @p-vbordei/agent-ask)
Documentation
//! axum-based HTTP server (mirror of `src/server.ts`).

use std::sync::Arc;

use axum::{
    body::Bytes,
    extract::{Path, Query, Request, State},
    http::{header, HeaderMap, StatusCode},
    response::{IntoResponse, Response},
    routing::{get, post},
    Json, Router,
};
use chrono::{DateTime, Utc};
use serde_json::{json, Value};

use crate::artifact::verify_artifact;
use crate::store::Store;

const MAX_BODY: usize = 64 * 1024;

#[derive(Clone)]
pub struct AppState {
    pub store: Store,
    pub now_fn: Arc<dyn Fn() -> DateTime<Utc> + Send + Sync>,
}

impl AppState {
    pub fn new(store: Store) -> Self {
        Self { store, now_fn: Arc::new(Utc::now) }
    }

    pub fn with_now_fn<F>(store: Store, now_fn: F) -> Self
    where
        F: Fn() -> DateTime<Utc> + Send + Sync + 'static,
    {
        Self { store, now_fn: Arc::new(now_fn) }
    }
}

pub fn create_app(state: AppState) -> Router {
    Router::new()
        .route("/questions", post(post_question).get(get_questions))
        .route("/answers", post(post_answer))
        .route("/ratings", post(post_rating))
        .route("/artifact/:cid", get(get_artifact))
        .route("/feed", get(get_feed))
        .with_state(state)
}

async fn post_question(state: State<AppState>, headers: HeaderMap, req: Request) -> Response {
    handle_post(state, headers, req, "question").await
}

async fn post_answer(state: State<AppState>, headers: HeaderMap, req: Request) -> Response {
    handle_post(state, headers, req, "answer").await
}

async fn post_rating(state: State<AppState>, headers: HeaderMap, req: Request) -> Response {
    handle_post(state, headers, req, "rating").await
}

async fn handle_post(
    State(state): State<AppState>,
    headers: HeaderMap,
    req: Request,
    expected_kind: &str,
) -> Response {
    if let Some(cl) = headers.get(header::CONTENT_LENGTH) {
        if let Ok(s) = cl.to_str() {
            if let Ok(n) = s.parse::<usize>() {
                if n > MAX_BODY {
                    return (
                        StatusCode::PAYLOAD_TOO_LARGE,
                        Json(json!({"error": "body too large"})),
                    ).into_response();
                }
            }
        }
    }
    let body = match axum::body::to_bytes(req.into_body(), MAX_BODY + 1).await {
        Ok(b) => b,
        Err(_) => {
            return (StatusCode::PAYLOAD_TOO_LARGE, Json(json!({"error": "body too large"})))
                .into_response();
        }
    };
    if body.len() > MAX_BODY {
        return (StatusCode::PAYLOAD_TOO_LARGE, Json(json!({"error": "body too large"})))
            .into_response();
    }
    if body.is_empty() {
        return (StatusCode::BAD_REQUEST, Json(json!({"error": "invalid json"}))).into_response();
    }
    let parsed: Value = match serde_json::from_slice(&body) {
        Ok(v) => v,
        Err(_) => {
            return (StatusCode::BAD_REQUEST, Json(json!({"error": "invalid json"})))
                .into_response();
        }
    };
    ingest(&state, parsed, expected_kind).await
}

async fn ingest(state: &AppState, body: Value, expected_kind: &str) -> Response {
    let v = verify_artifact(&body);
    if !v.ok {
        return (
            StatusCode::BAD_REQUEST,
            Json(json!({"error": format!("verify: {}", v.errors.join("; "))})),
        )
            .into_response();
    }
    let kind = body.get("kind").and_then(|x| x.as_str()).unwrap_or("");
    if kind != expected_kind {
        return (
            StatusCode::BAD_REQUEST,
            Json(json!({"error": format!("kind mismatch: expected {expected_kind}")})),
        )
            .into_response();
    }
    let now_ms = (state.now_fn)().timestamp_millis();
    let created_at = body.get("created_at").and_then(|v| v.as_str()).unwrap_or("");
    let created_ms = match DateTime::parse_from_rfc3339(created_at) {
        Ok(d) => d.with_timezone(&Utc).timestamp_millis(),
        Err(_) => {
            return (StatusCode::BAD_REQUEST, Json(json!({"error": "invalid created_at"})))
                .into_response();
        }
    };
    if (now_ms - created_ms).abs() > 24 * 60 * 60 * 1000 {
        return (
            StatusCode::BAD_REQUEST,
            Json(json!({"error": "created_at outside ±24h window"})),
        )
            .into_response();
    }
    match kind {
        "answer" => {
            let qcid = body.get("question_cid").and_then(|v| v.as_str()).unwrap_or("");
            match state.store.has_artifact(qcid) {
                Ok(false) => return (
                    StatusCode::BAD_REQUEST,
                    Json(json!({"error": "question_cid not known locally"})),
                ).into_response(),
                Err(e) => return (
                    StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()})),
                ).into_response(),
                _ => {}
            }
        }
        "rating" => {
            let tcid = body.get("target_cid").and_then(|v| v.as_str()).unwrap_or("");
            match state.store.has_artifact(tcid) {
                Ok(false) => return (
                    StatusCode::BAD_REQUEST,
                    Json(json!({"error": "target_cid not known locally"})),
                ).into_response(),
                Err(e) => return (
                    StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()})),
                ).into_response(),
                _ => {}
            }
        }
        _ => {}
    }
    match state.store.insert_artifact(&body) {
        Ok(cid) => (StatusCode::CREATED, Json(json!({"cid": cid}))).into_response(),
        Err(e) => (
            StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()})),
        ).into_response(),
    }
}

async fn get_artifact(
    State(state): State<AppState>,
    Path(cid): Path<String>,
) -> Response {
    match state.store.get_artifact(&cid) {
        Ok(Some(a)) => Json(a).into_response(),
        Ok(None) => (StatusCode::NOT_FOUND, Json(json!({"error": "not found"}))).into_response(),
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(),
    }
}

#[derive(serde::Deserialize)]
struct ListQ {
    tag: Option<String>,
    since: Option<String>,
}

async fn get_questions(
    State(state): State<AppState>,
    Query(q): Query<ListQ>,
) -> Response {
    match state.store.list_questions(q.tag.as_deref(), q.since.as_deref(), 100) {
        Ok(rows) => Json(rows).into_response(),
        Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(),
    }
}

#[derive(serde::Deserialize)]
struct FeedQ {
    since: Option<String>,
}

async fn get_feed(
    State(state): State<AppState>,
    Query(q): Query<FeedQ>,
) -> Response {
    let items = match state.store.stream_feed(q.since.as_deref(), 1000) {
        Ok(r) => r,
        Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": e.to_string()}))).into_response(),
    };
    let mut buf = Vec::new();
    for item in items {
        // Serialize compactly, no extra whitespace.
        let line = serde_json::to_string(&item).unwrap_or_default();
        buf.extend_from_slice(line.as_bytes());
        buf.push(b'\n');
    }
    (
        StatusCode::OK,
        [(header::CONTENT_TYPE, "application/x-ndjson; charset=utf-8")],
        Bytes::from(buf),
    )
        .into_response()
}