delegates.rs
1 use axum::extract::State; 2 use axum::response::IntoResponse; 3 use axum::routing::get; 4 use axum::{Json, Router}; 5 6 use radicle::identity::Did; 7 use radicle::storage::ReadStorage; 8 9 use crate::api::error::Error; 10 use crate::api::query::{PaginationQuery, RepoQuery}; 11 use crate::api::Context; 12 use crate::axum_extra::{Path, Query}; 13 14 pub fn router(ctx: Context) -> Router { 15 Router::new() 16 .route("/delegates/{did}/repos", get(delegates_repos_handler)) 17 .with_state(ctx) 18 } 19 20 /// List all repos which delegate is a part of. 21 /// `GET /delegates/:did/repos` 22 async fn delegates_repos_handler( 23 State(ctx): State<Context>, 24 Path(did): Path<Did>, 25 Query(qs): Query<PaginationQuery>, 26 ) -> impl IntoResponse { 27 let PaginationQuery { 28 show, 29 page, 30 per_page, 31 } = qs; 32 let page = page.unwrap_or(0); 33 let per_page = per_page.unwrap_or(10); 34 let storage = &ctx.profile.storage; 35 let web_config = ctx.web_config().read().await; 36 let pinned = &web_config.pinned; 37 let mut repos = match show { 38 RepoQuery::All => storage 39 .repositories()? 40 .into_iter() 41 .filter(|repo| repo.doc.visibility().is_public()) 42 .filter(|repo| repo.doc.delegates().iter().any(|d| *d == did)) 43 .collect::<Vec<_>>(), 44 RepoQuery::Pinned => storage 45 .repositories_by_id(pinned.repositories.iter()) 46 .filter_map(|result| match result { 47 Ok(repo) => Some(repo), 48 Err(e) => { 49 tracing::warn!("Failed to load pinned repository: {}", e); 50 None 51 } 52 }) 53 .filter(|repo| repo.doc.visibility().is_public()) 54 .filter(|repo| repo.doc.delegates().iter().any(|d| *d == did)) 55 .collect::<Vec<_>>(), 56 }; 57 repos.sort_by_key(|p| p.rid); 58 59 let infos = repos 60 .into_iter() 61 .filter_map(|id| { 62 let Ok((repo, doc)) = ctx.repo(id.rid) else { 63 return None; 64 }; 65 let Ok(repo_info) = ctx.repo_info(&repo, doc) else { 66 return None; 67 }; 68 69 Some(repo_info) 70 }) 71 .skip(page * per_page) 72 .take(per_page) 73 .collect::<Vec<_>>(); 74 75 Ok::<_, Error>(Json(infos)) 76 } 77 78 #[cfg(test)] 79 mod routes { 80 use std::net::SocketAddr; 81 82 use axum::extract::connect_info::MockConnectInfo; 83 use axum::http::StatusCode; 84 use serde_json::json; 85 86 use crate::test::{self, get, CONTRIBUTOR_ALIAS, DID, HEAD, RID}; 87 88 #[tokio::test] 89 async fn test_delegates_repos() { 90 let tmp = tempfile::tempdir().unwrap(); 91 let seed = test::seed(tmp.path()); 92 let app = super::router(seed.clone()) 93 .layer(MockConnectInfo(SocketAddr::from(([127, 0, 0, 1], 8080)))); 94 let response = get( 95 &app, 96 "/delegates/did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/repos?show=all", 97 ) 98 .await; 99 100 assert_eq!( 101 response.status(), 102 StatusCode::OK, 103 "failed response: {:?}", 104 response.json().await 105 ); 106 assert_eq!( 107 response.json().await, 108 json!([ 109 { 110 "payloads": { 111 "xyz.radicle.project": { 112 "data": { 113 "defaultBranch": "master", 114 "description": "Rad repository for tests", 115 "name": "hello-world", 116 }, 117 "meta": { 118 "head": HEAD, 119 "patches": { 120 "open": 1, 121 "draft": 0, 122 "archived": 0, 123 "merged": 0, 124 }, 125 "issues": { 126 "open": 1, 127 "closed": 0, 128 }, 129 } 130 } 131 }, 132 "delegates": [ 133 { 134 "id": DID, 135 "alias": CONTRIBUTOR_ALIAS 136 } 137 ], 138 "threshold": 1, 139 "visibility": { 140 "type": "public" 141 }, 142 "rid": RID, 143 "seeding": 1, 144 }, 145 { 146 "payloads": { 147 "xyz.radicle.project": { 148 "data": { 149 "defaultBranch": "master", 150 "description": "Rad repository for sorting", 151 "name": "again-hello-world", 152 }, 153 "meta": { 154 "head": "344dcd184df5bf37aab6c107fa9371a1c5b3321a", 155 "patches": { 156 "open": 0, 157 "draft": 0, 158 "archived": 0, 159 "merged": 0, 160 }, 161 "issues": { 162 "open": 0, 163 "closed": 0, 164 }, 165 } 166 } 167 }, 168 "delegates": [ 169 { 170 "id": DID, 171 "alias": CONTRIBUTOR_ALIAS 172 }, 173 ], 174 "threshold": 1, 175 "visibility": { 176 "type": "public" 177 }, 178 "rid": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE", 179 "seeding": 1, 180 } 181 ]) 182 ); 183 184 let app = super::router(seed).layer(MockConnectInfo(SocketAddr::from(( 185 [192, 168, 13, 37], 186 8080, 187 )))); 188 let response = get( 189 &app, 190 "/delegates/did:key:z6MknSLrJoTcukLrE435hVNQT4JUhbvWLX4kUzqkEStBU8Vi/repos?show=all", 191 ) 192 .await; 193 194 assert_eq!( 195 response.status(), 196 StatusCode::OK, 197 "failed response: {:?}", 198 response.json().await 199 ); 200 assert_eq!( 201 response.json().await, 202 json!([ 203 { 204 "payloads": { 205 "xyz.radicle.project": { 206 "data": { 207 "defaultBranch": "master", 208 "description": "Rad repository for tests", 209 "name": "hello-world", 210 }, 211 "meta": { 212 "head": HEAD, 213 "patches": { 214 "open": 1, 215 "draft": 0, 216 "archived": 0, 217 "merged": 0, 218 }, 219 "issues": { 220 "open": 1, 221 "closed": 0, 222 }, 223 } 224 } 225 }, 226 "delegates": [ 227 { 228 "id": DID, 229 "alias": CONTRIBUTOR_ALIAS 230 } 231 ], 232 "threshold": 1, 233 "visibility": { 234 "type": "public" 235 }, 236 "rid": RID, 237 "seeding": 1, 238 }, 239 { 240 "payloads": { 241 "xyz.radicle.project": { 242 "data": { 243 "defaultBranch": "master", 244 "description": "Rad repository for sorting", 245 "name": "again-hello-world", 246 }, 247 "meta": { 248 "head": "344dcd184df5bf37aab6c107fa9371a1c5b3321a", 249 "patches": { 250 "open": 0, 251 "draft": 0, 252 "archived": 0, 253 "merged": 0, 254 }, 255 "issues": { 256 "open": 0, 257 "closed": 0, 258 }, 259 } 260 } 261 }, 262 "delegates": [ 263 { 264 "id": DID, 265 "alias": CONTRIBUTOR_ALIAS 266 }, 267 ], 268 "threshold": 1, 269 "visibility": { 270 "type": "public" 271 }, 272 "rid": "rad:z4GypKmh1gkEfmkXtarcYnkvtFUfE", 273 "seeding": 1, 274 } 275 ]) 276 ); 277 } 278 }