crabmate 0.5.0

Rust AI agent: OpenAI-compatible chat/completions, function calling, HTTP serve, ops CLI
Documentation
//! `GET /openapi.json`:OpenAPI 3.0 机器可读契约(与 `server.rs` 路由对齐;**不**替代 `docs/SSE协议.md` 对 SSE 行级语义的说明)。

mod openapi_components;
mod openapi_components_user_data;
mod openapi_paths;
mod openapi_paths_chat_stream;
mod openapi_paths_tool_jobs;
mod openapi_paths_user_data;
mod openapi_paths_user_data_mcp;
mod openapi_paths_workspace;
#[cfg(test)]
mod route_table;

use serde_json::{Value, json};

use openapi_components::openapi_components_value;
use openapi_paths::openapi_paths_value;

/// 构建与当前 `serve` 路由表一致的 OpenAPI 文档(不含静态 `/` SPA)。
pub fn build_openapi_spec() -> Value {
    let version = env!("CARGO_PKG_VERSION");
    json!({
        "openapi": "3.0.3",
        "info": {
            "title": "CrabMate Web API",
            "version": version,
            "description": concat!(
                "CrabMate `serve` 模式的 HTTP 契约摘要。\n\n",
                "- **鉴权**:嵌入默认 **`web_api_require_bearer=false`**:允许无共享密钥启动 **`serve`**;若将 **`web_api_require_bearer=true`**,则启动前须配置非空 **`CM_WEB_API_BEARER_TOKEN`**(或 TOML **`web_api_bearer_token`**)。进程启动且密钥**非空**时,下列需鉴权路径须在请求头携带 **`Authorization: Bearer <token>`** 或 **`X-API-Key: <token>`**(与配置值为**同一密钥**,二选一)。密钥为空时中间件不校验,路径可对能访问监听地址的客户端匿名访问(仅限可信环境)。\n",
                "- **SSE**:`POST /chat/stream` 返回 `text/event-stream`;控制面 JSON 与错误码见仓库 `docs/SSE协议.md`,本 OpenAPI 仅作入口说明。\n",
                "- **上传**:`POST /upload` 使用 `multipart/form-data`。"
            )
        },
        "tags": [
            { "name": "chat", "description": "对话与流式 SSE" },
            { "name": "workspace", "description": "工作区浏览与文件" },
            { "name": "system", "description": "健康检查与状态" },
            { "name": "tasks", "description": "进程内任务清单" },
            { "name": "tool_jobs", "description": "后台工具任务轮询与取消" },
            { "name": "config", "description": "配置热重载" },
            { "name": "user_data", "description": "本机用户数据(~/.local/share/crabmate)" },
            { "name": "uploads", "description": "上传与删除" }
        ],
        "paths": openapi_paths_value(),
        "components": openapi_components_value(),
    })
}

/// Axum handler:`application/json` OpenAPI 文档。
pub(crate) async fn openapi_json_handler() -> axum::Json<Value> {
    axum::Json(build_openapi_spec())
}

#[cfg(test)]
mod tests {
    use super::*;
    use std::path::Path;

    #[test]
    fn openapi_ops_match_axum_route_source() {
        let spec = build_openapi_spec();
        let documented = super::route_table::openapi_path_ops(&spec);
        let mounted =
            super::route_table::axum_route_ops_from_source(Path::new(env!("CARGO_MANIFEST_DIR")));
        let missing: Vec<_> = mounted.difference(&documented).cloned().collect();
        let extra: Vec<_> = documented.difference(&mounted).cloned().collect();
        assert!(
            missing.is_empty() && extra.is_empty(),
            "OpenAPI path+method must match axum `.route(` in src/web/routes, src/web/server.rs, cm_web_host web_ui (not e2e/static)\nmissing from OpenAPI: {missing:?}\nextra in OpenAPI: {extra:?}"
        );
        assert!(
            documented.iter().all(|(path, _)| !path.starts_with("/e2e/")),
            "E2E fixture routes must not appear in OpenAPI"
        );
    }

    #[test]
    fn openapi_spec_has_core_paths_and_version() {
        let v = build_openapi_spec();
        assert_eq!(v["openapi"], "3.0.3");
        let paths = v["paths"].as_object().expect("paths object");
        assert!(paths.contains_key("/health"));
        assert!(paths.contains_key("/web-ui"));
        assert!(paths.contains_key("/chat/stream"));
        assert!(paths.contains_key("/chat/stream/{job_id}/cancel"));
        assert!(paths.contains_key("/chat/async"));
        assert!(paths.contains_key("/chat/jobs/{job_id}"));
        assert!(paths.contains_key("/tools/jobs/{tool_job_id}"));
        assert!(paths.contains_key("/tools/jobs/{tool_job_id}/cancel"));
        assert!(paths.contains_key("/conversation/messages"));
        assert!(paths.contains_key("/openapi.json"));
        assert!(paths.contains_key("/user-data/prefs"));
        assert!(paths.contains_key("/user-data/workspaces/current/sessions"));
        assert!(v["components"]["securitySchemes"]["bearerAuth"].is_object());
        assert!(v["components"]["securitySchemes"]["apiKeyAuth"].is_object());
        let schemas = v["components"]["schemas"]
            .as_object()
            .expect("schemas object");
        assert!(
            schemas.contains_key("ChatRequestBody"),
            "ChatRequestBody from contract"
        );
        assert!(
            schemas.contains_key("WebUiConfigResponse"),
            "WebUiConfigResponse from contract"
        );
    }
}