api.rs
1 use std::collections::BTreeMap; 2 use std::sync::Arc; 3 4 use axum::response::{IntoResponse, Json}; 5 use axum::routing::get; 6 use axum::Router; 7 use serde_json::{json, Value}; 8 9 use radicle::identity::doc::PayloadId; 10 use radicle::identity::{DocAt, RepoId}; 11 use radicle::issue::cache::Issues as _; 12 use radicle::node::routing::Store; 13 use radicle::node::NodeId; 14 use radicle::patch::cache::Patches as _; 15 use radicle::storage::git::Repository; 16 use radicle::storage::{ReadRepository, ReadStorage}; 17 use radicle::{web, Profile}; 18 use tokio::sync::RwLock; 19 20 mod error; 21 mod json; 22 pub(crate) mod query; 23 mod v1; 24 25 use crate::api::error::Error; 26 use crate::cache::Cache; 27 use crate::Options; 28 29 pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION"); 30 // This version has to be updated on every breaking change to the radicle-httpd API. 31 pub const API_VERSION: &str = "6.1.0"; 32 33 /// Thread-safe wrapper around radicle's web configuration. 34 /// 35 /// This struct provides concurrent read/write access to web configuration 36 /// that can be dynamically reloaded (e.g., via SIGHUP) without restarting the server. 37 /// All access is synchronized via an async [`RwLock`] to prevent race conditions. 38 #[derive(Clone)] 39 pub struct WebConfig { 40 inner: Arc<RwLock<web::Config>>, 41 } 42 43 impl WebConfig { 44 /// Creates a new WebConfig from a [`Profile`]'s web configuration. 45 pub fn from_profile(profile: &Profile) -> Self { 46 let config = profile.config.web.clone(); 47 Self { 48 inner: Arc::new(RwLock::new(config)), 49 } 50 } 51 52 /// Return the underlying web configuration. 53 pub async fn read(&self) -> web::Config { 54 self.inner.read().await.clone() 55 } 56 57 /// Atomically updates the config by applying a function while holding the write lock. 58 /// This prevents lost updates when multiple tasks attempt concurrent modifications. 59 pub async fn update<F>(&self, f: F) 60 where 61 F: FnOnce(&mut web::Config), 62 { 63 let mut config = self.inner.write().await; 64 f(&mut config); 65 } 66 } 67 68 #[derive(Clone)] 69 pub struct Context { 70 profile: Arc<Profile>, 71 cache: Option<Cache>, 72 web_config: WebConfig, 73 } 74 75 impl Context { 76 pub fn new(profile: Arc<Profile>, web_config: WebConfig, options: &Options) -> Self { 77 Self { 78 profile: profile.clone(), 79 cache: options.cache.map(Cache::new), 80 web_config, 81 } 82 } 83 84 #[allow(clippy::result_large_err)] 85 pub fn repo_info<R: ReadRepository + radicle::cob::Store<Namespace = NodeId>>( 86 &self, 87 repo: &R, 88 doc: DocAt, 89 ) -> Result<repo::Info, error::Error> { 90 let DocAt { doc, .. } = doc; 91 let rid = repo.id(); 92 93 let aliases = self.profile.aliases(); 94 let delegates = doc 95 .delegates() 96 .iter() 97 .map(|did| json::Author::new(did).as_json(&aliases)) 98 .collect::<Vec<_>>(); 99 let db = &self.profile.database()?; 100 let seeding = db.count(&rid).unwrap_or_default(); 101 102 let payloads: BTreeMap<PayloadId, Value> = doc 103 .payload() 104 .iter() 105 .filter_map(|(id, payload)| { 106 if id == &PayloadId::project() { 107 let (_, head) = repo.head().ok()?; 108 let patches = self.profile.patches(repo).ok()?; 109 let patches = patches.counts().ok()?; 110 let issues = self.profile.issues(repo).ok()?; 111 let issues = issues.counts().ok()?; 112 113 Some(( 114 id.clone(), 115 json!({ 116 "data": payload, 117 "meta": { 118 "head": head, 119 "issues": issues, 120 "patches": patches 121 } 122 }), 123 )) 124 } else { 125 Some((id.clone(), json!({ "data": payload }))) 126 } 127 }) 128 .collect(); 129 130 Ok(repo::Info { 131 payloads, 132 delegates, 133 threshold: doc.threshold(), 134 visibility: doc.visibility().clone(), 135 rid, 136 seeding, 137 }) 138 } 139 140 /// Get a repository by RID, checking to make sure we're allowed to view it. 141 #[allow(clippy::result_large_err)] 142 pub fn repo(&self, rid: RepoId) -> Result<(Repository, DocAt), error::Error> { 143 let repo = self.profile.storage.repository(rid)?; 144 let doc = repo.identity_doc()?; 145 // Don't allow accessing private repos. 146 if doc.visibility().is_private() { 147 return Err(Error::NotFound); 148 } 149 Ok((repo, doc)) 150 } 151 152 /// Returns a reference to the thread-safe web configuration. 153 /// 154 /// Use this instead of accessing [`radicle::web::Config`] from the [`Profile`] to ensure 155 /// you get the latest config after dynamic reloads. 156 pub fn web_config(&self) -> &WebConfig { 157 &self.web_config 158 } 159 160 #[cfg(test)] 161 pub fn profile(&self) -> &Arc<Profile> { 162 &self.profile 163 } 164 } 165 166 pub fn router(ctx: Context) -> Router { 167 Router::new() 168 .route("/", get(root_handler)) 169 .merge(v1::router(ctx)) 170 } 171 172 async fn root_handler() -> impl IntoResponse { 173 let response = json!({ 174 "path": "/api", 175 "links": [ 176 { 177 "href": "/v1", 178 "rel": "v1", 179 "type": "GET" 180 } 181 ] 182 }); 183 184 Json(response) 185 } 186 187 mod search { 188 use std::cmp::Ordering; 189 use std::collections::BTreeMap; 190 191 use serde::{Deserialize, Serialize}; 192 use serde_json::json; 193 194 use radicle::identity::doc::{Payload, PayloadId}; 195 use radicle::identity::RepoId; 196 use radicle::node::routing::Store; 197 use radicle::node::{AliasStore, Database}; 198 use radicle::profile::Aliases; 199 use radicle::storage::RepositoryInfo; 200 201 #[derive(Serialize, Deserialize)] 202 #[serde(rename_all = "camelCase")] 203 pub struct SearchQueryString { 204 pub q: Option<String>, 205 pub page: Option<usize>, 206 pub per_page: Option<usize>, 207 } 208 209 #[derive(Serialize, Deserialize, Eq, Debug)] 210 pub struct SearchResult { 211 pub rid: RepoId, 212 pub payloads: BTreeMap<PayloadId, Payload>, 213 pub delegates: Vec<serde_json::Value>, 214 pub seeds: usize, 215 #[serde(skip)] 216 pub index: usize, 217 } 218 219 impl SearchResult { 220 pub fn new( 221 q: &str, 222 info: RepositoryInfo, 223 db: &Database, 224 aliases: &Aliases, 225 ) -> Option<Self> { 226 if info.doc.visibility().is_private() { 227 return None; 228 } 229 let Ok(Some(index)) = info.doc.project().map(|p| p.name().find(q)) else { 230 return None; 231 }; 232 let seeds = db.count(&info.rid).unwrap_or_default(); 233 let delegates = info 234 .doc 235 .delegates() 236 .iter() 237 .map(|did| match aliases.alias(did) { 238 Some(alias) => json!({ 239 "id": did, 240 "alias": alias, 241 }), 242 None => json!({ 243 "id": did, 244 }), 245 }) 246 .collect::<Vec<_>>(); 247 248 Some(SearchResult { 249 rid: info.rid, 250 payloads: info.doc.payload().clone(), 251 delegates, 252 seeds, 253 index, 254 }) 255 } 256 } 257 258 impl Ord for SearchResult { 259 fn cmp(&self, other: &Self) -> Ordering { 260 match (self.index, other.index) { 261 (0, 0) => self.seeds.cmp(&other.seeds), 262 (0, _) => std::cmp::Ordering::Less, 263 (_, 0) => std::cmp::Ordering::Greater, 264 (ai, bi) if ai == bi => self.seeds.cmp(&other.seeds), 265 (_, _) => self.seeds.cmp(&other.seeds), 266 } 267 } 268 } 269 270 impl PartialOrd for SearchResult { 271 fn partial_cmp(&self, other: &Self) -> Option<Ordering> { 272 Some(self.cmp(other)) 273 } 274 } 275 276 impl PartialEq for SearchResult { 277 fn eq(&self, other: &Self) -> bool { 278 self.rid == other.rid 279 } 280 } 281 } 282 283 mod repo { 284 use std::collections::BTreeMap; 285 286 use serde::Serialize; 287 use serde_json::Value; 288 289 use radicle::identity::doc::PayloadId; 290 use radicle::identity::{RepoId, Visibility}; 291 292 /// Repos info. 293 #[derive(Serialize)] 294 #[serde(rename_all = "camelCase")] 295 pub struct Info { 296 pub payloads: BTreeMap<PayloadId, Value>, 297 pub delegates: Vec<Value>, 298 pub threshold: usize, 299 pub visibility: Visibility, 300 pub rid: RepoId, 301 pub seeding: usize, 302 } 303 } 304 305 #[cfg(test)] 306 mod tests { 307 use crate::test; 308 309 #[tokio::test] 310 async fn test_web_config_accessor() { 311 let tmp = tempfile::tempdir().unwrap(); 312 let ctx = test::seed(tmp.path()); 313 314 let config = ctx.web_config.read().await; 315 assert_eq!(config.pinned.repositories.len(), 0); 316 } 317 318 #[tokio::test] 319 async fn test_web_config_reload_simulation() { 320 let tmp = tempfile::tempdir().unwrap(); 321 let ctx = test::seed(tmp.path()); 322 323 { 324 let config = ctx.web_config.read().await; 325 assert_eq!(config.pinned.repositories.len(), 0); 326 assert_eq!(config.description, None); 327 } 328 329 { 330 ctx.web_config 331 .update(|config| { 332 config.description = Some("Updated description".to_string()); 333 config.avatar_url = Some("https://example.com/avatar.png".to_string()); 334 }) 335 .await; 336 } 337 338 { 339 let config = ctx.web_config.read().await; 340 assert_eq!(config.description, Some("Updated description".to_string())); 341 assert_eq!( 342 config.avatar_url, 343 Some("https://example.com/avatar.png".to_string()) 344 ); 345 } 346 } 347 348 #[tokio::test] 349 async fn test_web_config_concurrent_reads() { 350 let tmp = tempfile::tempdir().unwrap(); 351 let ctx = test::seed(tmp.path()); 352 353 let mut handles = vec![]; 354 for _ in 0..10 { 355 let ctx_clone = ctx.clone(); 356 let handle = tokio::spawn(async move { 357 let config = ctx_clone.web_config.read().await; 358 config.pinned.repositories.len() 359 }); 360 handles.push(handle); 361 } 362 363 for handle in handles { 364 handle.await.unwrap(); 365 } 366 } 367 368 #[tokio::test] 369 async fn test_web_config_preserves_data_across_reads() { 370 let tmp = tempfile::tempdir().unwrap(); 371 let ctx = test::seed(tmp.path()); 372 373 { 374 ctx.web_config 375 .update(|config| { 376 config.banner_url = Some("https://example.com/banner.png".to_string()); 377 }) 378 .await; 379 } 380 381 for _ in 0..5 { 382 let config = ctx.web_config.read().await; 383 assert_eq!( 384 config.banner_url, 385 Some("https://example.com/banner.png".to_string()) 386 ); 387 } 388 } 389 390 #[tokio::test] 391 async fn test_profile_immutable_after_reload() { 392 let tmp = tempfile::tempdir().unwrap(); 393 let ctx = test::seed(tmp.path()); 394 let original_key = ctx.profile.public_key; 395 let original_home = ctx.profile.home.path().to_path_buf(); 396 397 { 398 ctx.web_config 399 .update(|config| { 400 config.description = Some("Updated".to_string()); 401 config.avatar_url = Some("https://example.com/new-avatar.png".to_string()); 402 }) 403 .await; 404 } 405 406 assert_eq!(ctx.profile.public_key, original_key); 407 assert_eq!(ctx.profile.home.path(), original_home); 408 } 409 410 #[tokio::test] 411 async fn test_empty_pinned_repos_transitions() { 412 use radicle::identity::RepoId; 413 use std::str::FromStr; 414 415 let tmp = tempfile::tempdir().unwrap(); 416 let ctx = test::seed(tmp.path()); 417 418 assert_eq!(ctx.web_config.read().await.pinned.repositories.len(), 0); 419 420 let rid1 = RepoId::from_str("rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp").unwrap(); 421 let rid2 = RepoId::from_str("rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE").unwrap(); 422 423 { 424 ctx.web_config 425 .update(|config| { 426 config.pinned.repositories.insert(rid1); 427 config.pinned.repositories.insert(rid2); 428 }) 429 .await; 430 } 431 assert_eq!(ctx.web_config.read().await.pinned.repositories.len(), 2); 432 433 { 434 ctx.web_config 435 .update(|config| { 436 config.pinned.repositories.clear(); 437 }) 438 .await; 439 } 440 assert_eq!(ctx.web_config.read().await.pinned.repositories.len(), 0); 441 } 442 }