/ src / api.rs
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  }