learn and share notes on atproto (wip) 馃 malfestio.stormlightlabs.org/
readability solid axum atproto srs
5
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 316 lines 12 kB view raw
1use crate::middleware::auth::UserContext; 2use crate::state::SharedState; 3 4use axum::{ 5 Json, 6 extract::{Extension, Path, State}, 7 http::StatusCode, 8 response::IntoResponse, 9}; 10use serde::Deserialize; 11use serde_json::json; 12 13#[derive(Deserialize)] 14pub struct AddCommentRequest { 15 pub content: String, 16 pub parent_id: Option<String>, 17} 18 19pub async fn follow( 20 State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Path(subject_did): Path<String>, 21) -> impl IntoResponse { 22 let user = match ctx { 23 Some(Extension(user)) => user, 24 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 25 }; 26 27 let result = state.social_repo.follow(&user.did, &subject_did).await; 28 29 match result { 30 Ok(_) => (StatusCode::OK, Json(json!({"status": "followed"}))).into_response(), 31 Err(malfestio_core::Error::Database(msg)) => { 32 tracing::error!("Database error: {}", msg); 33 ( 34 StatusCode::INTERNAL_SERVER_ERROR, 35 Json(json!({"error": "Failed to follow"})), 36 ) 37 .into_response() 38 } 39 Err(e) => ( 40 StatusCode::INTERNAL_SERVER_ERROR, 41 Json(json!({"error": format!("{:?}", e)})), 42 ) 43 .into_response(), 44 } 45} 46 47pub async fn unfollow( 48 State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Path(subject_did): Path<String>, 49) -> impl IntoResponse { 50 let user = match ctx { 51 Some(Extension(user)) => user, 52 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 53 }; 54 55 let result = state.social_repo.unfollow(&user.did, &subject_did).await; 56 57 match result { 58 Ok(_) => (StatusCode::OK, Json(json!({"status": "unfollowed"}))).into_response(), 59 Err(malfestio_core::Error::Database(msg)) => { 60 tracing::error!("Database error: {}", msg); 61 ( 62 StatusCode::INTERNAL_SERVER_ERROR, 63 Json(json!({"error": "Failed to unfollow"})), 64 ) 65 .into_response() 66 } 67 Err(e) => ( 68 StatusCode::INTERNAL_SERVER_ERROR, 69 Json(json!({"error": format!("{:?}", e)})), 70 ) 71 .into_response(), 72 } 73} 74 75pub async fn get_followers(State(state): State<SharedState>, Path(did): Path<String>) -> impl IntoResponse { 76 let result = state.social_repo.get_followers(&did).await; 77 78 match result { 79 Ok(followers) => Json(followers).into_response(), 80 Err(malfestio_core::Error::Database(msg)) => { 81 tracing::error!("Database error: {}", msg); 82 ( 83 StatusCode::INTERNAL_SERVER_ERROR, 84 Json(json!({"error": "Failed to get followers"})), 85 ) 86 .into_response() 87 } 88 Err(e) => ( 89 StatusCode::INTERNAL_SERVER_ERROR, 90 Json(json!({"error": format!("{:?}", e)})), 91 ) 92 .into_response(), 93 } 94} 95 96pub async fn get_following(State(state): State<SharedState>, Path(did): Path<String>) -> impl IntoResponse { 97 let result = state.social_repo.get_following(&did).await; 98 99 match result { 100 Ok(following) => Json(following).into_response(), 101 Err(malfestio_core::Error::Database(msg)) => { 102 tracing::error!("Database error: {}", msg); 103 ( 104 StatusCode::INTERNAL_SERVER_ERROR, 105 Json(json!({"error": "Failed to get following"})), 106 ) 107 .into_response() 108 } 109 Err(e) => ( 110 StatusCode::INTERNAL_SERVER_ERROR, 111 Json(json!({"error": format!("{:?}", e)})), 112 ) 113 .into_response(), 114 } 115} 116 117pub async fn add_comment( 118 State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Path(deck_id): Path<String>, 119 Json(payload): Json<AddCommentRequest>, 120) -> impl IntoResponse { 121 let user = match ctx { 122 Some(Extension(user)) => user, 123 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 124 }; 125 126 let result = state 127 .social_repo 128 .add_comment(&deck_id, &user.did, &payload.content, payload.parent_id.as_deref()) 129 .await; 130 131 match result { 132 Ok(comment) => (StatusCode::CREATED, Json(comment)).into_response(), 133 Err(malfestio_core::Error::Database(msg)) => { 134 tracing::error!("Database error: {}", msg); 135 ( 136 StatusCode::INTERNAL_SERVER_ERROR, 137 Json(json!({"error": "Failed to add comment"})), 138 ) 139 .into_response() 140 } 141 Err(e) => ( 142 StatusCode::INTERNAL_SERVER_ERROR, 143 Json(json!({"error": format!("{:?}", e)})), 144 ) 145 .into_response(), 146 } 147} 148 149pub async fn get_comments(State(state): State<SharedState>, Path(deck_id): Path<String>) -> impl IntoResponse { 150 let result = state.social_repo.get_comments(&deck_id).await; 151 152 match result { 153 Ok(comments) => Json(comments).into_response(), 154 Err(malfestio_core::Error::Database(msg)) => { 155 tracing::error!("Database error: {}", msg); 156 ( 157 StatusCode::INTERNAL_SERVER_ERROR, 158 Json(json!({"error": "Failed to get comments"})), 159 ) 160 .into_response() 161 } 162 Err(e) => ( 163 StatusCode::INTERNAL_SERVER_ERROR, 164 Json(json!({"error": format!("{:?}", e)})), 165 ) 166 .into_response(), 167 } 168} 169 170#[cfg(test)] 171mod tests { 172 use super::*; 173 use crate::middleware::auth::UserContext; 174 use crate::repository::card::mock::MockCardRepository; 175 use crate::repository::note::mock::MockNoteRepository; 176 use crate::repository::oauth::mock::MockOAuthRepository; 177 use crate::repository::review::mock::MockReviewRepository; 178 use crate::repository::social::{SocialRepository, mock::MockSocialRepository}; 179 use crate::state::AppState; 180 use axum::extract::Json; 181 use std::sync::Arc; 182 183 fn create_test_state_with_social(social_repo: Arc<dyn SocialRepository>) -> SharedState { 184 let pool = crate::db::create_mock_pool(); 185 let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>; 186 let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>; 187 let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>; 188 let preferences_repo = Arc::new(crate::repository::preferences::mock::MockPreferencesRepository::new()) 189 as Arc<dyn crate::repository::preferences::PreferencesRepository>; 190 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn crate::repository::review::ReviewRepository>; 191 192 let deck_repo = Arc::new(crate::repository::deck::mock::MockDeckRepository::new()) 193 as Arc<dyn crate::repository::deck::DeckRepository>; 194 let config = crate::state::AppConfig { pds_url: "https://bsky.social".to_string() }; 195 let search_repo = Arc::new(crate::repository::search::mock::MockSearchRepository::new()) 196 as Arc<dyn crate::repository::search::SearchRepository>; 197 let auth_cache = Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())); 198 let sync_repo = Arc::new(crate::repository::sync::mock::MockSyncRepository::new()) 199 as Arc<dyn crate::repository::sync::SyncRepository>; 200 201 Arc::new(AppState { 202 pool, 203 card_repo, 204 note_repo, 205 oauth_repo, 206 prefs_repo: preferences_repo, 207 review_repo, 208 social_repo, 209 deck_repo, 210 search_repo, 211 sync_repo, 212 config, 213 auth_cache, 214 dpop_nonces: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())), 215 identity_resolver: crate::oauth::resolver::IdentityResolver::new(), 216 }) 217 } 218 219 #[tokio::test] 220 async fn test_follow_success() { 221 let social_repo = Arc::new(MockSocialRepository::new()); 222 let state = create_test_state_with_social(social_repo.clone()); 223 let user = UserContext { 224 did: "did:plc:follower".to_string(), 225 handle: "follower".to_string(), 226 access_token: "test_token".to_string(), 227 pds_url: "https://bsky.social".to_string(), 228 has_dpop: false, 229 }; 230 231 let response = follow(State(state), Some(Extension(user)), Path("did:plc:subject".to_string())) 232 .await 233 .into_response(); 234 235 assert_eq!(response.status(), StatusCode::OK); 236 237 let followers = social_repo.get_followers("did:plc:subject").await.unwrap(); 238 assert!(followers.contains(&"did:plc:follower".to_string())); 239 } 240 241 #[tokio::test] 242 async fn test_unfollow_success() { 243 let social_repo = Arc::new(MockSocialRepository::new()); 244 social_repo.follow("did:plc:follower", "did:plc:subject").await.unwrap(); 245 246 let state = create_test_state_with_social(social_repo.clone()); 247 let user = UserContext { 248 did: "did:plc:follower".to_string(), 249 handle: "follower".to_string(), 250 access_token: "test_token".to_string(), 251 pds_url: "https://bsky.social".to_string(), 252 has_dpop: false, 253 }; 254 255 let response = unfollow(State(state), Some(Extension(user)), Path("did:plc:subject".to_string())) 256 .await 257 .into_response(); 258 259 assert_eq!(response.status(), StatusCode::OK); 260 261 let followers = social_repo.get_followers("did:plc:subject").await.unwrap(); 262 assert!(followers.is_empty()); 263 } 264 265 #[tokio::test] 266 async fn test_get_followers() { 267 let social_repo = Arc::new(MockSocialRepository::new()); 268 social_repo.follow("did:plc:1", "did:plc:subject").await.unwrap(); 269 social_repo.follow("did:plc:2", "did:plc:subject").await.unwrap(); 270 271 let state = create_test_state_with_social(social_repo); 272 273 let response = get_followers(State(state), Path("did:plc:subject".to_string())) 274 .await 275 .into_response(); 276 277 assert_eq!(response.status(), StatusCode::OK); 278 279 let body_bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap(); 280 let followers: Vec<String> = serde_json::from_slice(&body_bytes).unwrap(); 281 282 assert_eq!(followers.len(), 2); 283 assert!(followers.contains(&"did:plc:1".to_string())); 284 assert!(followers.contains(&"did:plc:2".to_string())); 285 } 286 287 #[tokio::test] 288 async fn test_add_comment_success() { 289 let social_repo = Arc::new(MockSocialRepository::new()); 290 let state = create_test_state_with_social(social_repo.clone()); 291 let user = UserContext { 292 did: "did:plc:author".to_string(), 293 handle: "author".to_string(), 294 access_token: "test_token".to_string(), 295 pds_url: "https://bsky.social".to_string(), 296 has_dpop: false, 297 }; 298 299 let payload = AddCommentRequest { content: "Great deck!".to_string(), parent_id: None }; 300 301 let response = add_comment( 302 State(state), 303 Some(Extension(user)), 304 Path("deck-1".to_string()), 305 Json(payload), 306 ) 307 .await 308 .into_response(); 309 310 assert_eq!(response.status(), StatusCode::CREATED); 311 312 let comments = social_repo.get_comments("deck-1").await.unwrap(); 313 assert_eq!(comments.len(), 1); 314 assert_eq!(comments[0].content, "Great deck!"); 315 } 316}