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 298 lines 11 kB view raw
1use crate::middleware::auth::UserContext; 2use crate::repository::note::NoteRepoError; 3use crate::state::SharedState; 4 5use axum::{ 6 Json, 7 extract::{Extension, Path, State}, 8 http::StatusCode, 9 response::IntoResponse, 10}; 11use malfestio_core::model::Visibility; 12use serde::Deserialize; 13use serde_json::json; 14 15#[derive(Deserialize)] 16pub struct CreateNoteRequest { 17 title: String, 18 body: String, 19 tags: Vec<String>, 20 visibility: Visibility, 21} 22 23pub async fn create_note( 24 State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Json(payload): Json<CreateNoteRequest>, 25) -> impl IntoResponse { 26 let user = match ctx { 27 Some(Extension(user)) => user, 28 None => return (StatusCode::UNAUTHORIZED, Json(json!({"error": "Unauthorized"}))).into_response(), 29 }; 30 31 let result = state 32 .note_repo 33 .create( 34 &user.did, 35 &payload.title, 36 &payload.body, 37 payload.tags, 38 payload.visibility, 39 Vec::new(), 40 ) 41 .await; 42 43 match result { 44 Ok(note) => (StatusCode::CREATED, Json(note)).into_response(), 45 Err(NoteRepoError::SerializationError(msg)) => { 46 tracing::error!("Serialization error: {}", msg); 47 ( 48 StatusCode::INTERNAL_SERVER_ERROR, 49 Json(json!({"error": "Failed to create note"})), 50 ) 51 .into_response() 52 } 53 Err(NoteRepoError::DatabaseError(msg)) => { 54 tracing::error!("Database error: {}", msg); 55 ( 56 StatusCode::INTERNAL_SERVER_ERROR, 57 Json(json!({"error": "Failed to create note"})), 58 ) 59 .into_response() 60 } 61 Err(NoteRepoError::NotFound(msg)) => (StatusCode::NOT_FOUND, Json(json!({"error": msg}))).into_response(), 62 Err(NoteRepoError::InvalidArgument(msg)) => { 63 (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response() 64 } 65 } 66} 67 68pub async fn list_notes(State(state): State<SharedState>, ctx: Option<Extension<UserContext>>) -> impl IntoResponse { 69 let viewer_did = ctx.map(|Extension(u)| u.did); 70 71 let result = state.note_repo.list(viewer_did.as_deref()).await; 72 73 match result { 74 Ok(notes) => Json(notes).into_response(), 75 Err(NoteRepoError::SerializationError(msg)) => { 76 tracing::error!("Serialization error: {}", msg); 77 ( 78 StatusCode::INTERNAL_SERVER_ERROR, 79 Json(json!({"error": "Failed to retrieve notes"})), 80 ) 81 .into_response() 82 } 83 Err(NoteRepoError::DatabaseError(msg)) => { 84 tracing::error!("Database error: {}", msg); 85 ( 86 StatusCode::INTERNAL_SERVER_ERROR, 87 Json(json!({"error": "Failed to retrieve notes"})), 88 ) 89 .into_response() 90 } 91 Err(NoteRepoError::NotFound(msg)) => (StatusCode::NOT_FOUND, Json(json!({"error": msg}))).into_response(), 92 Err(NoteRepoError::InvalidArgument(msg)) => { 93 (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response() 94 } 95 } 96} 97 98pub async fn get_note( 99 State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Path(id): Path<String>, 100) -> impl IntoResponse { 101 let viewer_did = ctx.map(|Extension(u)| u.did); 102 103 let result = state.note_repo.get(&id, viewer_did.as_deref()).await; 104 105 match result { 106 Ok(note) => Json(note).into_response(), 107 Err(NoteRepoError::NotFound(msg)) => (StatusCode::NOT_FOUND, Json(json!({"error": msg}))).into_response(), 108 Err(NoteRepoError::InvalidArgument(msg)) => { 109 if msg.contains("Access denied") { 110 (StatusCode::FORBIDDEN, Json(json!({"error": msg}))).into_response() 111 } else { 112 (StatusCode::BAD_REQUEST, Json(json!({"error": msg}))).into_response() 113 } 114 } 115 Err(NoteRepoError::SerializationError(msg)) => { 116 tracing::error!("Serialization error: {}", msg); 117 ( 118 StatusCode::INTERNAL_SERVER_ERROR, 119 Json(json!({"error": "Failed to retrieve note"})), 120 ) 121 .into_response() 122 } 123 Err(NoteRepoError::DatabaseError(msg)) => { 124 tracing::error!("Database error: {}", msg); 125 ( 126 StatusCode::INTERNAL_SERVER_ERROR, 127 Json(json!({"error": "Failed to retrieve note"})), 128 ) 129 .into_response() 130 } 131 } 132} 133 134#[cfg(test)] 135mod tests { 136 use super::*; 137 use crate::middleware::auth::UserContext; 138 use crate::repository::note::mock::MockNoteRepository; 139 use crate::state::AppState; 140 use malfestio_core::model::{Note, Visibility}; 141 use std::sync::Arc; 142 143 fn create_test_state() -> SharedState { 144 let pool = crate::db::create_mock_pool(); 145 let card_repo = Arc::new(crate::repository::card::mock::MockCardRepository::new()) 146 as Arc<dyn crate::repository::card::CardRepository>; 147 let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>; 148 let oauth_repo = Arc::new(crate::repository::oauth::mock::MockOAuthRepository::new()) 149 as Arc<dyn crate::repository::oauth::OAuthRepository>; 150 AppState::new_with_repos(pool, card_repo, note_repo, oauth_repo) 151 } 152 153 #[tokio::test] 154 async fn test_create_note_success() { 155 let state = create_test_state(); 156 let user = UserContext { 157 did: "did:plc:test123".to_string(), 158 handle: "test.handle".to_string(), 159 access_token: "test_token".to_string(), 160 pds_url: "https://bsky.social".to_string(), 161 has_dpop: false, 162 }; 163 164 let payload = CreateNoteRequest { 165 title: "Test Note".to_string(), 166 body: "This is a test note".to_string(), 167 tags: vec!["test".to_string()], 168 visibility: Visibility::Private, 169 }; 170 171 let response = create_note(axum::extract::State(state), Some(Extension(user)), Json(payload)) 172 .await 173 .into_response(); 174 175 assert_eq!(response.status(), StatusCode::CREATED); 176 } 177 178 #[tokio::test] 179 async fn test_create_note_unauthorized() { 180 let state = create_test_state(); 181 182 let payload = CreateNoteRequest { 183 title: "Test Note".to_string(), 184 body: "This is a test note".to_string(), 185 tags: vec!["test".to_string()], 186 visibility: Visibility::Private, 187 }; 188 189 let response = create_note(axum::extract::State(state), None, Json(payload)) 190 .await 191 .into_response(); 192 193 assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 194 } 195 196 #[tokio::test] 197 async fn test_list_notes_with_visibility_filtering() { 198 let pool = crate::db::create_mock_pool(); 199 200 let test_notes = vec![ 201 Note { 202 id: "note-1".to_string(), 203 owner_did: "did:plc:test".to_string(), 204 title: "Public Note".to_string(), 205 body: "Public content".to_string(), 206 tags: vec![], 207 visibility: Visibility::Public, 208 published_at: None, 209 links: vec![], 210 language: None, 211 }, 212 Note { 213 id: "note-2".to_string(), 214 owner_did: "did:plc:test".to_string(), 215 title: "Private Note".to_string(), 216 body: "Private content".to_string(), 217 tags: vec![], 218 visibility: Visibility::Private, 219 published_at: None, 220 links: vec![], 221 language: None, 222 }, 223 ]; 224 225 let note_repo = 226 Arc::new(MockNoteRepository::with_notes(test_notes)) as Arc<dyn crate::repository::note::NoteRepository>; 227 let card_repo = Arc::new(crate::repository::card::mock::MockCardRepository::new()) 228 as Arc<dyn crate::repository::card::CardRepository>; 229 let oauth_repo = Arc::new(crate::repository::oauth::mock::MockOAuthRepository::new()) 230 as Arc<dyn crate::repository::oauth::OAuthRepository>; 231 232 let state = AppState::new_with_repos(pool, card_repo, note_repo, oauth_repo); 233 234 let response = list_notes(axum::extract::State(state.clone()), None) 235 .await 236 .into_response(); 237 238 assert_eq!(response.status(), StatusCode::OK); 239 } 240 241 #[tokio::test] 242 async fn test_get_note_access_control() { 243 let pool = crate::db::create_mock_pool(); 244 245 let note_id = "test-note-id".to_string(); 246 let test_notes = vec![Note { 247 id: note_id.clone(), 248 owner_did: "did:plc:owner".to_string(), 249 title: "Private Note".to_string(), 250 body: "Private content".to_string(), 251 tags: vec![], 252 visibility: Visibility::Private, 253 published_at: None, 254 links: vec![], 255 language: None, 256 }]; 257 258 let note_repo = 259 Arc::new(MockNoteRepository::with_notes(test_notes)) as Arc<dyn crate::repository::note::NoteRepository>; 260 let card_repo = Arc::new(crate::repository::card::mock::MockCardRepository::new()) 261 as Arc<dyn crate::repository::card::CardRepository>; 262 let oauth_repo = Arc::new(crate::repository::oauth::mock::MockOAuthRepository::new()) 263 as Arc<dyn crate::repository::oauth::OAuthRepository>; 264 265 let state = AppState::new_with_repos(pool, card_repo, note_repo, oauth_repo); 266 267 let owner = UserContext { 268 did: "did:plc:owner".to_string(), 269 handle: "owner.handle".to_string(), 270 access_token: "test_token".to_string(), 271 pds_url: "https://bsky.social".to_string(), 272 has_dpop: false, 273 }; 274 275 let response = get_note( 276 axum::extract::State(state.clone()), 277 Some(Extension(owner)), 278 Path(note_id.clone()), 279 ) 280 .await 281 .into_response(); 282 283 assert_eq!(response.status(), StatusCode::OK); 284 285 let other_user = UserContext { 286 did: "did:plc:other".to_string(), 287 handle: "other.handle".to_string(), 288 access_token: "test_token".to_string(), 289 pds_url: "https://bsky.social".to_string(), 290 has_dpop: false, 291 }; 292 let response = get_note(axum::extract::State(state), Some(Extension(other_user)), Path(note_id)) 293 .await 294 .into_response(); 295 296 assert_eq!(response.status(), StatusCode::FORBIDDEN); 297 } 298}