1use 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
106pub use guts_storage::RepoStore;
108
109#[derive(Clone)]
111pub struct AppState {
112 pub repos: Arc<RepoStore>,
114 pub p2p: Option<Arc<P2PManager>>,
116 pub consensus: Option<Arc<ConsensusEngine>>,
118 pub mempool: Option<Arc<Mempool>>,
120 pub collaboration: Arc<CollaborationStore>,
122 pub auth: Arc<AuthStore>,
124 pub realtime: Arc<EventHub>,
126 pub ci: Arc<CiStore>,
128 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#[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#[derive(Serialize, Deserialize)]
201pub struct RepoInfo {
202 pub name: String,
203 pub owner: String,
204}
205
206#[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
215pub fn create_router(state: AppState, health_state: HealthState) -> Router {
217 Router::new()
218 .route("/metrics", get(metrics_handler))
220 .route("/api/repos", get(list_repos).post(create_repo))
222 .route("/api/repos/{owner}/{name}", get(get_repo))
223 .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 .merge(collaboration_routes())
232 .merge(auth_routes())
234 .merge(ci_routes())
236 .merge(compat_routes())
238 .merge(consensus_routes())
240 .merge(realtime_routes())
242 .merge(health_routes(health_state))
244 .merge(guts_web::web_routes())
246 .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
253async 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
268async fn create_repo(
270 State(state): State<AppState>,
271 Json(req): Json<CreateRepoRequest>,
272) -> Result<impl IntoResponse, ApiError> {
273 if let Err(e) = req.validate() {
275 return Err(ApiError::Validation(e.to_string()));
276 }
277
278 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
310async 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
324async 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
346async 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
366async 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 let objects_before: std::collections::HashSet<_>;
374
375 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 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 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 if let Some(p2p) = &state.p2p {
421 p2p.notify_update(&repo_key, new_objects.clone(), refs.clone());
422
423 p2p.register_repo(repo_key.clone(), repo.clone());
425 }
426
427 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}