learn and share notes on atproto (wip) 馃
malfestio.stormlightlabs.org/
readability
solid
axum
atproto
srs
1use crate::middleware::auth::UserContext;
2use crate::state::SharedState;
3use axum::{
4 Json,
5 extract::{Extension, Query, State},
6 http::StatusCode,
7 response::IntoResponse,
8};
9use serde::Deserialize;
10use serde_json::json;
11
12#[derive(Deserialize)]
13pub struct SearchQuery {
14 q: String,
15 #[serde(default = "default_limit")]
16 limit: i64,
17 #[serde(default = "default_offset")]
18 offset: i64,
19 source: Option<String>,
20}
21
22fn default_limit() -> i64 {
23 20
24}
25
26fn default_offset() -> i64 {
27 0
28}
29
30/// GET /api/search?q=...
31/// Search for decks, cards, and notes using full-text search
32///
33/// TODO: filter by user
34pub async fn search(
35 State(state): State<SharedState>, ctx: Option<Extension<UserContext>>, Query(query): Query<SearchQuery>,
36) -> impl IntoResponse {
37 let user_did = ctx.map(|Extension(u)| u.did);
38
39 match state
40 .search_repo
41 .search(
42 &query.q,
43 query.limit,
44 query.offset,
45 user_did.as_deref(),
46 query.source.as_deref(),
47 )
48 .await
49 {
50 Ok(results) => Json(results).into_response(),
51 Err(e) => {
52 tracing::error!("Search failed: {:?}", e);
53 (
54 StatusCode::INTERNAL_SERVER_ERROR,
55 Json(json!({"error": "Search failed"})),
56 )
57 .into_response()
58 }
59 }
60}
61
62/// GET /api/discovery
63/// Get discovery info like top tags
64pub async fn discovery(State(state): State<SharedState>) -> impl IntoResponse {
65 match state.search_repo.get_top_tags(10).await {
66 Ok(tags) => Json(json!({ "top_tags": tags })).into_response(),
67 Err(e) => {
68 tracing::error!("Discovery failed: {:?}", e);
69 (
70 StatusCode::INTERNAL_SERVER_ERROR,
71 Json(json!({"error": "Discovery failed"})),
72 )
73 .into_response()
74 }
75 }
76}
77
78#[cfg(test)]
79mod tests {
80 use super::*;
81 use crate::repository::card::mock::MockCardRepository;
82 use crate::repository::deck::mock::MockDeckRepository;
83 use crate::repository::note::mock::MockNoteRepository;
84 use crate::repository::oauth::mock::MockOAuthRepository;
85 use crate::repository::review::mock::MockReviewRepository;
86 use crate::repository::search::mock::MockSearchRepository;
87 use crate::repository::search::{SearchRepository, SearchResult};
88 use crate::repository::social::mock::MockSocialRepository;
89 use crate::state::AppState;
90 use std::sync::Arc;
91
92 fn create_test_state_with_search(search_repo: Arc<MockSearchRepository>) -> SharedState {
93 let pool = crate::db::create_mock_pool();
94 let card_repo = Arc::new(MockCardRepository::new()) as Arc<dyn crate::repository::card::CardRepository>;
95 let note_repo = Arc::new(MockNoteRepository::new()) as Arc<dyn crate::repository::note::NoteRepository>;
96 let oauth_repo = Arc::new(MockOAuthRepository::new()) as Arc<dyn crate::repository::oauth::OAuthRepository>;
97 let review_repo = Arc::new(MockReviewRepository::new()) as Arc<dyn crate::repository::review::ReviewRepository>;
98 let social_repo = Arc::new(MockSocialRepository::new()) as Arc<dyn crate::repository::social::SocialRepository>;
99 let deck_repo = Arc::new(MockDeckRepository::new()) as Arc<dyn crate::repository::deck::DeckRepository>;
100 let config = crate::state::AppConfig { pds_url: "https://bsky.social".to_string() };
101 let auth_cache = Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new()));
102 let search_repo_trait = search_repo.clone() as Arc<dyn SearchRepository>;
103 let prefs_repo = Arc::new(crate::repository::preferences::mock::MockPreferencesRepository::new())
104 as Arc<dyn crate::repository::preferences::PreferencesRepository>;
105 let sync_repo = Arc::new(crate::repository::sync::mock::MockSyncRepository::new())
106 as Arc<dyn crate::repository::sync::SyncRepository>;
107
108 Arc::new(AppState {
109 pool,
110 card_repo,
111 note_repo,
112 oauth_repo,
113 prefs_repo,
114 review_repo,
115 social_repo,
116 deck_repo,
117 search_repo: search_repo_trait,
118 sync_repo,
119 config,
120 auth_cache,
121 dpop_nonces: Arc::new(tokio::sync::RwLock::new(std::collections::HashMap::new())),
122 identity_resolver: crate::oauth::resolver::IdentityResolver::new(),
123 })
124 }
125
126 #[tokio::test]
127 async fn test_search_handler_passes_viewer_did() {
128 let search_repo = Arc::new(MockSearchRepository::new());
129 search_repo
130 .add_result(SearchResult {
131 item_type: "deck".to_string(),
132 item_id: "private-deck".to_string(),
133 creator_did: "did:alice".to_string(),
134 data: serde_json::json!({ "title": "Secret", "visibility": { "type": "Private" } }),
135 rank: 1.0,
136 source: "local".to_string(),
137 })
138 .await;
139
140 let state = create_test_state_with_search(search_repo.clone());
141 let auth_ctx = Extension(UserContext {
142 did: "did:alice".to_string(),
143 handle: "alice.test".to_string(),
144 access_token: "test_token".to_string(),
145 pds_url: "https://bsky.social".to_string(),
146 has_dpop: false,
147 });
148 let response = search(
149 State(state.clone()),
150 Some(auth_ctx),
151 Query(SearchQuery { q: "private".to_string(), limit: 10, offset: 0, source: None }),
152 )
153 .await
154 .into_response();
155
156 let body = axum::body::to_bytes(response.into_body(), usize::MAX).await.unwrap();
157 let results: Vec<SearchResult> = serde_json::from_slice(&body).unwrap();
158
159 assert_eq!(results.len(), 1, "Alice should see her private deck");
160 assert_eq!(results[0].item_id, "private-deck");
161
162 let response_anon = search(
163 State(state.clone()),
164 None,
165 Query(SearchQuery { q: "private".to_string(), limit: 10, offset: 0, source: None }),
166 )
167 .await
168 .into_response();
169
170 let body_anon = axum::body::to_bytes(response_anon.into_body(), usize::MAX)
171 .await
172 .unwrap();
173 let results_anon: Vec<SearchResult> = serde_json::from_slice(&body_anon).unwrap();
174
175 assert_eq!(results_anon.len(), 0, "Anonymous user should not see private deck");
176 }
177}