guts_node/
api.rs

1//! # Core HTTP API
2//!
3//! This module provides the main HTTP API for the Guts node, including:
4//!
5//! - **Git Smart HTTP Protocol**: Standard Git endpoints for clone, push, and pull
6//! - **Repository Management**: CRUD operations for repositories
7//! - **Health Checks**: Comprehensive liveness, readiness, and startup probes
8//! - **Metrics**: Prometheus metrics endpoint
9//!
10//! ## Endpoint Overview
11//!
12//! | Method | Path | Description |
13//! |--------|------|-------------|
14//! | GET | `/health` | Overall health status |
15//! | GET | `/health/live` | Liveness probe |
16//! | GET | `/health/ready` | Readiness probe |
17//! | GET | `/health/startup` | Startup probe |
18//! | GET | `/metrics` | Prometheus metrics |
19//! | GET | `/api/repos` | List all repositories |
20//! | POST | `/api/repos` | Create a new repository |
21//! | GET | `/api/repos/{owner}/{name}` | Get repository details |
22//! | GET | `/git/{owner}/{name}/info/refs` | Git reference advertisement |
23//! | POST | `/git/{owner}/{name}/git-upload-pack` | Git fetch/clone |
24//! | POST | `/git/{owner}/{name}/git-receive-pack` | Git push |
25//!
26//! ## Git Smart HTTP Protocol
27//!
28//! The node implements Git's Smart HTTP protocol, enabling standard Git clients
29//! to interact with repositories:
30//!
31//! ```bash
32//! # Clone a repository
33//! git clone http://localhost:8080/git/alice/myrepo
34//!
35//! # Push changes
36//! git push origin main
37//!
38//! # Fetch updates
39//! git fetch origin
40//! ```
41//!
42//! ## Application State
43//!
44//! All handlers share an [`AppState`] containing:
45//!
46//! - `repos`: Repository storage (Git objects and refs)
47//! - `collaboration`: Pull requests, issues, comments storage
48//! - `auth`: Organizations, teams, permissions storage
49//! - `p2p`: Optional P2P manager for network replication
50//! - `realtime`: Event hub for WebSocket real-time updates
51//!
52//! ## Error Handling
53//!
54//! Errors are returned as JSON with appropriate HTTP status codes:
55//!
56//! ```json
57//! {
58//!   "error": "repository not found: alice/myrepo"
59//! }
60//! ```
61//!
62//! | Status | Meaning |
63//! |--------|---------|
64//! | 404 | Repository not found |
65//! | 409 | Repository already exists |
66//! | 422 | Validation error |
67//! | 500 | Internal server error |
68
69use axum::{
70    body::Body,
71    extract::{Path, State},
72    http::{header, StatusCode},
73    middleware,
74    response::{IntoResponse, Response},
75    routing::{get, post},
76    Json, Router,
77};
78use guts_auth::AuthStore;
79use guts_collaboration::CollaborationStore;
80use guts_git::{advertise_refs, receive_pack, upload_pack};
81use guts_realtime::{EventHub, EventKind};
82use guts_storage::{Reference, StorageError};
83use serde::{Deserialize, Serialize};
84use std::collections::HashMap;
85use std::io::Cursor;
86use std::sync::Arc;
87use tower_http::trace::TraceLayer;
88use validator::Validate;
89
90use crate::auth_api::auth_routes;
91use crate::ci_api::ci_routes;
92use crate::collaboration_api::collaboration_routes;
93use crate::compat_api::compat_routes;
94use crate::consensus_api::consensus_routes;
95use crate::health::{health_routes, HealthState};
96use crate::observability::middleware::{
97    metrics_handler, metrics_middleware, request_id_middleware,
98};
99use crate::p2p::P2PManager;
100use crate::realtime_api::realtime_routes;
101use crate::validation::validate_name;
102use guts_ci::CiStore;
103use guts_compat::CompatStore;
104use guts_consensus::{ConsensusEngine, Mempool};
105
106/// Re-export RepoStore for external use.
107pub use guts_storage::RepoStore;
108
109/// Application state shared across handlers.
110#[derive(Clone)]
111pub struct AppState {
112    /// Repository store.
113    pub repos: Arc<RepoStore>,
114    /// Optional P2P manager for replication.
115    pub p2p: Option<Arc<P2PManager>>,
116    /// Optional consensus engine for BFT consensus.
117    pub consensus: Option<Arc<ConsensusEngine>>,
118    /// Optional mempool for pending transactions.
119    pub mempool: Option<Arc<Mempool>>,
120    /// Collaboration store for PRs, Issues, etc.
121    pub collaboration: Arc<CollaborationStore>,
122    /// Authorization store for permissions, organizations, etc.
123    pub auth: Arc<AuthStore>,
124    /// Real-time event hub for WebSocket connections.
125    pub realtime: Arc<EventHub>,
126    /// CI/CD store for workflows, runs, artifacts, and status checks.
127    pub ci: Arc<CiStore>,
128    /// Compatibility store for users, tokens, SSH keys, releases.
129    pub compat: Arc<CompatStore>,
130}
131
132impl axum::extract::FromRef<AppState> for guts_web::WebState {
133    fn from_ref(app_state: &AppState) -> Self {
134        guts_web::WebState {
135            repos: app_state.repos.clone(),
136            collaboration: app_state.collaboration.clone(),
137            auth: app_state.auth.clone(),
138            ci: app_state.ci.clone(),
139            consensus: app_state.consensus.clone(),
140            mempool: app_state.mempool.clone(),
141        }
142    }
143}
144
145/// API error type.
146#[derive(Debug, thiserror::Error)]
147pub enum ApiError {
148    #[error("repository not found: {0}")]
149    RepoNotFound(String),
150    #[error("repository already exists: {0}")]
151    RepoExists(String),
152    #[error("git error: {0}")]
153    Git(#[from] guts_git::GitError),
154    #[error("storage error: {0}")]
155    Storage(StorageError),
156    #[error("bad request: {0}")]
157    BadRequest(String),
158    #[error("validation error: {0}")]
159    Validation(String),
160}
161
162impl From<StorageError> for ApiError {
163    fn from(err: StorageError) -> Self {
164        match &err {
165            StorageError::RepoNotFound(key) => ApiError::RepoNotFound(key.clone()),
166            StorageError::RepoExists(key) => ApiError::RepoExists(key.clone()),
167            _ => ApiError::Storage(err),
168        }
169    }
170}
171
172impl IntoResponse for ApiError {
173    fn into_response(self) -> Response {
174        let (status, message) = match &self {
175            ApiError::RepoNotFound(_) => (StatusCode::NOT_FOUND, self.to_string()),
176            ApiError::RepoExists(_) => (StatusCode::CONFLICT, self.to_string()),
177            ApiError::Git(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
178            ApiError::Storage(_) => (StatusCode::INTERNAL_SERVER_ERROR, self.to_string()),
179            ApiError::BadRequest(_) => (StatusCode::BAD_REQUEST, self.to_string()),
180            ApiError::Validation(_) => (StatusCode::UNPROCESSABLE_ENTITY, self.to_string()),
181        };
182
183        tracing::warn!(
184            error_type = %std::any::type_name::<Self>(),
185            error = %message,
186            status = %status.as_u16(),
187            "API error"
188        );
189
190        (status, Json(ErrorResponse { error: message })).into_response()
191    }
192}
193
194#[derive(Serialize)]
195struct ErrorResponse {
196    error: String,
197}
198
199/// Repository info for listing.
200#[derive(Serialize, Deserialize)]
201pub struct RepoInfo {
202    pub name: String,
203    pub owner: String,
204}
205
206/// Request to create a repository.
207#[derive(Deserialize, Validate)]
208pub struct CreateRepoRequest {
209    #[validate(length(min = 1, max = 100))]
210    pub name: String,
211    #[validate(length(min = 1, max = 100))]
212    pub owner: String,
213}
214
215/// Creates the API router with health state.
216pub fn create_router(state: AppState, health_state: HealthState) -> Router {
217    Router::new()
218        // Metrics endpoint (on main router for now)
219        .route("/metrics", get(metrics_handler))
220        // Repository management
221        .route("/api/repos", get(list_repos).post(create_repo))
222        .route("/api/repos/{owner}/{name}", get(get_repo))
223        // Git smart HTTP protocol (using /git/ prefix to avoid axum path parameter limitations)
224        .route("/git/{owner}/{name}/info/refs", get(git_info_refs))
225        .route("/git/{owner}/{name}/git-upload-pack", post(git_upload_pack))
226        .route(
227            "/git/{owner}/{name}/git-receive-pack",
228            post(git_receive_pack),
229        )
230        // Collaboration API (PRs, Issues, Comments, Reviews)
231        .merge(collaboration_routes())
232        // Authorization API (Organizations, Teams, Permissions, Webhooks)
233        .merge(auth_routes())
234        // CI/CD API (Workflows, Runs, Artifacts, Status Checks)
235        .merge(ci_routes())
236        // Compatibility API (Users, Tokens, SSH Keys, Releases, Contents, Archives)
237        .merge(compat_routes())
238        // Consensus API (Blocks, Validators, Transactions)
239        .merge(consensus_routes())
240        // Real-time WebSocket API
241        .merge(realtime_routes())
242        // Health check routes
243        .merge(health_routes(health_state))
244        // Web UI routes
245        .merge(guts_web::web_routes())
246        // Observability layers
247        .layer(middleware::from_fn(metrics_middleware))
248        .layer(middleware::from_fn(request_id_middleware))
249        .layer(TraceLayer::new_for_http())
250        .with_state(state)
251}
252
253/// Lists all repositories.
254async fn list_repos(State(state): State<AppState>) -> impl IntoResponse {
255    tracing::debug!("Listing repositories");
256    let repos: Vec<RepoInfo> = state
257        .repos
258        .list()
259        .into_iter()
260        .map(|r| RepoInfo {
261            name: r.name.clone(),
262            owner: r.owner.clone(),
263        })
264        .collect();
265    Json(repos)
266}
267
268/// Creates a new repository.
269async fn create_repo(
270    State(state): State<AppState>,
271    Json(req): Json<CreateRepoRequest>,
272) -> Result<impl IntoResponse, ApiError> {
273    // Validate request
274    if let Err(e) = req.validate() {
275        return Err(ApiError::Validation(e.to_string()));
276    }
277
278    // Validate name format
279    if let Err(e) = validate_name(&req.name) {
280        return Err(ApiError::Validation(format!(
281            "Invalid repository name: {}",
282            e.message.unwrap_or_default()
283        )));
284    }
285
286    if let Err(e) = validate_name(&req.owner) {
287        return Err(ApiError::Validation(format!(
288            "Invalid owner name: {}",
289            e.message.unwrap_or_default()
290        )));
291    }
292
293    tracing::info!(
294        owner = %req.owner,
295        name = %req.name,
296        "Creating repository"
297    );
298
299    let repo = state.repos.create(&req.name, &req.owner)?;
300
301    Ok((
302        StatusCode::CREATED,
303        Json(RepoInfo {
304            name: repo.name.clone(),
305            owner: repo.owner.clone(),
306        }),
307    ))
308}
309
310/// Gets repository info.
311async fn get_repo(
312    State(state): State<AppState>,
313    Path((owner, name)): Path<(String, String)>,
314) -> Result<impl IntoResponse, ApiError> {
315    tracing::debug!(owner = %owner, name = %name, "Getting repository");
316    let repo = state.repos.get(&owner, &name)?;
317
318    Ok(Json(RepoInfo {
319        name: repo.name.clone(),
320        owner: repo.owner.clone(),
321    }))
322}
323
324/// Git info/refs endpoint - advertises references.
325async fn git_info_refs(
326    State(state): State<AppState>,
327    Path((owner, name)): Path<(String, String)>,
328    axum::extract::Query(params): axum::extract::Query<HashMap<String, String>>,
329) -> Result<Response, ApiError> {
330    let repo = state.repos.get(&owner, &name)?;
331    let service = params.get("service").cloned().unwrap_or_default();
332
333    let mut output = Vec::new();
334    advertise_refs(&mut output, &repo, &service)?;
335
336    let content_type = format!("application/x-{}-advertisement", service);
337
338    Response::builder()
339        .status(StatusCode::OK)
340        .header(header::CONTENT_TYPE, content_type)
341        .header("Cache-Control", "no-cache")
342        .body(Body::from(output))
343        .map_err(|e| ApiError::BadRequest(format!("Failed to build response: {}", e)))
344}
345
346/// Git upload-pack endpoint - handles fetch/clone.
347async fn git_upload_pack(
348    State(state): State<AppState>,
349    Path((owner, name)): Path<(String, String)>,
350    body: axum::body::Bytes,
351) -> Result<Response, ApiError> {
352    let repo = state.repos.get(&owner, &name)?;
353
354    let mut input = Cursor::new(body.to_vec());
355    let mut output = Vec::new();
356
357    upload_pack(&mut input, &mut output, &repo)?;
358
359    Response::builder()
360        .status(StatusCode::OK)
361        .header(header::CONTENT_TYPE, "application/x-git-upload-pack-result")
362        .body(Body::from(output))
363        .map_err(|e| ApiError::BadRequest(format!("Failed to build response: {}", e)))
364}
365
366/// Git receive-pack endpoint - handles push.
367async fn git_receive_pack(
368    State(state): State<AppState>,
369    Path((owner, name)): Path<(String, String)>,
370    body: axum::body::Bytes,
371) -> Result<Response, ApiError> {
372    // Track objects before push
373    let objects_before: std::collections::HashSet<_>;
374
375    // Auto-create repository if it doesn't exist (for initial push)
376    let repo = match state.repos.get(&owner, &name) {
377        Ok(repo) => {
378            objects_before = repo.objects.list_objects().into_iter().collect();
379            repo
380        }
381        Err(StorageError::RepoNotFound(_)) => {
382            objects_before = std::collections::HashSet::new();
383            state.repos.create(&name, &owner)?
384        }
385        Err(e) => return Err(e.into()),
386    };
387
388    let mut input = Cursor::new(body.to_vec());
389    let mut output = Vec::new();
390
391    receive_pack(&mut input, &mut output, &repo)?;
392
393    // Calculate new objects
394    let objects_after: std::collections::HashSet<_> =
395        repo.objects.list_objects().into_iter().collect();
396    let new_objects: Vec<_> = objects_after.difference(&objects_before).copied().collect();
397
398    // Get current refs
399    let refs: Vec<_> = repo
400        .refs
401        .list_all()
402        .into_iter()
403        .filter_map(|(name, reference)| match reference {
404            Reference::Direct(oid) => Some((name, oid)),
405            Reference::Symbolic(_) => None,
406        })
407        .collect();
408
409    tracing::info!(
410        owner = %owner,
411        name = %name,
412        objects = repo.objects.len(),
413        new_objects = new_objects.len(),
414        "Push completed"
415    );
416
417    let repo_key = format!("{}/{}", owner, name);
418
419    // Notify P2P network about the update
420    if let Some(p2p) = &state.p2p {
421        p2p.notify_update(&repo_key, new_objects.clone(), refs.clone());
422
423        // Also register this repo with the P2P manager
424        p2p.register_repo(repo_key.clone(), repo.clone());
425    }
426
427    // Emit real-time event for WebSocket clients
428    let channel = format!("repo:{}", repo_key);
429    state.realtime.emit_event(
430        channel,
431        EventKind::Push,
432        serde_json::json!({
433            "repository": repo_key,
434            "owner": owner,
435            "name": name,
436            "commit_count": new_objects.len(),
437            "refs": refs.iter().map(|(name, oid)| {
438                serde_json::json!({
439                    "name": name,
440                    "sha": format!("{:?}", oid)
441                })
442            }).collect::<Vec<_>>()
443        }),
444    );
445
446    Response::builder()
447        .status(StatusCode::OK)
448        .header(
449            header::CONTENT_TYPE,
450            "application/x-git-receive-pack-result",
451        )
452        .body(Body::from(output))
453        .map_err(|e| ApiError::BadRequest(format!("Failed to build response: {}", e)))
454}