learn and share notes on atproto (wip) 馃
malfestio.stormlightlabs.org/
readability
solid
axum
atproto
srs
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}