use std::collections::BTreeMap;
use std::sync::Arc;
use axum::response::{IntoResponse, Json};
use axum::routing::get;
use axum::Router;
use serde_json::{json, Value};
use radicle::identity::crefs::GetCanonicalRefs;
use radicle::identity::doc::PayloadId;
use radicle::identity::{DocAt, RepoId};
use radicle::issue::cache::Issues as _;
use radicle::node::routing::Store;
use radicle::patch::cache::Patches as _;
use radicle::storage::git::Repository;
use radicle::storage::{ReadRepository, ReadStorage};
use radicle::{git, web, Profile};
use tokio::sync::RwLock;
mod error;
mod json;
pub(crate) mod query;
mod v1;
use crate::api::error::Error;
use crate::cache::Cache;
use crate::Options;
pub const RADICLE_VERSION: &str = env!("RADICLE_VERSION");
// This version has to be updated on every breaking change to the radicle-httpd API.
pub const API_VERSION: &str = "6.1.0";
/// Thread-safe wrapper around radicle's web configuration.
///
/// This struct provides concurrent read/write access to web configuration
/// that can be dynamically reloaded (e.g., via SIGHUP) without restarting the server.
/// All access is synchronized via an async [`RwLock`] to prevent race conditions.
#[derive(Clone)]
pub struct WebConfig {
inner: Arc<RwLock<web::Config>>,
}
impl WebConfig {
/// Creates a new WebConfig from a [`Profile`]'s web configuration.
pub fn from_profile(profile: &Profile) -> Self {
let config = profile.config.web.clone();
Self {
inner: Arc::new(RwLock::new(config)),
}
}
/// Return the underlying web configuration.
pub async fn read(&self) -> web::Config {
self.inner.read().await.clone()
}
/// Atomically updates the config by applying a function while holding the write lock.
/// This prevents lost updates when multiple tasks attempt concurrent modifications.
pub async fn update<F>(&self, f: F)
where
F: FnOnce(&mut web::Config),
{
let mut config = self.inner.write().await;
f(&mut config);
}
}
#[derive(Clone)]
pub struct Context {
profile: Arc<Profile>,
cache: Option<Cache>,
web_config: WebConfig,
}
impl Context {
pub fn new(profile: Arc<Profile>, web_config: WebConfig, options: &Options) -> Self {
Self {
profile: profile.clone(),
cache: options.cache.map(Cache::new),
web_config,
}
}
#[allow(clippy::result_large_err)]
pub fn repo_info(&self, repo: &Repository, doc: DocAt) -> Result<repo::Info, error::Error> {
let DocAt { doc, .. } = doc;
let rid = repo.id();
let aliases = self.profile.aliases();
let delegates = doc
.delegates()
.iter()
.map(|did| json::Author::new(did).as_json(&aliases))
.collect::<Vec<_>>();
let db = &self.profile.database()?;
let seeding = db.count(&rid).unwrap_or_default();
let payloads: BTreeMap<PayloadId, Value> = doc
.payload()
.iter()
.filter_map(|(id, payload)| {
if id == &PayloadId::project() {
let (_, head) = repo.head().ok()?;
let patches = self.profile.patches(repo).ok()?;
let patches = patches.counts().ok()?;
let issues = self.profile.issues(repo).ok()?;
let issues = issues.counts().ok()?;
Some((
id.clone(),
json!({
"data": payload,
"meta": {
"head": head,
"issues": issues,
"patches": patches
}
}),
))
} else {
Some((id.clone(), json!({ "data": payload })))
}
})
.collect();
let refs = canonical_refs(repo, &doc)
.inspect_err(|e| tracing::warn!("failed to read canonical refs for {rid}: {e}"))
.unwrap_or_default();
Ok(repo::Info {
payloads,
delegates,
threshold: doc.threshold(),
visibility: doc.visibility().clone(),
rid,
seeding,
refs,
})
}
/// Get a repository by RID, checking to make sure we're allowed to view it.
#[allow(clippy::result_large_err)]
pub fn repo(&self, rid: RepoId) -> Result<(Repository, DocAt), error::Error> {
let repo = self.profile.storage.repository(rid)?;
let doc = repo.identity_doc()?;
// Don't allow accessing private repos.
if doc.visibility().is_private() {
return Err(Error::NotFound);
}
Ok((repo, doc))
}
/// Returns a reference to the thread-safe web configuration.
///
/// Use this instead of accessing [`radicle::web::Config`] from the [`Profile`] to ensure
/// you get the latest config after dynamic reloads.
pub fn web_config(&self) -> &WebConfig {
&self.web_config
}
#[cfg(test)]
pub fn profile(&self) -> &Arc<Profile> {
&self.profile
}
}
pub trait ReadCanonicalRefs {
fn find_by_pattern(
&self,
pattern: &git::fmt::refspec::QualifiedPattern,
) -> Result<Vec<(git::fmt::RefString, git::Oid)>, error::Error>;
}
pub trait PeelToCommit {
fn peel_to_commit(&self, oid: git::Oid) -> Result<git::Oid, git::raw::Error>;
}
pub trait ResolveTag {
fn resolve_tag(&self, oid: git::Oid) -> Result<repo::Tag, git::raw::Error>;
}
impl ReadCanonicalRefs for Repository {
fn find_by_pattern(
&self,
pattern: &git::fmt::refspec::QualifiedPattern,
) -> Result<Vec<(git::fmt::RefString, git::Oid)>, error::Error> {
let mut refs = Vec::new();
for r in self.backend.references_glob(pattern.as_str())? {
let r = r?;
let Some(refname) = r.name().and_then(|n| git::fmt::RefString::try_from(n).ok()) else {
continue;
};
let Some(oid) = r.target().map(git::Oid::from) else {
continue;
};
refs.push((refname, oid));
}
Ok(refs)
}
}
impl PeelToCommit for Repository {
fn peel_to_commit(&self, oid: git::Oid) -> Result<git::Oid, git::raw::Error> {
let obj = self.backend.find_object(oid.into(), None)?;
let commit = obj.peel_to_commit()?;
Ok(commit.id().into())
}
}
impl ResolveTag for Repository {
fn resolve_tag(&self, oid: git::Oid) -> Result<repo::Tag, git::raw::Error> {
// Annotated tag: carries tagger/message.
if let Ok(tag) = self.backend.find_tag(oid.into()) {
return Ok(repo::Tag {
commit: tag.target_id().into(),
tagger: tag.tagger().map(|t| repo::Tagger {
name: t.name().unwrap_or_default().to_owned(),
email: t.email().unwrap_or_default().to_owned(),
timestamp: t.when().seconds(),
}),
message: tag.message().map(str::to_owned),
});
}
// Lightweight tag: ref points directly at a commit.
let commit = self.backend.find_commit(oid.into())?;
Ok(repo::Tag {
commit: commit.id().into(),
tagger: None,
message: None,
})
}
}
#[allow(clippy::result_large_err)]
fn canonical_refs<R: ReadCanonicalRefs + PeelToCommit + ResolveTag>(
repo: &R,
doc: &radicle::identity::Doc,
) -> Result<repo::CanonicalReferences, error::Error> {
let Some(crefs) = doc.canonical_refs()? else {
return Ok(repo::CanonicalReferences::default());
};
canonical_refs_for_patterns(repo, crefs.rules().iter().map(|(p, _)| p.as_ref()))
}
#[allow(clippy::result_large_err)]
fn canonical_refs_for_patterns<'a, R, I>(
repo: &R,
patterns: I,
) -> Result<repo::CanonicalReferences, error::Error>
where
R: ReadCanonicalRefs + PeelToCommit + ResolveTag,
I: IntoIterator<Item = &'a git::fmt::refspec::QualifiedPattern<'static>>,
{
let mut canonical = repo::CanonicalReferences::default();
for pattern in patterns {
for (refname, oid) in repo.find_by_pattern(pattern)? {
if refname.as_str().starts_with("refs/tags/") {
match repo.resolve_tag(oid) {
Ok(tag) => {
canonical.tags.insert(refname, tag);
}
Err(e) => tracing::warn!("skipping canonical tag {refname}: {e}"),
}
} else {
match repo.peel_to_commit(oid) {
Ok(commit) => {
canonical.refs.insert(refname, commit);
}
Err(e) => tracing::warn!("skipping canonical ref {refname}: {e}"),
}
}
}
}
Ok(canonical)
}
pub fn router(ctx: Context) -> Router {
Router::new()
.route("/", get(root_handler))
.merge(v1::router(ctx))
}
async fn root_handler() -> impl IntoResponse {
let response = json!({
"path": "/api",
"links": [
{
"href": "/v1",
"rel": "v1",
"type": "GET"
}
]
});
Json(response)
}
mod search {
use std::cmp::Ordering;
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use serde_json::json;
use radicle::identity::doc::{Payload, PayloadId};
use radicle::identity::RepoId;
use radicle::node::routing::Store;
use radicle::node::{AliasStore, Database};
use radicle::profile::Aliases;
use radicle::storage::RepositoryInfo;
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct SearchQueryString {
pub q: Option<String>,
pub page: Option<usize>,
pub per_page: Option<usize>,
}
#[derive(Serialize, Deserialize, Eq, Debug)]
pub struct SearchResult {
pub rid: RepoId,
pub payloads: BTreeMap<PayloadId, Payload>,
pub delegates: Vec<serde_json::Value>,
pub seeds: usize,
#[serde(skip)]
pub index: usize,
}
impl SearchResult {
pub fn new(
q: &str,
info: RepositoryInfo,
db: &Database,
aliases: &Aliases,
) -> Option<Self> {
if info.doc.visibility().is_private() {
return None;
}
let Ok(Some(index)) = info.doc.project().map(|p| p.name().find(q)) else {
return None;
};
let seeds = db.count(&info.rid).unwrap_or_default();
let delegates = info
.doc
.delegates()
.iter()
.map(|did| match aliases.alias(did) {
Some(alias) => json!({
"id": did,
"alias": alias,
}),
None => json!({
"id": did,
}),
})
.collect::<Vec<_>>();
Some(SearchResult {
rid: info.rid,
payloads: info.doc.payload().clone(),
delegates,
seeds,
index,
})
}
}
impl Ord for SearchResult {
fn cmp(&self, other: &Self) -> Ordering {
match (self.index, other.index) {
(0, 0) => self.seeds.cmp(&other.seeds),
(0, _) => std::cmp::Ordering::Less,
(_, 0) => std::cmp::Ordering::Greater,
(ai, bi) if ai == bi => self.seeds.cmp(&other.seeds),
(_, _) => self.seeds.cmp(&other.seeds),
}
}
}
impl PartialOrd for SearchResult {
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
Some(self.cmp(other))
}
}
impl PartialEq for SearchResult {
fn eq(&self, other: &Self) -> bool {
self.rid == other.rid
}
}
}
mod repo {
use std::collections::BTreeMap;
use serde::Serialize;
use serde_json::Value;
use radicle::git::fmt::RefString;
use radicle::git::Oid;
use radicle::identity::doc::PayloadId;
use radicle::identity::{RepoId, Visibility};
#[derive(Default, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CanonicalReferences {
pub tags: BTreeMap<RefString, Tag>,
pub refs: BTreeMap<RefString, Oid>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Tag {
pub commit: Oid,
#[serde(skip_serializing_if = "Option::is_none")]
pub tagger: Option<Tagger>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Tagger {
pub name: String,
pub email: String,
pub timestamp: i64,
}
/// Repos info.
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Info {
pub payloads: BTreeMap<PayloadId, Value>,
pub delegates: Vec<Value>,
pub threshold: usize,
pub visibility: Visibility,
pub rid: RepoId,
pub seeding: usize,
pub refs: CanonicalReferences,
}
}
#[cfg(test)]
mod tests {
use crate::test;
#[tokio::test]
async fn test_web_config_accessor() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test::seed(tmp.path());
let config = ctx.web_config.read().await;
assert_eq!(config.pinned.repositories.len(), 0);
}
#[tokio::test]
async fn test_web_config_reload_simulation() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test::seed(tmp.path());
{
let config = ctx.web_config.read().await;
assert_eq!(config.pinned.repositories.len(), 0);
assert_eq!(config.description, None);
}
{
ctx.web_config
.update(|config| {
config.description = Some("Updated description".to_string());
config.avatar_url = Some("https://example.com/avatar.png".to_string());
})
.await;
}
{
let config = ctx.web_config.read().await;
assert_eq!(config.description, Some("Updated description".to_string()));
assert_eq!(
config.avatar_url,
Some("https://example.com/avatar.png".to_string())
);
}
}
#[tokio::test]
async fn test_web_config_concurrent_reads() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test::seed(tmp.path());
let mut handles = vec![];
for _ in 0..10 {
let ctx_clone = ctx.clone();
let handle = tokio::spawn(async move {
let config = ctx_clone.web_config.read().await;
config.pinned.repositories.len()
});
handles.push(handle);
}
for handle in handles {
handle.await.unwrap();
}
}
#[tokio::test]
async fn test_web_config_preserves_data_across_reads() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test::seed(tmp.path());
{
ctx.web_config
.update(|config| {
config.banner_url = Some("https://example.com/banner.png".to_string());
})
.await;
}
for _ in 0..5 {
let config = ctx.web_config.read().await;
assert_eq!(
config.banner_url,
Some("https://example.com/banner.png".to_string())
);
}
}
#[tokio::test]
async fn test_profile_immutable_after_reload() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test::seed(tmp.path());
let original_key = ctx.profile.public_key;
let original_home = ctx.profile.home.path().to_path_buf();
{
ctx.web_config
.update(|config| {
config.description = Some("Updated".to_string());
config.avatar_url = Some("https://example.com/new-avatar.png".to_string());
})
.await;
}
assert_eq!(ctx.profile.public_key, original_key);
assert_eq!(ctx.profile.home.path(), original_home);
}
#[tokio::test]
async fn test_empty_pinned_repos_transitions() {
use radicle::identity::RepoId;
use std::str::FromStr;
let tmp = tempfile::tempdir().unwrap();
let ctx = test::seed(tmp.path());
assert_eq!(ctx.web_config.read().await.pinned.repositories.len(), 0);
let rid1 = RepoId::from_str("rad:z4FucBZHZMCsxTyQE1dfE2YR59Qbp").unwrap();
let rid2 = RepoId::from_str("rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE").unwrap();
{
ctx.web_config
.update(|config| {
config.pinned.repositories.insert(rid1);
config.pinned.repositories.insert(rid2);
})
.await;
}
assert_eq!(ctx.web_config.read().await.pinned.repositories.len(), 2);
{
ctx.web_config
.update(|config| {
config.pinned.repositories.clear();
})
.await;
}
assert_eq!(ctx.web_config.read().await.pinned.repositories.len(), 0);
}
mod refs {
use std::str::FromStr;
use radicle::git::fmt::RefStr;
use radicle::identity::RepoId;
use radicle::storage::{ReadRepository, ReadStorage};
use crate::test;
fn r(s: &str) -> &RefStr {
RefStr::try_from_str(s).unwrap()
}
#[test]
fn test_canonical_refs_empty_without_config() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test::seed(tmp.path());
let rid = RepoId::from_str(test::RID).unwrap();
let repo = ctx.profile.storage.repository(rid).unwrap();
let doc = repo.identity_doc().unwrap();
let refs = super::super::canonical_refs(&repo, &doc.doc).unwrap();
assert!(refs.tags.is_empty());
assert!(refs.refs.is_empty());
}
#[test]
fn test_repo_info_includes_refs() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test::seed(tmp.path());
let rid = RepoId::from_str(test::RID).unwrap();
let (repo, doc) = ctx.repo(rid).unwrap();
let info = ctx.repo_info(&repo, doc).unwrap();
assert!(info.refs.tags.is_empty());
assert!(info.refs.refs.is_empty());
}
#[test]
fn test_multi_peer_canonical_refs() {
let tmp = tempfile::tempdir().unwrap();
let ctx = test::seed_multi_peer(tmp.path());
let rid = RepoId::from_str(test::RID).unwrap();
let repo = ctx.profile.storage.repository(rid).unwrap();
let doc = repo.identity_doc().unwrap();
let refs = super::super::canonical_refs(&repo, &doc.doc).unwrap();
assert!(refs.refs.contains_key(r("refs/heads/master")));
assert!(refs.tags.contains_key(r("refs/tags/v1.0")));
assert!(refs.refs.contains_key(r("refs/heads/feature/branch")));
assert!(!refs.tags.contains_key(r("refs/tags/v2.0-rc")));
// Fixture uses a lightweight tag (ref → commit, no tag object).
let tag = refs.tags.get(r("refs/tags/v1.0")).unwrap();
assert!(tag.tagger.is_none());
assert!(tag.message.is_none());
}
mod mock {
use std::collections::HashMap;
use std::str::FromStr;
use radicle::git;
use radicle::git::fmt::refspec::{PatternString, QualifiedPattern};
use radicle::git::fmt::RefString;
use crate::api::error;
use crate::api::{
canonical_refs_for_patterns, repo, PeelToCommit, ReadCanonicalRefs, ResolveTag,
};
struct StubRepo {
refs: HashMap<String, Vec<(RefString, git::Oid)>>,
peel_errors: HashMap<git::Oid, String>,
annotated_tags: HashMap<git::Oid, (String, String, i64, String)>,
}
impl ReadCanonicalRefs for StubRepo {
fn find_by_pattern(
&self,
pattern: &QualifiedPattern,
) -> Result<Vec<(RefString, git::Oid)>, error::Error> {
Ok(self.refs.get(pattern.as_str()).cloned().unwrap_or_default())
}
}
impl PeelToCommit for StubRepo {
fn peel_to_commit(&self, oid: git::Oid) -> Result<git::Oid, git::raw::Error> {
if let Some(msg) = self.peel_errors.get(&oid) {
Err(git::raw::Error::from_str(msg))
} else {
Ok(oid)
}
}
}
impl ResolveTag for StubRepo {
fn resolve_tag(&self, oid: git::Oid) -> Result<repo::Tag, git::raw::Error> {
if let Some(msg) = self.peel_errors.get(&oid) {
return Err(git::raw::Error::from_str(msg));
}
let (tagger, message) = self
.annotated_tags
.get(&oid)
.map(|(name, email, ts, msg)| {
(
Some(repo::Tagger {
name: name.clone(),
email: email.clone(),
timestamp: *ts,
}),
Some(msg.clone()),
)
})
.unwrap_or((None, None));
Ok(repo::Tag {
commit: oid,
tagger,
message,
})
}
}
fn pat(s: &str) -> QualifiedPattern<'static> {
QualifiedPattern::from_patternstr(&PatternString::try_from(s).unwrap())
.unwrap()
.to_owned()
}
fn oid(hex: &str) -> git::Oid {
git::Oid::from_str(hex).unwrap()
}
fn refname(s: &str) -> RefString {
RefString::try_from(s).unwrap()
}
#[test]
fn classifies_tags_and_non_tags() {
let head = oid("1111111111111111111111111111111111111111");
let tag = oid("2222222222222222222222222222222222222222");
let heads = pat("refs/heads/*");
let tags = pat("refs/tags/*");
let stub = StubRepo {
refs: HashMap::from([
(
heads.as_str().to_owned(),
vec![(refname("refs/heads/main"), head)],
),
(
tags.as_str().to_owned(),
vec![(refname("refs/tags/v1"), tag)],
),
]),
peel_errors: HashMap::new(),
annotated_tags: HashMap::new(),
};
let result = canonical_refs_for_patterns(&stub, [&heads, &tags]).unwrap();
assert_eq!(result.refs.get(&refname("refs/heads/main")), Some(&head));
assert_eq!(
result.tags.get(&refname("refs/tags/v1")).map(|t| t.commit),
Some(tag),
);
assert!(!result.tags.contains_key(&refname("refs/heads/main")));
assert!(!result.refs.contains_key(&refname("refs/tags/v1")));
}
#[test]
fn annotated_tag_metadata_is_included() {
let tag = oid("3333333333333333333333333333333333333333");
let pattern = pat("refs/tags/*");
let stub = StubRepo {
refs: HashMap::from([(
pattern.as_str().to_owned(),
vec![(refname("refs/tags/v1"), tag)],
)]),
peel_errors: HashMap::new(),
annotated_tags: HashMap::from([(
tag,
(
"Alice".to_owned(),
"alice@example.com".to_owned(),
1700000000i64,
"Release v1".to_owned(),
),
)]),
};
let result = canonical_refs_for_patterns(&stub, [&pattern]).unwrap();
let resolved = result.tags.get(&refname("refs/tags/v1")).unwrap();
assert_eq!(resolved.message.as_deref(), Some("Release v1"));
let tagger = resolved.tagger.as_ref().unwrap();
assert_eq!(tagger.name, "Alice");
assert_eq!(tagger.email, "alice@example.com");
assert_eq!(tagger.timestamp, 1700000000);
}
#[test]
fn skips_refs_whose_peel_fails() {
let good = oid("1111111111111111111111111111111111111111");
let bad = oid("2222222222222222222222222222222222222222");
let heads = pat("refs/heads/*");
let stub = StubRepo {
refs: HashMap::from([(
heads.as_str().to_owned(),
vec![
(refname("refs/heads/ok"), good),
(refname("refs/heads/broken"), bad),
],
)]),
peel_errors: HashMap::from([(bad, "missing".to_owned())]),
annotated_tags: HashMap::new(),
};
let result = canonical_refs_for_patterns(&stub, [&heads]).unwrap();
assert_eq!(result.refs.get(&refname("refs/heads/ok")), Some(&good));
assert!(!result.refs.contains_key(&refname("refs/heads/broken")));
}
#[test]
fn empty_patterns_yield_empty_result() {
let stub = StubRepo {
refs: HashMap::new(),
peel_errors: HashMap::new(),
annotated_tags: HashMap::new(),
};
let result: Vec<&QualifiedPattern<'static>> = vec![];
let result = canonical_refs_for_patterns(&stub, result).unwrap();
assert!(result.refs.is_empty());
assert!(result.tags.is_empty());
}
}
}
}