/ radicle-httpd / 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::Profile;
 18  
 19  mod error;
 20  mod json;
 21  pub(crate) mod query;
 22  mod v1;
 23  
 24  use crate::api::error::Error;
 25  use crate::cache::Cache;
 26  use crate::Options;
 27  
 28  pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION");
 29  // This version has to be updated on every breaking change to the radicle-httpd API.
 30  pub const API_VERSION: &str = "6.1.0";
 31  
 32  #[derive(Clone)]
 33  pub struct Context {
 34      profile: Arc<Profile>,
 35      cache: Option<Cache>,
 36  }
 37  
 38  impl Context {
 39      pub fn new(profile: Arc<Profile>, options: &Options) -> Self {
 40          Self {
 41              profile,
 42              cache: options.cache.map(Cache::new),
 43          }
 44      }
 45  
 46      #[allow(clippy::result_large_err)]
 47      pub fn repo_info<R: ReadRepository + radicle::cob::Store<Namespace = NodeId>>(
 48          &self,
 49          repo: &R,
 50          doc: DocAt,
 51      ) -> Result<repo::Info, error::Error> {
 52          let DocAt { doc, .. } = doc;
 53          let rid = repo.id();
 54  
 55          let aliases = self.profile.aliases();
 56          let delegates = doc
 57              .delegates()
 58              .iter()
 59              .map(|did| json::Author::new(did).as_json(&aliases))
 60              .collect::<Vec<_>>();
 61          let db = &self.profile.database()?;
 62          let seeding = db.count(&rid).unwrap_or_default();
 63  
 64          let payloads: BTreeMap<PayloadId, Value> = doc
 65              .payload()
 66              .iter()
 67              .filter_map(|(id, payload)| {
 68                  if id == &PayloadId::project() {
 69                      let (_, head) = repo.head().ok()?;
 70                      let patches = self.profile.patches(repo).ok()?;
 71                      let patches = patches.counts().ok()?;
 72                      let issues = self.profile.issues(repo).ok()?;
 73                      let issues = issues.counts().ok()?;
 74  
 75                      Some((
 76                          id.clone(),
 77                          json!({
 78                              "data": payload,
 79                              "meta": {
 80                                  "head": head,
 81                                  "issues": issues,
 82                                  "patches": patches
 83                              }
 84                          }),
 85                      ))
 86                  } else {
 87                      Some((id.clone(), json!({ "data": payload })))
 88                  }
 89              })
 90              .collect();
 91  
 92          Ok(repo::Info {
 93              payloads,
 94              delegates,
 95              threshold: doc.threshold(),
 96              visibility: doc.visibility().clone(),
 97              rid,
 98              seeding,
 99          })
100      }
101  
102      /// Get a repository by RID, checking to make sure we're allowed to view it.
103      #[allow(clippy::result_large_err)]
104      pub fn repo(&self, rid: RepoId) -> Result<(Repository, DocAt), error::Error> {
105          let repo = self.profile.storage.repository(rid)?;
106          let doc = repo.identity_doc()?;
107          // Don't allow accessing private repos.
108          if doc.visibility().is_private() {
109              return Err(Error::NotFound);
110          }
111          Ok((repo, doc))
112      }
113  
114      #[cfg(test)]
115      pub fn profile(&self) -> &Arc<Profile> {
116          &self.profile
117      }
118  }
119  
120  pub fn router(ctx: Context) -> Router {
121      Router::new()
122          .route("/", get(root_handler))
123          .merge(v1::router(ctx))
124  }
125  
126  async fn root_handler() -> impl IntoResponse {
127      let response = json!({
128          "path": "/api",
129          "links": [
130              {
131                  "href": "/v1",
132                  "rel": "v1",
133                  "type": "GET"
134              }
135          ]
136      });
137  
138      Json(response)
139  }
140  
141  mod search {
142      use std::cmp::Ordering;
143      use std::collections::BTreeMap;
144  
145      use serde::{Deserialize, Serialize};
146      use serde_json::json;
147  
148      use radicle::identity::doc::{Payload, PayloadId};
149      use radicle::identity::RepoId;
150      use radicle::node::routing::Store;
151      use radicle::node::{AliasStore, Database};
152      use radicle::profile::Aliases;
153      use radicle::storage::RepositoryInfo;
154  
155      #[derive(Serialize, Deserialize)]
156      #[serde(rename_all = "camelCase")]
157      pub struct SearchQueryString {
158          pub q: Option<String>,
159          pub page: Option<usize>,
160          pub per_page: Option<usize>,
161      }
162  
163      #[derive(Serialize, Deserialize, Eq, Debug)]
164      pub struct SearchResult {
165          pub rid: RepoId,
166          pub payloads: BTreeMap<PayloadId, Payload>,
167          pub delegates: Vec<serde_json::Value>,
168          pub seeds: usize,
169          #[serde(skip)]
170          pub index: usize,
171      }
172  
173      impl SearchResult {
174          pub fn new(
175              q: &str,
176              info: RepositoryInfo,
177              db: &Database,
178              aliases: &Aliases,
179          ) -> Option<Self> {
180              if info.doc.visibility().is_private() {
181                  return None;
182              }
183              let Ok(Some(index)) = info.doc.project().map(|p| p.name().find(q)) else {
184                  return None;
185              };
186              let seeds = db.count(&info.rid).unwrap_or_default();
187              let delegates = info
188                  .doc
189                  .delegates()
190                  .iter()
191                  .map(|did| match aliases.alias(did) {
192                      Some(alias) => json!({
193                          "id": did,
194                          "alias": alias,
195                      }),
196                      None => json!({
197                          "id": did,
198                      }),
199                  })
200                  .collect::<Vec<_>>();
201  
202              Some(SearchResult {
203                  rid: info.rid,
204                  payloads: info.doc.payload().clone(),
205                  delegates,
206                  seeds,
207                  index,
208              })
209          }
210      }
211  
212      impl Ord for SearchResult {
213          fn cmp(&self, other: &Self) -> Ordering {
214              match (self.index, other.index) {
215                  (0, 0) => self.seeds.cmp(&other.seeds),
216                  (0, _) => std::cmp::Ordering::Less,
217                  (_, 0) => std::cmp::Ordering::Greater,
218                  (ai, bi) if ai == bi => self.seeds.cmp(&other.seeds),
219                  (_, _) => self.seeds.cmp(&other.seeds),
220              }
221          }
222      }
223  
224      impl PartialOrd for SearchResult {
225          fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
226              Some(self.cmp(other))
227          }
228      }
229  
230      impl PartialEq for SearchResult {
231          fn eq(&self, other: &Self) -> bool {
232              self.rid == other.rid
233          }
234      }
235  }
236  
237  mod repo {
238      use std::collections::BTreeMap;
239  
240      use serde::Serialize;
241      use serde_json::Value;
242  
243      use radicle::identity::doc::PayloadId;
244      use radicle::identity::{RepoId, Visibility};
245  
246      /// Repos info.
247      #[derive(Serialize)]
248      #[serde(rename_all = "camelCase")]
249      pub struct Info {
250          pub payloads: BTreeMap<PayloadId, Value>,
251          pub delegates: Vec<Value>,
252          pub threshold: usize,
253          pub visibility: Visibility,
254          pub rid: RepoId,
255          pub seeding: usize,
256      }
257  }