Rust AppView - highly experimental!

chore: refactor parakeet

+1270 -1129
+4 -1
parakeet/Cargo.toml
··· 19 19 eyre = "0.6.12" 20 20 figment = { version = "0.10.19", features = ["env", "toml"] } 21 21 itertools = "0.14.0" 22 + jacquard = { workspace = true } 23 + jacquard-api = { workspace = true } 24 + jacquard-axum = { workspace = true } 25 + jacquard-common = { workspace = true } 22 26 jsonwebtoken = { git = "https://gitlab.com/parakeet-social/jsonwebtoken", branch = "es256k" } 23 - lexica = { path = "../lexica" } 24 27 moka = { version = "0.12", features = ["future"] } 25 28 multibase = "0.9.1" 26 29 parakeet-db = { path = "../parakeet-db" }
+29 -31
parakeet/src/entities/converters/post.rs
··· 3 3 /// This replaces the complex hydration layer with simple, direct conversions 4 4 5 5 use crate::entities::core::{PostEntity, PostData}; 6 - use lexica::app_bsky::feed::PostView; 7 - use lexica::app_bsky::actor::ProfileViewBasic; 8 - use lexica::app_bsky::RecordStats; 6 + use jacquard_api::app_bsky::feed::PostView; 7 + use jacquard_api::app_bsky::actor::ProfileViewBasic; 9 8 10 9 impl PostEntity { 11 10 /// Convert PostData directly to PostView 12 11 /// 13 12 /// This is much simpler than the old hydration system - we just map the fields directly 14 - pub fn post_to_post_view(&self, post_data: &PostData, author_profile: ProfileViewBasic) -> PostView { 15 - let uri = format!( 16 - "at://{}/app.bsky.feed.post/{}", 17 - post_data.author.did, 18 - parakeet_db::tid_util::encode_tid(post_data.post.rkey) 19 - ); 20 - 21 - let cid = parakeet_db::cid_util::digest_to_record_cid_string(&post_data.post.cid) 22 - .unwrap_or_default(); 13 + pub fn post_to_post_view<'a>(&self, post_data: &'a PostData, author_profile: ProfileViewBasic<'a>, uri: &'a str, cid: &'a str) -> PostView<'static> { 14 + use jacquard_common::types::string::{AtUri, Cid}; 15 + use jacquard_common::types::string::Datetime; 16 + use jacquard_common::IntoStatic; 23 17 24 - PostView { 25 - uri: uri.clone(), 26 - cid, 27 - author: author_profile, 28 - record: serde_json::Value::Object(serde_json::Map::new()), // Would need actual record data 29 - embed: None, // Would need embed hydration 30 - stats: RecordStats { 31 - reply_count: post_data.post.reply_count as i64, 32 - repost_count: post_data.post.repost_count as i64, 33 - like_count: post_data.post.like_count as i64, 34 - quote_count: post_data.post.quote_count as i64, 35 - bookmark_count: 0, // Not tracked in our schema 36 - }, 37 - indexed_at: parakeet_db::tid_util::tid_to_datetime(post_data.post.rkey), 38 - labels: vec![], 39 - viewer: None, // Would need viewer state 40 - threadgate: None, // Would need threadgate data 41 - } 18 + PostView::new() 19 + .uri(AtUri::from(uri)) 20 + .cid(Cid::from(cid)) 21 + .author(author_profile) 22 + .record(serde_json::Value::Object(serde_json::Map::new())) 23 + .reply_count(Some(post_data.post.reply_count as i64)) 24 + .repost_count(Some(post_data.post.repost_count as i64)) 25 + .like_count(Some(post_data.post.like_count as i64)) 26 + .quote_count(Some(post_data.post.quote_count as i64)) 27 + .bookmark_count(Some(0)) 28 + .indexed_at(Datetime::new( 29 + &parakeet_db::tid_util::tid_to_datetime(post_data.post.rkey) 30 + .to_rfc3339_opts(chrono::SecondsFormat::Millis, true) 31 + ).unwrap()) 32 + .build() 33 + .into_static() 42 34 } 43 35 44 36 /// Get PostView by composite key (direct conversion, no hydration) ··· 50 42 .get_profile_view_basic(post_data.author.id, viewer_did) 51 43 .await?; 52 44 53 - Some(self.post_to_post_view(&post_data, author_profile)) 45 + // Build the post URI and CID 46 + let uri = format!("at://{}/app.bsky.feed.post/{}", 47 + author_profile.did.as_str(), 48 + parakeet_db::tid_util::encode_tid(post_data.post.rkey)); 49 + let cid = parakeet_db::cid_util::digest_to_blob_cid_string(&post_data.post.cid).unwrap_or_default(); 50 + 51 + Some(self.post_to_post_view(&post_data, author_profile, &uri, &cid)) 54 52 } 55 53 56 54 /// Get PostView by URI
+283 -149
parakeet/src/entities/converters/profile.rs
··· 4 4 5 5 use crate::entities::core::{ProfileEntity, PostEntity, StarterpackEntity}; 6 6 use parakeet_db::models::Actor; 7 - use lexica::app_bsky::actor::{ 7 + use jacquard_api::app_bsky::actor::{ 8 8 ProfileView, ProfileViewDetailed, ProfileViewBasic, 9 - ProfileAssociated, ProfileAssociatedChat, ChatAllowIncoming, 10 - ProfileAssociatedActivitySubscription, ProfileAllowSubscriptions, 11 - ProfileViewerState 9 + ProfileAssociated, ProfileAssociatedChat, 10 + ProfileAssociatedActivitySubscription, ViewerState 12 11 }; 12 + use jacquard_common::types::string::{CowStr, Did, Uri, Handle}; 13 + use jacquard_common::types::aturi::AtUri; 14 + use jacquard_common::types::datetime::Datetime; 15 + use jacquard_common::IntoStatic; 13 16 use std::str::FromStr; 14 17 use std::sync::Arc; 15 18 16 19 /// Standalone function for converting Actor to ProfileView 17 - pub fn actor_to_profile_view(actor: &Actor) -> ProfileView { 18 - ProfileView { 19 - did: actor.did.clone(), 20 - handle: actor.handle.clone().unwrap_or_else(|| "handle.invalid".to_string()), 21 - display_name: actor.profile_display_name.clone(), 22 - description: actor.profile_description.clone(), 23 - avatar: actor.profile_avatar_cid.as_ref() 24 - .and_then(|cid| parakeet_db::cid_util::digest_to_blob_cid_string(cid) 25 - .map(|cid_str| format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", actor.did, cid_str))), 26 - indexed_at: actor.last_indexed.unwrap_or_else(|| chrono::Utc::now()), 27 - created_at: actor.account_created_at.unwrap_or_else(|| chrono::Utc::now()), 20 + pub fn actor_to_profile_view(actor: &Actor) -> ProfileView<'static> { 21 + let handle_string = actor.handle.clone().unwrap_or_else(|| "handle.invalid".to_string()); 28 22 29 - // Basic required fields 30 - associated: None, 31 - labels: Vec::new(), 32 - viewer: None, 33 - pronouns: actor.profile_pronouns.clone(), 34 - status: None, 35 - verification: None, 23 + // Keep avatar_url alive for the entire function scope 24 + let avatar_url = actor.profile_avatar_cid.as_ref() 25 + .and_then(|avatar_cid| parakeet_db::cid_util::digest_to_blob_cid_string(avatar_cid)) 26 + .map(|cid_str| format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", actor.did, cid_str)); 27 + 28 + let mut builder = ProfileView::new() 29 + .did(Did::new(&actor.did).unwrap()) 30 + .handle(Handle::new(&handle_string).unwrap()); 31 + 32 + // Optional fields 33 + if let Some(ref display_name) = actor.profile_display_name { 34 + builder = builder.display_name(Some(CowStr::from(display_name.clone()))); 35 + } 36 + 37 + if let Some(ref description) = actor.profile_description { 38 + builder = builder.description(Some(CowStr::from(description.clone()))); 39 + } 40 + 41 + if let Some(ref url) = avatar_url { 42 + builder = builder.avatar(Some(Uri::new(url).unwrap())); 36 43 } 44 + 45 + if let Some(indexed_at) = actor.last_indexed { 46 + builder = builder.indexed_at(Datetime::new(indexed_at.fixed_offset())); 47 + } 48 + 49 + if let Some(created_at) = actor.account_created_at { 50 + builder = builder.created_at(Datetime::new(created_at.fixed_offset())); 51 + } 52 + 53 + if let Some(ref pronouns) = actor.profile_pronouns { 54 + builder = builder.pronouns(Some(CowStr::from(pronouns.clone()))); 55 + } 56 + 57 + builder.build().into_static() 37 58 } 38 59 39 60 impl ProfileEntity { 40 61 /// Build viewer state for an actor from the perspective of a viewer 41 - async fn build_viewer_state(&self, actor: &Actor, viewer_did: &str) -> Option<ProfileViewerState> { 62 + async fn build_viewer_state(&self, actor: &Actor, viewer_did: &str) -> Option<ViewerState<'static>> { 42 63 // Get viewer's actor_id 43 64 let viewer_actor_id = self.resolve_identifier(viewer_did).await.ok()?; 44 65 ··· 88 109 parakeet_db::tid_util::encode_tid(b.rkey))) 89 110 }); 90 111 91 - Some(ProfileViewerState { 92 - muted, 112 + Some(ViewerState { 113 + muted: Some(muted), 114 + blocked_by: Some(blocked_by), 115 + blocking: blocking.as_ref().and_then(|s| AtUri::new(s).ok()), 116 + followed_by: followed_by.as_ref().and_then(|s| AtUri::new(s).ok()), 117 + following: following.as_ref().and_then(|s| AtUri::new(s).ok()), 93 118 muted_by_list: None, 94 - blocked_by, 95 - blocking, 96 119 blocking_by_list: None, 97 - following, 98 - followed_by, 99 120 known_followers: None, 100 - }) 121 + activity_subscription: None, 122 + ..Default::default() 123 + }.into_static()) 101 124 } 102 125 /// Convert an Actor directly to ProfileViewDetailed 103 126 /// 104 127 /// This is much simpler than the old hydration system - we just map the fields directly 105 - pub fn actor_to_profile_view_detailed(&self, actor: &Actor) -> ProfileViewDetailed { 106 - ProfileViewDetailed { 107 - did: actor.did.clone(), 108 - handle: actor.handle.clone().unwrap_or_else(|| "handle.invalid".to_string()), 109 - display_name: actor.profile_display_name.clone(), 110 - description: actor.profile_description.clone(), 111 - avatar: actor.profile_avatar_cid.as_ref() 112 - .and_then(|cid| parakeet_db::cid_util::digest_to_blob_cid_string(cid) 113 - .map(|cid_str| format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", actor.did, cid_str))), 114 - banner: actor.profile_banner_cid.as_ref() 115 - .and_then(|cid| parakeet_db::cid_util::digest_to_blob_cid_string(cid) 116 - .map(|cid_str| format!("https://cdn.bsky.social/img/banner/plain/{}/{}@jpeg", actor.did, cid_str))), 117 - followers_count: actor.followers_count.unwrap_or(0) as i64, 118 - follows_count: actor.following_count.unwrap_or(0) as i64, 119 - posts_count: actor.posts_count.unwrap_or(0) as i64, 120 - indexed_at: actor.last_indexed.unwrap_or_else(|| chrono::Utc::now()), 121 - created_at: actor.account_created_at.unwrap_or_else(|| chrono::Utc::now()), 128 + pub fn actor_to_profile_view_detailed(&self, actor: &Actor) -> ProfileViewDetailed<'static> { 129 + let handle_string = actor.handle.clone().unwrap_or_else(|| "handle.invalid".to_string()); 122 130 123 - // Associated data 124 - associated: Some(ProfileAssociated { 125 - lists: None, 131 + // Keep URLs alive for the entire function scope 132 + let avatar_url = actor.profile_avatar_cid.as_ref() 133 + .and_then(|avatar_cid| parakeet_db::cid_util::digest_to_blob_cid_string(avatar_cid)) 134 + .map(|cid_str| format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", actor.did, cid_str)); 135 + 136 + let banner_url = actor.profile_banner_cid.as_ref() 137 + .and_then(|banner_cid| parakeet_db::cid_util::digest_to_blob_cid_string(banner_cid)) 138 + .map(|cid_str| format!("https://cdn.bsky.social/img/banner/plain/{}/{}@jpeg", actor.did, cid_str)); 139 + 140 + let mut builder = ProfileViewDetailed::new() 141 + .did(Did::new(&actor.did).unwrap()) 142 + .handle(Handle::new(&handle_string).unwrap()); 143 + 144 + // Optional fields 145 + if let Some(ref display_name) = actor.profile_display_name { 146 + builder = builder.display_name(Some(CowStr::from(display_name.clone()))); 147 + } 148 + 149 + if let Some(ref description) = actor.profile_description { 150 + builder = builder.description(Some(CowStr::from(description.clone()))); 151 + } 152 + 153 + if let Some(ref url) = avatar_url { 154 + builder = builder.avatar(Some(Uri::new(url).unwrap())); 155 + } 156 + 157 + if let Some(ref url) = banner_url { 158 + builder = builder.banner(Some(Uri::new(url).unwrap())); 159 + } 160 + 161 + builder = builder 162 + .followers_count(actor.followers_count.unwrap_or(0) as i64) 163 + .follows_count(actor.following_count.unwrap_or(0) as i64) 164 + .posts_count(actor.posts_count.unwrap_or(0) as i64); 165 + 166 + if let Some(indexed_at) = actor.last_indexed { 167 + builder = builder.indexed_at(Datetime::new(indexed_at.fixed_offset())); 168 + } 169 + 170 + if let Some(created_at) = actor.account_created_at { 171 + builder = builder.created_at(Datetime::new(created_at.fixed_offset())); 172 + } 173 + 174 + // Build associated data if any relevant fields are present 175 + if actor.chat_allow_incoming.is_some() || actor.notif_decl_allow_subscriptions.is_some() { 176 + let chat = actor.chat_allow_incoming.as_ref().map(|chat_type| { 177 + ProfileAssociatedChat { 178 + allow_incoming: chat_type.to_string().into(), 179 + ..Default::default() 180 + }.into_static() 181 + }); 182 + 183 + let activity_subscription = actor.notif_decl_allow_subscriptions.as_ref().map(|sub_type| { 184 + ProfileAssociatedActivitySubscription { 185 + allow_subscriptions: sub_type.to_string().into(), 186 + extra_data: None, 187 + }.into_static() 188 + }); 189 + 190 + let associated = ProfileAssociated { 191 + chat, 192 + activity_subscription, 126 193 feedgens: None, 194 + lists: None, 127 195 starter_packs: None, 196 + extra_data: None, 128 197 labeler: None, 129 - chat: actor.chat_allow_incoming.as_ref().and_then(|chat_type| { 130 - ChatAllowIncoming::from_str(&chat_type.to_string()).ok().map(|allow| { 131 - ProfileAssociatedChat { allow_incoming: allow } 132 - }) 133 - }), 134 - activity_subscription: actor.notif_decl_allow_subscriptions.as_ref().and_then(|sub_type| { 135 - ProfileAllowSubscriptions::from_str(&sub_type.to_string()).ok().map(|allow| { 136 - ProfileAssociatedActivitySubscription { allow_subscriptions: allow } 137 - }) 138 - }), 139 - }), 198 + }.into_static(); 140 199 141 - // These fields require viewer state - set to None/empty for now 142 - viewer: None, 143 - labels: vec![], 200 + builder = builder.associated(associated); 201 + } 144 202 145 - // Additional optional fields 146 - pronouns: actor.profile_pronouns.clone(), 147 - website: actor.profile_website.clone(), 148 - pinned_post: None, // Would need pinned post data from actor.pinned_post 149 - joined_via_starter_pack: None, // Would need starter pack data 150 - verification: None, // Would need verification data 151 - status: None, // Would need status data 203 + // Additional optional fields 204 + if let Some(ref pronouns) = actor.profile_pronouns { 205 + builder = builder.pronouns(Some(CowStr::from(pronouns.clone()))); 152 206 } 207 + 208 + builder.build().into_static() 153 209 } 154 210 155 211 /// Convert an Actor to ProfileViewDetailed with enriched fields (pinned_post, joined_via_starter_pack) ··· 160 216 actor: &Actor, 161 217 post_entity: Option<&PostEntity>, 162 218 starterpack_entity: Option<&StarterpackEntity>, 163 - ) -> ProfileViewDetailed { 164 - let mut profile = self.actor_to_profile_view_detailed(actor); 219 + ) -> ProfileViewDetailed<'static> { 220 + let handle_string = actor.handle.clone().unwrap_or_else(|| "handle.invalid".to_string()); 221 + 222 + // Keep URLs alive for the entire function scope 223 + let avatar_url = actor.profile_avatar_cid.as_ref() 224 + .and_then(|avatar_cid| parakeet_db::cid_util::digest_to_blob_cid_string(avatar_cid)) 225 + .map(|cid_str| format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", actor.did, cid_str)); 226 + 227 + let banner_url = actor.profile_banner_cid.as_ref() 228 + .and_then(|banner_cid| parakeet_db::cid_util::digest_to_blob_cid_string(banner_cid)) 229 + .map(|cid_str| format!("https://cdn.bsky.social/img/banner/plain/{}/{}@jpeg", actor.did, cid_str)); 230 + 231 + let mut builder = ProfileViewDetailed::new() 232 + .did(Did::new(&actor.did).unwrap()) 233 + .handle(Handle::new(&handle_string).unwrap()); 234 + 235 + // Copy all the fields from the basic conversion 236 + if let Some(ref display_name) = actor.profile_display_name { 237 + builder = builder.display_name(Some(CowStr::from(display_name.clone()))); 238 + } 239 + 240 + if let Some(ref description) = actor.profile_description { 241 + builder = builder.description(Some(CowStr::from(description.clone()))); 242 + } 243 + 244 + if let Some(ref url) = avatar_url { 245 + builder = builder.avatar(Some(Uri::new(url).unwrap())); 246 + } 247 + 248 + if let Some(ref url) = banner_url { 249 + builder = builder.banner(Some(Uri::new(url).unwrap())); 250 + } 251 + 252 + builder = builder 253 + .followers_count(actor.followers_count.unwrap_or(0) as i64) 254 + .follows_count(actor.following_count.unwrap_or(0) as i64) 255 + .posts_count(actor.posts_count.unwrap_or(0) as i64); 256 + 257 + if let Some(indexed_at) = actor.last_indexed { 258 + builder = builder.indexed_at(Datetime::new(indexed_at.fixed_offset())); 259 + } 260 + 261 + if let Some(created_at) = actor.account_created_at { 262 + builder = builder.created_at(Datetime::new(created_at.fixed_offset())); 263 + } 264 + 265 + // Build associated data if any relevant fields are present 266 + if actor.chat_allow_incoming.is_some() || actor.notif_decl_allow_subscriptions.is_some() { 267 + let chat = actor.chat_allow_incoming.as_ref().map(|chat_type| { 268 + ProfileAssociatedChat { 269 + allow_incoming: chat_type.to_string().into(), 270 + ..Default::default() 271 + }.into_static() 272 + }); 273 + 274 + let activity_subscription = actor.notif_decl_allow_subscriptions.as_ref().map(|sub_type| { 275 + ProfileAssociatedActivitySubscription { 276 + allow_subscriptions: sub_type.to_string().into(), 277 + extra_data: None, 278 + }.into_static() 279 + }); 280 + 281 + let associated = ProfileAssociated { 282 + chat, 283 + activity_subscription, 284 + feedgens: None, 285 + lists: None, 286 + starter_packs: None, 287 + extra_data: None, 288 + labeler: None, 289 + }.into_static(); 290 + 291 + builder = builder.associated(associated); 292 + } 165 293 166 294 // Populate pinned_post if available 167 295 if let (Some(pinned_rkey), Some(post_entity)) = (actor.profile_pinned_post_rkey, post_entity) { 168 - // Use PostEntity's new method to get the post as a StrongRef 169 296 if let Ok(strong_ref) = post_entity.get_post_strong_ref(actor.id, pinned_rkey).await { 170 - profile.pinned_post = strong_ref; 297 + builder = builder.pinned_post(strong_ref); 171 298 } 172 299 } 173 300 174 301 // Populate joined_via_starter_pack if available 175 302 if let (Some(sp_id), Some(starterpack_entity)) = (actor.profile_joined_sp_id, starterpack_entity) { 176 - profile.joined_via_starter_pack = starterpack_entity.get_by_id(sp_id).await.ok().flatten(); 303 + if let Some(sp) = starterpack_entity.get_by_id(sp_id).await.ok().flatten() { 304 + builder = builder.joined_via_starter_pack(sp); 305 + } 306 + } 307 + 308 + if let Some(ref pronouns) = actor.profile_pronouns { 309 + builder = builder.pronouns(Some(CowStr::from(pronouns.clone()))); 177 310 } 178 311 179 - profile 312 + builder.build().into_static() 180 313 } 181 314 182 315 /// Convert an Actor to ProfileView (simpler variant) 183 - pub fn actor_to_profile_view(&self, actor: &Actor) -> ProfileView { 184 - ProfileView { 185 - did: actor.did.clone(), 186 - handle: actor.handle.clone().unwrap_or_else(|| "handle.invalid".to_string()), 187 - display_name: actor.profile_display_name.clone(), 188 - description: actor.profile_description.clone(), 189 - avatar: actor.profile_avatar_cid.as_ref() 190 - .and_then(|cid| parakeet_db::cid_util::digest_to_blob_cid_string(cid) 191 - .map(|cid_str| format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", actor.did, cid_str))), 192 - indexed_at: actor.last_indexed.unwrap_or_else(|| chrono::Utc::now()), 193 - created_at: actor.account_created_at.unwrap_or_else(|| chrono::Utc::now()), 194 - viewer: None, 195 - labels: vec![], 196 - pronouns: actor.profile_pronouns.clone(), 197 - status: None, 198 - verification: None, 199 - associated: Some(ProfileAssociated { 200 - lists: None, 201 - feedgens: None, 202 - starter_packs: None, 203 - labeler: None, 204 - chat: actor.chat_allow_incoming.as_ref().and_then(|chat_type| { 205 - ChatAllowIncoming::from_str(&chat_type.to_string()).ok().map(|allow| { 206 - ProfileAssociatedChat { allow_incoming: allow } 207 - }) 208 - }), 209 - activity_subscription: actor.notif_decl_allow_subscriptions.as_ref().and_then(|sub_type| { 210 - ProfileAllowSubscriptions::from_str(&sub_type.to_string()).ok().map(|allow| { 211 - ProfileAssociatedActivitySubscription { allow_subscriptions: allow } 212 - }) 213 - }), 214 - }), 215 - } 316 + pub fn actor_to_profile_view(&self, actor: &Actor) -> ProfileView<'static> { 317 + // Use the standalone function we defined at the top 318 + actor_to_profile_view(actor) 216 319 } 217 320 218 321 /// Convert an Actor to ProfileViewBasic (minimal variant) 219 - pub async fn actor_to_profile_view_basic(&self, actor: &Actor, viewer_did: Option<&str>) -> ProfileViewBasic { 322 + pub async fn actor_to_profile_view_basic(&self, actor: &Actor, viewer_did: Option<&str>) -> ProfileViewBasic<'static> { 220 323 let viewer = if let Some(did) = viewer_did { 221 324 if did != actor.did.as_str() { 222 325 self.build_viewer_state(actor, did).await ··· 227 330 None 228 331 }; 229 332 230 - ProfileViewBasic { 231 - did: actor.did.clone(), 232 - handle: actor.handle.clone().unwrap_or_else(|| "handle.invalid".to_string()), 233 - display_name: actor.profile_display_name.clone(), 234 - avatar: actor.profile_avatar_cid.as_ref() 235 - .and_then(|cid| parakeet_db::cid_util::digest_to_blob_cid_string(cid) 236 - .map(|cid_str| format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", actor.did, cid_str))), 237 - viewer, 238 - labels: vec![], 239 - created_at: actor.account_created_at.unwrap_or_else(|| chrono::Utc::now()), 240 - pronouns: actor.profile_pronouns.clone(), 241 - status: None, 242 - verification: None, 243 - associated: Some(ProfileAssociated { 244 - lists: None, 333 + let handle_string = actor.handle.clone().unwrap_or_else(|| "handle.invalid".to_string()); 334 + 335 + // Keep avatar_url alive for the entire function scope 336 + let avatar_url = actor.profile_avatar_cid.as_ref() 337 + .and_then(|avatar_cid| parakeet_db::cid_util::digest_to_blob_cid_string(avatar_cid)) 338 + .map(|cid_str| format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", actor.did, cid_str)); 339 + 340 + let mut builder = ProfileViewBasic::new() 341 + .did(Did::new(&actor.did).unwrap()) 342 + .handle(Handle::new(&handle_string).unwrap()); 343 + 344 + if let Some(ref display_name) = actor.profile_display_name { 345 + builder = builder.display_name(Some(CowStr::from(display_name.clone()))); 346 + } 347 + 348 + if let Some(ref url) = avatar_url { 349 + builder = builder.avatar(Some(Uri::new(url).unwrap())); 350 + } 351 + 352 + if let Some(viewer_state) = viewer { 353 + builder = builder.viewer(viewer_state); 354 + } 355 + 356 + if let Some(created_at) = actor.account_created_at { 357 + builder = builder.created_at(Datetime::new(created_at.fixed_offset())); 358 + } 359 + 360 + if let Some(ref pronouns) = actor.profile_pronouns { 361 + builder = builder.pronouns(Some(CowStr::from(pronouns.clone()))); 362 + } 363 + 364 + // Build associated data if any relevant fields are present 365 + if actor.chat_allow_incoming.is_some() || actor.notif_decl_allow_subscriptions.is_some() { 366 + let chat = actor.chat_allow_incoming.as_ref().map(|chat_type| { 367 + ProfileAssociatedChat { 368 + allow_incoming: chat_type.to_string().into(), 369 + ..Default::default() 370 + }.into_static() 371 + }); 372 + 373 + let activity_subscription = actor.notif_decl_allow_subscriptions.as_ref().map(|sub_type| { 374 + ProfileAssociatedActivitySubscription { 375 + allow_subscriptions: sub_type.to_string().into(), 376 + extra_data: None, 377 + }.into_static() 378 + }); 379 + 380 + let associated = ProfileAssociated { 381 + chat, 382 + activity_subscription, 245 383 feedgens: None, 384 + lists: None, 246 385 starter_packs: None, 386 + extra_data: None, 247 387 labeler: None, 248 - chat: actor.chat_allow_incoming.as_ref().and_then(|chat_type| { 249 - ChatAllowIncoming::from_str(&chat_type.to_string()).ok().map(|allow| { 250 - ProfileAssociatedChat { allow_incoming: allow } 251 - }) 252 - }), 253 - activity_subscription: actor.notif_decl_allow_subscriptions.as_ref().and_then(|sub_type| { 254 - ProfileAllowSubscriptions::from_str(&sub_type.to_string()).ok().map(|allow| { 255 - ProfileAssociatedActivitySubscription { allow_subscriptions: allow } 256 - }) 257 - }), 258 - }), 388 + }.into_static(); 389 + 390 + builder = builder.associated(associated); 259 391 } 392 + 393 + builder.build().into_static() 260 394 } 261 395 262 396 /// Get ProfileViewDetailed by actor_id (direct conversion, no hydration) 263 - pub async fn get_profile_view_detailed(&self, actor_id: i32) -> Option<ProfileViewDetailed> { 397 + pub async fn get_profile_view_detailed(&self, actor_id: i32) -> Option<ProfileViewDetailed<'static>> { 264 398 let actor = self.get_profile_by_id(actor_id).await.ok()?; 265 399 Some(self.actor_to_profile_view_detailed(&actor)) 266 400 } 267 401 268 402 /// Get multiple ProfileViewDetailed by actor_ids 269 - pub async fn get_profile_views_detailed(&self, actor_ids: &[i32]) -> Vec<ProfileViewDetailed> { 403 + pub async fn get_profile_views_detailed(&self, actor_ids: &[i32]) -> Vec<ProfileViewDetailed<'static>> { 270 404 let actors = self.get_profiles_by_ids(actor_ids).await.unwrap_or_default(); 271 405 actors.iter().map(|actor| self.actor_to_profile_view_detailed(actor)).collect() 272 406 } ··· 279 413 actor_ids: &[i32], 280 414 post_entity: Option<Arc<PostEntity>>, 281 415 starterpack_entity: Option<Arc<StarterpackEntity>>, 282 - ) -> Vec<ProfileViewDetailed> { 416 + ) -> Vec<ProfileViewDetailed<'static>> { 283 417 let actors = self.get_profiles_by_ids(actor_ids).await.unwrap_or_default(); 284 418 285 419 if actors.is_empty() { ··· 343 477 } 344 478 } 345 479 346 - profiles 480 + profiles.into_iter().map(|p| p.into_static()).collect() 347 481 } 348 482 349 483 /// Resolve identifier and get ProfileViewDetailed 350 - pub async fn resolve_and_get_profile_view_detailed(&self, identifier: &str) -> Option<ProfileViewDetailed> { 484 + pub async fn resolve_and_get_profile_view_detailed(&self, identifier: &str) -> Option<ProfileViewDetailed<'static>> { 351 485 let actor_id = self.resolve_identifier(identifier).await.ok()?; 352 486 self.get_profile_view_detailed(actor_id).await 353 487 } 354 488 355 489 /// Batch resolve identifiers and get ProfileViewDetailed 356 - pub async fn resolve_and_get_profile_views_detailed(&self, identifiers: &[String]) -> Vec<ProfileViewDetailed> { 490 + pub async fn resolve_and_get_profile_views_detailed(&self, identifiers: &[String]) -> Vec<ProfileViewDetailed<'static>> { 357 491 let actor_ids = self.resolve_identifiers(identifiers).await.unwrap_or_default(); 358 492 self.get_profile_views_detailed(&actor_ids).await 359 493 } ··· 364 498 identifiers: &[String], 365 499 post_entity: Option<Arc<PostEntity>>, 366 500 starterpack_entity: Option<Arc<StarterpackEntity>>, 367 - ) -> Vec<ProfileViewDetailed> { 501 + ) -> Vec<ProfileViewDetailed<'static>> { 368 502 let actor_ids = self.resolve_identifiers(identifiers).await.unwrap_or_default(); 369 503 self.get_profile_views_detailed_enriched(&actor_ids, post_entity, starterpack_entity).await 370 504 } 371 505 372 506 /// Get ProfileView by actor_id 373 - pub async fn get_profile_view(&self, actor_id: i32) -> Option<ProfileView> { 507 + pub async fn get_profile_view(&self, actor_id: i32) -> Option<ProfileView<'static>> { 374 508 let actor = self.get_profile_by_id(actor_id).await.ok()?; 375 509 Some(self.actor_to_profile_view(&actor)) 376 510 } 377 511 378 512 /// Get multiple ProfileView by actor_ids 379 - pub async fn get_profile_views(&self, actor_ids: &[i32]) -> Vec<ProfileView> { 513 + pub async fn get_profile_views(&self, actor_ids: &[i32]) -> Vec<ProfileView<'static>> { 380 514 let actors = self.get_profiles_by_ids(actor_ids).await.unwrap_or_default(); 381 515 actors.iter().map(|actor| self.actor_to_profile_view(actor)).collect() 382 516 } 383 517 384 518 /// Batch resolve identifiers and get ProfileView 385 - pub async fn resolve_and_get_profile_views(&self, identifiers: &[String]) -> Vec<ProfileView> { 519 + pub async fn resolve_and_get_profile_views(&self, identifiers: &[String]) -> Vec<ProfileView<'static>> { 386 520 let actor_ids = self.resolve_identifiers(identifiers).await.unwrap_or_default(); 387 521 self.get_profile_views(&actor_ids).await 388 522 } 389 523 390 524 /// Get ProfileViewBasic by actor_id 391 - pub async fn get_profile_view_basic(&self, actor_id: i32, viewer_did: Option<&str>) -> Option<ProfileViewBasic> { 525 + pub async fn get_profile_view_basic(&self, actor_id: i32, viewer_did: Option<&str>) -> Option<ProfileViewBasic<'static>> { 392 526 let actor = self.get_profile_by_id(actor_id).await.ok()?; 393 527 Some(self.actor_to_profile_view_basic(&actor, viewer_did).await) 394 528 } 395 529 396 530 /// Get multiple ProfileViewBasic by actor_ids 397 - pub async fn get_profile_views_basic(&self, actor_ids: &[i32], viewer_did: Option<&str>) -> Vec<ProfileViewBasic> { 531 + pub async fn get_profile_views_basic(&self, actor_ids: &[i32], viewer_did: Option<&str>) -> Vec<ProfileViewBasic<'static>> { 398 532 let actors = self.get_profiles_by_ids(actor_ids).await.unwrap_or_default(); 399 533 let mut views = Vec::with_capacity(actors.len()); 400 534 for actor in actors {
+48 -26
parakeet/src/entities/core/feedgen.rs
··· 2 2 use diesel::prelude::*; 3 3 use diesel_async::pooled_connection::deadpool::Pool; 4 4 use diesel_async::{AsyncPgConnection, RunQueryDsl}; 5 - use lexica::app_bsky::feed::{GeneratorView, GeneratorViewerState}; 5 + use jacquard_api::app_bsky::feed::{GeneratorView, GeneratorViewerState}; 6 6 use moka::future::Cache; 7 7 use std::collections::HashMap; 8 8 use std::sync::Arc; ··· 65 65 pub struct FeedGeneratorEntity { 66 66 db_pool: Arc<Pool<AsyncPgConnection>>, 67 67 profile_entity: Arc<ProfileEntity>, 68 - feedgen_cache: Cache<FeedGenKey, GeneratorView>, 68 + feedgen_cache: Cache<FeedGenKey, GeneratorView<'static>>, 69 69 uri_to_key: Cache<String, FeedGenKey>, 70 70 config: FeedGeneratorConfig, 71 71 cdn_base: String, ··· 103 103 &self, 104 104 uris: Vec<String>, 105 105 viewer_did: Option<&str>, 106 - ) -> Result<HashMap<String, GeneratorView>, diesel::result::Error> { 106 + ) -> Result<HashMap<String, GeneratorView<'static>>, diesel::result::Error> { 107 107 let mut results = HashMap::new(); 108 108 let mut missing_keys = Vec::new(); 109 109 ··· 148 148 &self, 149 149 uri: &str, 150 150 viewer_did: Option<&str>, 151 - ) -> Result<Option<GeneratorView>, diesel::result::Error> { 151 + ) -> Result<Option<GeneratorView<'static>>, diesel::result::Error> { 152 152 let results = self.get_by_uris(vec![uri.to_string()], viewer_did).await?; 153 153 Ok(results.into_iter().next().map(|(_, v)| v)) 154 154 } ··· 176 176 &self, 177 177 keys: &[(String, FeedGenKey)], 178 178 viewer_did: Option<&str>, 179 - ) -> Result<HashMap<String, GeneratorView>, diesel::result::Error> { 179 + ) -> Result<HashMap<String, GeneratorView<'static>>, diesel::result::Error> { 180 180 let mut conn = self.db_pool.get().await 181 181 .map_err(|e| diesel::result::Error::DatabaseError( 182 182 diesel::result::DatabaseErrorKind::UnableToSendCommand, ··· 218 218 219 219 // Reconstruct the URI using the owner DID from the creator view 220 220 let uri = format!("at://{}/app.bsky.feed.generator/{}", 221 - view.creator.did.clone(), 221 + view.creator.did.as_str(), 222 222 rkey 223 223 ); 224 224 ··· 233 233 &self, 234 234 data: FeedGenData, 235 235 viewer_actor_id: Option<i32>, 236 - ) -> Result<GeneratorView, diesel::result::Error> { 236 + ) -> Result<GeneratorView<'static>, diesel::result::Error> { 237 237 // Get creator profile 238 238 let creator = self.profile_entity 239 239 .get_profile_by_id(data.owner_actor_id) ··· 260 260 .await 261 261 .ok(); 262 262 263 - viewer_did.map(|did| GeneratorViewerState { 264 - like: Some(format!("at://{}/app.bsky.feed.like/TODO", did)), 263 + viewer_did.and_then(|did| { 264 + use jacquard_common::types::string::AtUri; 265 + use jacquard_common::IntoStatic; 266 + let like_uri = format!("at://{}/app.bsky.feed.like/TODO", did); 267 + AtUri::new(&like_uri).ok().map(|uri| { 268 + GeneratorViewerState { 269 + like: Some(uri), 270 + ..Default::default() 271 + }.into_static() 272 + }) 265 273 }) 266 274 } else { 267 275 None ··· 283 291 }); 284 292 285 293 // Parse description facets 286 - let description_facets = data.description_facets.and_then(|v| { 294 + let description_facets: Option<Vec<jacquard_api::app_bsky::richtext::facet::Facet>> = data.description_facets.and_then(|v| { 287 295 serde_json::from_value(v).ok() 288 296 }); 289 297 290 298 // Construct the URI 291 299 let uri = format!("at://{}/app.bsky.feed.generator/{}", creator.did, data.rkey); 292 300 293 - Ok(GeneratorView { 294 - uri, 295 - cid: cid.unwrap_or_else(|| "".to_string()), 296 - did: service_did, 297 - creator: creator_view, 298 - display_name: data.name.unwrap_or_else(|| "Untitled Feed".to_string()), 299 - description: data.description, 300 - description_facets, 301 - avatar, 302 - like_count: data.like_count as i64, 303 - accepts_interactions: data.accepts_interactions, 304 - labels: Vec::new(), // TODO: Load labels if needed 305 - viewer, 306 - content_mode: None, // TODO: Parse from database if needed 307 - indexed_at: data.created_at, 308 - }) 301 + use jacquard_common::types::string::{AtUri, Cid, Did, Datetime}; 302 + use jacquard_common::IntoStatic; 303 + 304 + let cid_string = cid.unwrap_or_else(|| "bafyreigenerator".to_string()); 305 + let mut builder = GeneratorView::new() 306 + .uri(AtUri::new(&uri).unwrap()) 307 + .cid(Cid::new(cid_string.as_bytes()).unwrap()) 308 + .did(Did::new(&service_did).unwrap()) 309 + .creator(creator_view) 310 + .display_name(data.name.unwrap_or_else(|| "Untitled Feed".to_string())) 311 + .like_count(Some(data.like_count as i64)) 312 + .indexed_at(Datetime::new(data.created_at.with_timezone(&chrono::FixedOffset::east_opt(0).unwrap()))); 313 + 314 + if let Some(desc) = data.description { 315 + use jacquard_common::types::string::CowStr; 316 + builder = builder.description(CowStr::from(desc)); 317 + } 318 + 319 + if let Some(ref av) = avatar { 320 + use jacquard_common::types::string::Uri; 321 + builder = builder.avatar(Uri::new(av).unwrap()); 322 + } 323 + 324 + builder = builder.accepts_interactions(data.accepts_interactions); 325 + 326 + if let Some(v) = viewer { 327 + builder = builder.viewer(v); 328 + } 329 + 330 + Ok(builder.build().into_static()) 309 331 } 310 332 311 333 /// Invalidate cache entries
+43 -27
parakeet/src/entities/core/list.rs
··· 2 2 use diesel::prelude::*; 3 3 use diesel_async::pooled_connection::deadpool::Pool; 4 4 use diesel_async::{AsyncPgConnection, RunQueryDsl}; 5 - use lexica::app_bsky::graph::{ListView, ListViewerState, ListPurpose}; 6 - use lexica::app_bsky::richtext::FacetMain; 5 + use jacquard_api::app_bsky::graph::{ListView, ListViewerState, ListPurpose}; 6 + use jacquard_api::app_bsky::richtext::facet::Facet; 7 7 use moka::future::Cache; 8 8 use std::collections::HashMap; 9 9 use std::sync::Arc; ··· 59 59 pub struct ListEntity { 60 60 db_pool: Arc<Pool<AsyncPgConnection>>, 61 61 profile_entity: Arc<ProfileEntity>, 62 - list_cache: Cache<ListKey, ListView>, 62 + list_cache: Cache<ListKey, ListView<'static>>, 63 63 uri_to_key: Cache<String, ListKey>, 64 64 config: ListConfig, 65 65 cdn_base: String, ··· 97 97 &self, 98 98 uris: Vec<String>, 99 99 viewer_did: Option<&str>, 100 - ) -> Result<HashMap<String, ListView>, diesel::result::Error> { 100 + ) -> Result<HashMap<String, ListView<'static>>, diesel::result::Error> { 101 101 let mut results = HashMap::new(); 102 102 let mut missing_keys = Vec::new(); 103 103 ··· 142 142 &self, 143 143 uri: &str, 144 144 viewer_did: Option<&str>, 145 - ) -> Result<Option<ListView>, diesel::result::Error> { 145 + ) -> Result<Option<ListView<'static>>, diesel::result::Error> { 146 146 let results = self.get_by_uris(vec![uri.to_string()], viewer_did).await?; 147 147 Ok(results.into_iter().next().map(|(_, v)| v)) 148 148 } ··· 170 170 &self, 171 171 keys: &[(String, ListKey)], 172 172 viewer_did: Option<&str>, 173 - ) -> Result<HashMap<String, ListView>, diesel::result::Error> { 173 + ) -> Result<HashMap<String, ListView<'static>>, diesel::result::Error> { 174 174 let mut conn = self.db_pool.get().await 175 175 .map_err(|e| diesel::result::Error::DatabaseError( 176 176 diesel::result::DatabaseErrorKind::UnableToSendCommand, ··· 268 268 &self, 269 269 data: &T, 270 270 _viewer_actor_id: Option<i32>, 271 - ) -> Result<ListView, diesel::result::Error> 271 + ) -> Result<ListView<'static>, diesel::result::Error> 272 272 where 273 273 T: ListDataTrait, 274 274 { ··· 291 291 }); 292 292 293 293 // Parse description facets 294 - let description_facets: Option<Vec<FacetMain>> = data.description_facets().and_then(|v| { 294 + let description_facets: Option<Vec<jacquard_api::app_bsky::richtext::facet::Facet>> = data.description_facets().and_then(|v| { 295 295 serde_json::from_value(v.clone()).ok() 296 296 }); 297 297 298 298 // Determine list purpose from list_type 299 299 let purpose = match data.list_type().as_deref() { 300 - Some("moderation") | Some("app.bsky.graph.defs#modlist") => ListPurpose::ModList, 301 - Some("curation") | Some("app.bsky.graph.defs#curatelist") => ListPurpose::CurateList, 302 - Some("reference") | Some("app.bsky.graph.defs#referencelist") => ListPurpose::ReferenceList, 303 - _ => ListPurpose::CurateList, // Default 300 + Some("moderation") | Some("app.bsky.graph.defs#modlist") => ListPurpose::AppBskyGraphDefsModlist, 301 + Some("curation") | Some("app.bsky.graph.defs#curatelist") => ListPurpose::AppBskyGraphDefsCuratelist, 302 + Some("reference") | Some("app.bsky.graph.defs#referencelist") => ListPurpose::AppBskyGraphDefsReferencelist, 303 + _ => ListPurpose::AppBskyGraphDefsCuratelist, // Default 304 304 }; 305 305 306 306 // Construct the URI ··· 312 312 313 313 // Build viewer state if applicable 314 314 // TODO: Implement mute/block status checking 315 - let viewer = None; 315 + let viewer: Option<jacquard_api::app_bsky::graph::ListViewerState> = None; 316 316 317 - Ok(ListView { 318 - uri, 319 - cid: cid.unwrap_or_else(|| "".to_string()), 320 - name: data.name().unwrap_or_else(|| "Untitled List".to_string()), 321 - creator: creator_view, 322 - purpose, 323 - description: data.description(), 324 - description_facets, 325 - avatar, 326 - list_item_count: data.item_count() as i64, 327 - viewer, 328 - labels: Vec::new(), // TODO: Load labels if needed 329 - indexed_at, 330 - }) 317 + use jacquard_common::types::string::{AtUri, Cid, Datetime}; 318 + use jacquard_common::IntoStatic; 319 + 320 + let cid_string = cid.unwrap_or_else(|| "bafyreilist".to_string()); 321 + let mut builder = ListView::new() 322 + .uri(AtUri::new(&uri).unwrap()) 323 + .cid(Cid::new(cid_string.as_bytes()).unwrap()) 324 + .name(data.name().unwrap_or_else(|| "Untitled List".to_string())) 325 + .creator(creator_view) 326 + .purpose(purpose) 327 + .list_item_count(Some(data.item_count() as i64)) 328 + .indexed_at(Datetime::new(indexed_at.with_timezone(&chrono::FixedOffset::east_opt(0).unwrap()))); 329 + 330 + if let Some(desc) = data.description() { 331 + use jacquard_common::types::string::CowStr; 332 + builder = builder.description(CowStr::from(desc)); 333 + } 334 + 335 + if let Some(ref av) = avatar { 336 + use jacquard_common::types::string::Uri; 337 + builder = builder.avatar(Uri::new(av).unwrap()); 338 + } 339 + 340 + if let Some(v) = viewer { 341 + builder = builder.viewer(v); 342 + } 343 + 344 + // TODO: Add description_facets and labels when supported 345 + 346 + Ok(builder.build().into_static()) 331 347 } 332 348 333 349 /// Invalidate cache entries
+69 -89
parakeet/src/entities/core/post.rs
··· 5 5 RunQueryDsl, 6 6 }; 7 7 use eyre::Result; 8 - use lexica::app_bsky::feed::{PostView, PostViewerState}; 9 - use lexica::app_bsky::RecordStats; 10 - use lexica::app_bsky::embed::{Embed, ImageView, External}; 11 - use lexica::StrongRef; 8 + use jacquard_api::app_bsky::feed::{PostView, ViewerState as PostViewerState}; 9 + use jacquard_api::com_atproto::repo::strong_ref::StrongRef; 10 + use jacquard_common::types::string::{AtUri, Cid, Datetime}; 11 + use jacquard_common::types::value::Data; 12 + use jacquard_common::IntoStatic; 12 13 use parakeet_db::models::{Post, Actor}; 13 14 use std::sync::Arc; 14 15 use std::collections::HashMap; ··· 66 67 67 68 impl PostData { 68 69 /// Convert this post data to a StrongRef (AT URI + CID) 69 - pub fn to_strong_ref(&self) -> Option<StrongRef> { 70 + pub fn to_strong_ref(&self) -> Option<StrongRef<'static>> { 70 71 // Convert the CID bytes to the proper string format 71 72 let cid_str = parakeet_db::cid_util::digest_to_record_cid_string(&self.post.cid)?; 72 73 ··· 77 78 parakeet_db::tid_util::encode_tid(self.post.rkey) 78 79 ); 79 80 80 - StrongRef::new_from_str(uri, &cid_str).ok() 81 + StrongRef::new_from_str(uri, &cid_str).ok().map(|sr| sr.into_static()) 81 82 } 82 83 } 83 84 ··· 170 171 } 171 172 172 173 /// Get a post as a StrongRef by actor_id and rkey 173 - pub async fn get_post_strong_ref(&self, actor_id: i32, rkey: i64) -> Result<Option<StrongRef>> { 174 + pub async fn get_post_strong_ref(&self, actor_id: i32, rkey: i64) -> Result<Option<StrongRef<'static>>> { 174 175 let post_data = self.get_post_by_key(actor_id, rkey).await?; 175 176 Ok(post_data.to_strong_ref()) 176 177 } ··· 429 430 &self, 430 431 uri: &str, 431 432 viewer_did: Option<&str>, 432 - ) -> Result<Option<(PostView, Option<lexica::app_bsky::feed::ReplyRef>)>, diesel::result::Error> { 433 + ) -> Result<Option<(PostView, Option<jacquard_api::app_bsky::feed::ReplyRef>)>, diesel::result::Error> { 433 434 // Resolve URI to key 434 435 let key = match self.resolve_uri(uri).await { 435 436 Ok(k) => k, ··· 480 481 &self, 481 482 uris: Vec<String>, 482 483 viewer_did: Option<&str>, 483 - ) -> Result<HashMap<String, (PostView, Option<lexica::app_bsky::feed::ReplyRef>)>, diesel::result::Error> { 484 + ) -> Result<HashMap<String, (PostView, Option<jacquard_api::app_bsky::feed::ReplyRef>)>, diesel::result::Error> { 484 485 let mut results = HashMap::new(); 485 486 486 487 for uri in uris { ··· 591 592 }; 592 593 593 594 Some(PostViewerState { 594 - repost: repost_uri, 595 - like: like_uri, 596 - bookmarked, 597 - thread_muted: false, 598 - reply_disabled, 599 - embedding_disabled: false, 600 - }) 595 + repost: repost_uri.as_ref().and_then(|u| AtUri::new(u).ok()), 596 + like: like_uri.as_ref().and_then(|u| AtUri::new(u).ok()), 597 + bookmarked: Some(bookmarked), 598 + pinned: None, 599 + reply_disabled: Some(reply_disabled), 600 + thread_muted: Some(false), 601 + embedding_disabled: Some(false), 602 + ..Default::default() 603 + }.into_static()) 601 604 } else { 602 605 None 603 606 }; 604 607 605 - PostView { 606 - uri, 607 - cid, 608 - author: self.profile_entity.actor_to_profile_view_basic(&data.author, viewer_did).await, 609 - record: serde_json::json!({ 610 - "$type": "app.bsky.feed.post", 611 - "text": text, 612 - "createdAt": created_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true), 613 - }), 614 - embed: self.build_embed(&data), 615 - stats: RecordStats { 616 - reply_count: data.post.reply_count as i64, 617 - repost_count: data.post.repost_count as i64, 618 - like_count: data.post.like_count as i64, 619 - quote_count: data.post.quote_count as i64, 620 - bookmark_count: 0, // Bookmarks are tracked separately 621 - }, 622 - indexed_at: created_at, // Use created_at as indexed_at 623 - viewer, 624 - labels: Vec::new(), 625 - threadgate: self.build_threadgate(&data) 626 - } 608 + let author = self.profile_entity.actor_to_profile_view_basic(&data.author, viewer_did).await; 609 + 610 + PostView::new() 611 + .uri(AtUri::new(&uri).unwrap()) 612 + .cid(Cid::new(cid.as_bytes()).unwrap()) 613 + .author(author) 614 + .record({ 615 + // TODO: Properly convert to Data type 616 + // For now, serialize to string then parse back 617 + let record_json = serde_json::json!({ 618 + "$type": "app.bsky.feed.post", 619 + "text": text, 620 + "createdAt": created_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true), 621 + }); 622 + // Parse as Data directly from Value 623 + serde_json::from_value::<Data>(record_json).unwrap() 624 + }) 625 + .embed(self.build_embed(&data)) 626 + .reply_count(Some(data.post.reply_count as i64)) 627 + .repost_count(Some(data.post.repost_count as i64)) 628 + .like_count(Some(data.post.like_count as i64)) 629 + .quote_count(Some(data.post.quote_count as i64)) 630 + .bookmark_count(Some(0)) 631 + .indexed_at(Datetime::new(created_at.with_timezone(&chrono::FixedOffset::east_opt(0).unwrap()))) 632 + .viewer(viewer) 633 + .labels(Vec::new()) 634 + .threadgate(self.build_threadgate(&data)) 635 + .build() 636 + .into_static() 627 637 } 628 638 629 639 /// Build threadgate from post data 630 - fn build_threadgate(&self, data: &PostData) -> Option<lexica::app_bsky::feed::ThreadgateView> { 631 - use lexica::app_bsky::feed::ThreadgateView; 632 - 633 - // Check if threadgate exists 634 - if data.post.threadgate_allow.is_none() { 635 - return None; 636 - } 637 - 638 - let uri = format!( 639 - "at://{}/app.bsky.feed.threadgate/{}", 640 - data.author.did, 641 - parakeet_db::tid_util::encode_tid(data.post.rkey) 642 - ); 643 - 644 - // Generate CID for threadgate 645 - let cid = "bafyreigrey4aogz7sq5bxfaiwlcieaivxscvgs5ivgqczecmvz6jhmnxq".to_string(); 646 - 647 - // Build allow rules from threadgate_allow array 648 - let mut allow = Vec::new(); 649 - if let Some(ref rules) = data.post.threadgate_allow { 650 - // Parse rules from the array 651 - // This would need proper implementation based on the actual rule format 652 - allow.push(serde_json::json!({ 653 - "$type": "app.bsky.feed.threadgate#mentionRule" 654 - })); 655 - } 640 + fn build_threadgate(&self, data: &PostData) -> Option<jacquard_api::app_bsky::feed::ThreadgateView<'static>> { 656 641 657 - // Create the record JSON 658 - let record = serde_json::json!({ 659 - "$type": "app.bsky.feed.threadgate", 660 - "post": format!("at://{}/app.bsky.feed.post/{}", 661 - data.author.did, 662 - parakeet_db::tid_util::encode_tid(data.post.rkey) 663 - ), 664 - "allow": allow, 665 - "createdAt": chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Millis, true) 666 - }); 667 - 668 - Some(ThreadgateView { 669 - uri, 670 - cid, 671 - record, 672 - lists: vec![], 673 - }) 642 + // TODO: Implement threadgate support with jacquard types 643 + None 674 644 } 675 645 676 646 /// Build embed from post data 677 - fn build_embed(&self, data: &PostData) -> Option<Embed> { 647 + fn build_embed(&self, data: &PostData) -> Option<jacquard_api::app_bsky::feed::PostViewEmbed<'static>> { 648 + // TODO: Implement embed support with jacquard types 649 + None 650 + 651 + /* Original embed logic to be migrated: 678 652 use parakeet_db::types::EmbedType; 679 653 680 654 match data.post.embed_type { ··· 693 667 data.author.did, cid_str), 694 668 alt: img.alt.clone().unwrap_or_default(), 695 669 aspect_ratio: if let (Some(w), Some(h)) = (img.width, img.height) { 696 - Some(lexica::app_bsky::embed::AspectRatio { 670 + Some(jacquard_api::app_bsky::embed::AspectRatio { 697 671 width: w, 698 672 height: h, 699 673 }) ··· 737 711 }, 738 712 None => None, 739 713 } 714 + */ 740 715 } 741 716 742 717 /// Build reply context for a post ··· 744 719 &self, 745 720 post_data: &PostData, 746 721 viewer_did: Option<&str>, 747 - ) -> Option<lexica::app_bsky::feed::ReplyRef> { 748 - use lexica::app_bsky::feed::{ReplyRef, ReplyRefPost}; 722 + ) -> Option<jacquard_api::app_bsky::feed::ReplyRef<'static>> { 723 + // TODO: Implement reply context with jacquard types 724 + return None; 725 + 726 + /* Original logic to be migrated: 727 + use jacquard_api::app_bsky::feed::{ReplyRef, ReplyRefPost}; 749 728 750 729 // Check if this post has parent/root references 751 730 if post_data.post.parent_post_actor_id.is_none() || post_data.post.parent_post_rkey.is_none() { ··· 791 770 if root_actor_id == parent_actor_id && root_rkey == parent_rkey { 792 771 // Root is same as parent, create a new ReplyRefPost with same data 793 772 match &parent_post { 794 - lexica::app_bsky::feed::ReplyRefPost::Post(view) => { 795 - lexica::app_bsky::feed::ReplyRefPost::Post(view.clone()) 773 + jacquard_api::app_bsky::feed::ReplyRefPost::Post(view) => { 774 + jacquard_api::app_bsky::feed::ReplyRefPost::Post(view.clone()) 796 775 }, 797 776 _ => return None, // Parent should be a Post 798 777 } ··· 834 813 } else { 835 814 // No root specified, parent is the root 836 815 match &parent_post { 837 - lexica::app_bsky::feed::ReplyRefPost::Post(view) => { 838 - lexica::app_bsky::feed::ReplyRefPost::Post(view.clone()) 816 + jacquard_api::app_bsky::feed::ReplyRefPost::Post(view) => { 817 + jacquard_api::app_bsky::feed::ReplyRefPost::Post(view.clone()) 839 818 }, 840 819 _ => return None, // Parent should be a Post 841 820 } ··· 849 828 parent: parent_post, 850 829 grandparent_author, 851 830 }) 831 + */ 852 832 } 853 833 854 834 /// Get likes for a post
+66 -47
parakeet/src/entities/core/starterpack.rs
··· 3 3 use diesel::prelude::*; 4 4 use diesel_async::pooled_connection::deadpool::Pool; 5 5 use diesel_async::{AsyncPgConnection, RunQueryDsl}; 6 - use lexica::app_bsky::graph::{StarterPackView, StarterPackViewBasic, ListViewBasic, ListItemView}; 7 - use lexica::app_bsky::richtext::FacetMain; 6 + use jacquard_api::app_bsky::graph::{StarterPackView, StarterPackViewBasic, ListViewBasic, ListItemView}; 7 + use jacquard_api::app_bsky::richtext::facet::Facet; 8 + use jacquard_common::types::aturi::AtUri; 9 + use jacquard_common::types::string::Cid; 8 10 use moka::future::Cache; 9 11 use std::collections::HashMap; 10 12 use std::sync::Arc; ··· 63 65 profile_entity: Arc<ProfileEntity>, 64 66 list_entity: Arc<ListEntity>, 65 67 feedgen_entity: Arc<crate::entities::core::feedgen::FeedGeneratorEntity>, 66 - starterpack_cache: Cache<StarterpackKey, StarterPackViewBasic>, 68 + starterpack_cache: Cache<StarterpackKey, StarterPackViewBasic<'static>>, 67 69 uri_to_key: Cache<String, StarterpackKey>, 68 70 config: StarterpackConfig, 69 71 } ··· 249 251 let owner_did = self.profile_entity 250 252 .get_did_by_id(view.creator.did.parse::<i32>().unwrap_or(0)) 251 253 .await 252 - .unwrap_or_else(|_| view.creator.did.clone()); 254 + .unwrap_or_else(|_| view.creator.did.to_string()); 253 255 254 256 let rkey_str = parakeet_db::tid_util::encode_tid(keys.iter() 255 257 .find(|(_, k)| k.actor_id == actor_id) ··· 274 276 .await 275 277 .map_err(|_| diesel::result::Error::NotFound)?; 276 278 277 - // Convert to ProfileViewBasic using profile_converter 278 - let creator_view = lexica::app_bsky::actor::ProfileViewBasic { 279 - did: creator.did.clone(), 280 - handle: creator.handle.clone().unwrap_or_else(|| "handle.invalid".to_string()), 281 - display_name: creator.profile_display_name.clone(), 282 - avatar: creator.profile_avatar_cid.as_ref() 283 - .and_then(|cid| parakeet_db::cid_util::digest_to_blob_cid_string(cid) 284 - .map(|cid_str| format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", creator.did, cid_str))), 285 - associated: None, 286 - viewer: None, 287 - labels: Vec::new(), 288 - created_at: creator.account_created_at.unwrap_or_else(|| chrono::Utc::now()), 289 - pronouns: creator.profile_pronouns.clone(), 290 - status: None, 291 - verification: None, 292 - }; 279 + // Convert to ProfileViewBasic using ProfileEntity 280 + let creator_view = self.profile_entity.actor_to_profile_view_basic(&creator, None).await; 293 281 294 282 // Convert CID 295 283 let cid = parakeet_db::cid_util::digest_to_blob_cid_string(&data.cid); 296 284 297 285 // Parse description facets 298 - let description_facets: Option<Vec<FacetMain>> = data.description_facets.and_then(|v| { 286 + let description_facets: Option<Vec<jacquard_api::app_bsky::richtext::facet::Facet>> = data.description_facets.and_then(|v| { 299 287 serde_json::from_value(v).ok() 300 288 }); 301 289 ··· 332 320 (0, 0, 0) 333 321 }; 334 322 323 + use jacquard_common::IntoStatic; 324 + 335 325 Ok(StarterPackViewBasic { 336 - uri, 337 - cid: cid.unwrap_or_else(|| "".to_string()), 338 - record, 326 + uri: AtUri::new(&uri).unwrap(), 327 + cid: Cid::new(cid.unwrap_or_else(|| "".to_string()).as_bytes()).unwrap(), 328 + record: serde_json::from_value(record).unwrap(), 339 329 creator: creator_view, 340 - list_item_count, 341 - joined_week_count, 342 - joined_all_time_count, 343 - labels: Vec::new(), 344 - indexed_at, 345 - }) 330 + list_item_count: Some(list_item_count), 331 + joined_week_count: Some(joined_week_count), 332 + joined_all_time_count: Some(joined_all_time_count), 333 + labels: Some(Vec::new()), 334 + indexed_at: jacquard_common::types::datetime::Datetime::from(indexed_at.fixed_offset()), 335 + extra_data: None, 336 + }.into_static()) 346 337 } 347 338 348 339 /// Build a full StarterPackView from raw data ··· 376 367 list_item_count: full_list.list_item_count, 377 368 viewer: full_list.viewer, 378 369 labels: full_list.labels, 379 - indexed_at: full_list.indexed_at, 370 + indexed_at: Some(full_list.indexed_at), 371 + extra_data: None, 380 372 }) 381 373 } else { 382 374 None ··· 424 416 Vec::new() 425 417 }; 426 418 427 - Ok(StarterPackView { 428 - uri: basic.uri, 429 - cid: basic.cid, 430 - record: basic.record, 431 - creator: basic.creator, 432 - list, 433 - list_items_sample, 434 - feeds, 435 - list_item_count: basic.list_item_count, 436 - joined_week_count: basic.joined_week_count, 437 - joined_all_time_count: basic.joined_all_time_count, 438 - labels: basic.labels, 439 - indexed_at: basic.indexed_at, 440 - }) 419 + use jacquard_common::IntoStatic; 420 + 421 + let mut builder = StarterPackView::new() 422 + .uri(basic.uri) 423 + .cid(basic.cid) 424 + .record(basic.record) 425 + .creator(basic.creator) 426 + .indexed_at(basic.indexed_at); 427 + 428 + if let Some(l) = list { 429 + builder = builder.list(l); 430 + } 431 + 432 + if !list_items_sample.is_empty() { 433 + builder = builder.list_items_sample(list_items_sample); 434 + } 435 + 436 + if !feeds.is_empty() { 437 + builder = builder.feeds(feeds); 438 + } 439 + 440 + // list_item_count is not a builder method in jacquard 441 + // This may need to be handled differently 442 + 443 + if let Some(count) = basic.joined_week_count { 444 + builder = builder.joined_week_count(count); 445 + } 446 + 447 + if let Some(count) = basic.joined_all_time_count { 448 + builder = builder.joined_all_time_count(count); 449 + } 450 + 451 + if let Some(ref labels) = basic.labels { 452 + if !labels.is_empty() { 453 + builder = builder.labels(labels.clone()); 454 + } 455 + } 456 + 457 + Ok(builder.build().into_static()) 441 458 } 442 459 443 460 /// Invalidate cache entries ··· 718 735 parakeet_db::tid_util::encode_tid(item_rkey)); 719 736 720 737 let subject = self.profile_entity.actor_to_profile_view(subject_actor); 738 + use jacquard_common::IntoStatic; 721 739 list_items.push(ListItemView { 722 - uri: item_uri, 740 + uri: jacquard_common::types::aturi::AtUri::new(&item_uri).unwrap(), 723 741 subject, 724 - }); 742 + extra_data: None, 743 + }.into_static()); 725 744 } 726 745 } 727 746 }
+1
parakeet/src/lib.rs
··· 26 26 27 27 // Inline module declarations for entities (replacing entities/mod.rs) 28 28 pub mod entities; 29 + pub mod views; 29 30 30 31 pub mod xrpc; 31 32
+80 -103
parakeet/src/xrpc/app_bsky/actor.rs
··· 1 1 use crate::common::errors::{Error, XrpcResult}; 2 2 use crate::common::auth::{AtpAcceptLabelers, AtpAuth}; 3 3 use crate::GlobalState; 4 - use axum::extract::{Query, State}; 5 - use axum::response::{IntoResponse as _, Response}; 4 + use axum::extract::State; 5 + use axum::http::StatusCode; 6 + use axum::response::IntoResponse; 6 7 use axum::Json; 7 - use axum_extra::extract::Query as ExtraQuery; 8 - use lexica::app_bsky::actor::{ 8 + use jacquard_axum::ExtractXrpc; 9 + use jacquard_api::app_bsky::actor::{ 9 10 ProfileView, ProfileViewBasic, ProfileViewDetailed, 10 - GetProfilesResponse, GetProfilesParams, 11 - SearchActorsResponse, SearchActorsParams, 12 - SearchActorsTypeaheadResponse, SearchActorsTypeaheadParams, 13 - GetSuggestionsResponse, GetSuggestionsParams, 14 11 }; 15 - use serde::{Deserialize, Serialize}; 16 - use std::collections::HashMap; 17 - 18 - #[derive(Debug, Deserialize)] 19 - pub struct ActorQuery { 20 - pub actor: String, 21 - } 12 + use jacquard_api::app_bsky::actor::get_profile::{GetProfileRequest, GetProfileOutput}; 13 + use jacquard_api::app_bsky::actor::get_profiles::{GetProfilesRequest, GetProfilesOutput}; 14 + use jacquard_api::app_bsky::actor::search_actors::{SearchActorsRequest, SearchActorsOutput}; 15 + use jacquard_api::app_bsky::actor::search_actors_typeahead::{SearchActorsTypeaheadRequest, SearchActorsTypeaheadOutput}; 16 + use jacquard_api::app_bsky::actor::get_suggestions::{GetSuggestionsRequest, GetSuggestionsOutput}; 17 + use jacquard_common::IntoStatic; 22 18 23 19 /// Handles the app.bsky.actor.getProfile endpoint 24 20 /// 25 - /// Fetches a user's profile from our database using ProfileEntity with enriched fields. 21 + /// Uses ExtractXrpc for proper XRPC request handling 26 22 pub async fn get_profile( 27 23 State(state): State<GlobalState>, 28 - AtpAcceptLabelers(_labelers): AtpAcceptLabelers, 24 + ExtractXrpc(req): ExtractXrpc<GetProfileRequest>, 25 + _labelers: AtpAcceptLabelers, 29 26 _maybe_auth: Option<AtpAuth>, 30 - Query(query): Query<ActorQuery>, 31 - ) -> XrpcResult<Response> { 27 + ) -> Result<Json<GetProfileOutput<'static>>, StatusCode> { 32 28 // First resolve the actor ID 33 29 let actor_id = state.profile_entity 34 - .resolve_identifier(&query.actor) 30 + .resolve_identifier(req.actor.as_ref()) 35 31 .await 36 - .map_err(|_| Error::not_found())?; 32 + .map_err(|_| StatusCode::NOT_FOUND)?; 37 33 38 34 // Get the actor data 39 35 let actor = state.profile_entity 40 36 .get_profile_by_id(actor_id) 41 37 .await 42 - .map_err(|_| Error::not_found())?; 38 + .map_err(|_| StatusCode::NOT_FOUND)?; 43 39 44 40 // Convert with enriched fields (pinned post, starter pack) 45 41 let profile = state.profile_entity ··· 50 46 ) 51 47 .await; 52 48 53 - Ok(Json(profile).into_response()) 49 + Ok(Json(GetProfileOutput { 50 + value: profile, 51 + extra_data: None, 52 + })) 54 53 } 55 - 56 54 57 55 /// Handles the app.bsky.actor.getProfiles endpoint 58 56 /// 59 - /// Returns detailed profile information for multiple actors using ProfileEntity with direct conversion. 57 + /// Returns detailed profile information for multiple actors 60 58 pub async fn get_profiles( 61 59 State(state): State<GlobalState>, 62 - AtpAcceptLabelers(_labelers): AtpAcceptLabelers, 60 + ExtractXrpc(req): ExtractXrpc<GetProfilesRequest>, 61 + _labelers: AtpAcceptLabelers, 63 62 _maybe_auth: Option<AtpAuth>, 64 - ExtraQuery(query): ExtraQuery<GetProfilesParams>, 65 - ) -> XrpcResult<Response> { 63 + ) -> Result<Json<GetProfilesOutput<'static>>, StatusCode> { 66 64 // Use ProfileEntity with enriched conversion to include pinned posts 65 + let identifiers: Vec<String> = req.actors.iter().map(|id| id.as_str().to_string()).collect(); 67 66 let profiles = state.profile_entity 68 67 .resolve_and_get_profile_views_detailed_enriched( 69 - &query.actors, 68 + &identifiers, 70 69 Some(state.post_entity.clone()), 71 70 Some(state.starterpack_entity.clone()), 72 71 ) 73 72 .await; 74 73 75 - Ok(Json(GetProfilesResponse { profiles }).into_response()) 74 + Ok(Json(GetProfilesOutput { 75 + profiles, 76 + extra_data: None, 77 + })) 76 78 } 77 79 78 80 // ============================================================================ ··· 81 83 82 84 /// Handles the app.bsky.actor.searchActors endpoint 83 85 /// 84 - /// Full-text search across handles, display names, and descriptions. 85 - /// Uses trigram similarity + full-text search with ranking. 86 + /// Full-text search across handles, display names, and descriptions 86 87 pub async fn search_actors( 87 88 State(state): State<GlobalState>, 88 - AtpAcceptLabelers(_labelers): AtpAcceptLabelers, 89 + ExtractXrpc(req): ExtractXrpc<SearchActorsRequest>, 90 + _labelers: AtpAcceptLabelers, 89 91 _maybe_auth: Option<AtpAuth>, 90 - Query(query): Query<SearchActorsParams>, 91 - ) -> XrpcResult<Response> { 92 + ) -> Result<Json<SearchActorsOutput<'static>>, StatusCode> { 92 93 // Get search term from either q or term parameter 93 - let search_term = query.q.or(query.term).ok_or_else(|| { 94 - Error::new( 95 - axum::http::StatusCode::BAD_REQUEST, 96 - "InvalidRequest", 97 - Some("Either 'q' or 'term' parameter is required".to_owned()), 98 - ) 99 - })?; 94 + let search_term = req.q.or(req.term).ok_or(StatusCode::BAD_REQUEST)?; 100 95 101 96 // Validate query is not empty/whitespace 102 97 let trimmed = search_term.trim(); 103 98 if trimmed.is_empty() { 104 - return Err(Error::new( 105 - axum::http::StatusCode::BAD_REQUEST, 106 - "InvalidRequest", 107 - Some("Query string cannot be empty".to_owned()), 108 - )); 99 + return Err(StatusCode::BAD_REQUEST); 109 100 } 110 101 111 - let limit = query.limit.unwrap_or(25).clamp(1, 100); 102 + let limit = req.limit.unwrap_or(25).clamp(1, 100); 112 103 113 104 // Parse cursor (rank value as float) 114 - let cursor_rank = query 105 + let cursor_rank = req 115 106 .cursor 116 107 .as_ref() 117 108 .and_then(|c| c.parse::<f64>().ok()); ··· 119 110 // Execute search query using ProfileEntity 120 111 let results = state.profile_entity.search_actors(trimmed, (limit + 1) as i64, cursor_rank) 121 112 .await 122 - .map_err(|e| { 123 - Error::new( 124 - axum::http::StatusCode::INTERNAL_SERVER_ERROR, 125 - "DatabaseError", 126 - Some(format!("Search query failed: {}", e)), 127 - ) 128 - })?; 113 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 129 114 130 115 // Check for pagination 131 116 let has_more = results.len() > limit as usize; ··· 148 133 None 149 134 }; 150 135 151 - Ok(Json(SearchActorsResponse { actors, cursor }).into_response()) 136 + Ok(Json(SearchActorsOutput { 137 + actors, 138 + cursor: cursor.map(|c| jacquard_common::types::string::CowStr::from(c)), 139 + extra_data: None, 140 + })) 152 141 } 153 142 154 143 /// Handles the app.bsky.actor.searchActorsTypeahead endpoint 155 144 /// 156 - /// Fast prefix-based search for autocomplete. 157 - /// Searches handle and displayName only, no pagination. 145 + /// Fast prefix-based search for autocomplete 158 146 pub async fn search_actors_typeahead( 159 147 State(state): State<GlobalState>, 160 - AtpAcceptLabelers(_labelers): AtpAcceptLabelers, 148 + ExtractXrpc(req): ExtractXrpc<SearchActorsTypeaheadRequest>, 149 + _labelers: AtpAcceptLabelers, 161 150 maybe_auth: Option<AtpAuth>, 162 - Query(query): Query<SearchActorsTypeaheadParams>, 163 - ) -> XrpcResult<Response> { 151 + ) -> Result<Json<SearchActorsTypeaheadOutput<'static>>, StatusCode> { 164 152 // Get search term 165 - let search_term = query.q.or(query.term).ok_or_else(|| { 166 - Error::new( 167 - axum::http::StatusCode::BAD_REQUEST, 168 - "InvalidRequest", 169 - Some("Either 'q' or 'term' parameter is required".to_owned()), 170 - ) 171 - })?; 153 + let search_term = req.q.or(req.term).ok_or(StatusCode::BAD_REQUEST)?; 172 154 173 155 // Validate and lowercase for case-insensitive search 174 156 let trimmed = search_term.trim().to_lowercase(); 175 157 if trimmed.is_empty() { 176 - return Err(Error::new( 177 - axum::http::StatusCode::BAD_REQUEST, 178 - "InvalidRequest", 179 - Some("Query string cannot be empty".to_owned()), 180 - )); 158 + return Err(StatusCode::BAD_REQUEST); 181 159 } 182 160 183 161 // Typeahead typically uses smaller limit 184 - let limit = query.limit.unwrap_or(10).clamp(1, 25); 162 + let limit = req.limit.unwrap_or(10).clamp(1, 25); 185 163 186 164 // Execute typeahead query (no cursor - fixed limit) using ProfileEntity 187 165 let results = state.profile_entity.search_actors_typeahead(&trimmed, limit as i64) 188 166 .await 189 - .map_err(|e| { 190 - Error::new( 191 - axum::http::StatusCode::INTERNAL_SERVER_ERROR, 192 - "DatabaseError", 193 - Some(format!("Typeahead query failed: {}", e)), 194 - ) 195 - })?; 167 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 196 168 197 169 // Get ProfileViewBasic for each actor_id (typeahead returns basic views) 198 170 let viewer_did = maybe_auth.as_ref().map(|auth| auth.0.as_str()); ··· 200 172 .get_profile_views_basic(&results, viewer_did) 201 173 .await; 202 174 203 - Ok(Json(SearchActorsTypeaheadResponse { actors }).into_response()) 175 + Ok(Json(SearchActorsTypeaheadOutput { 176 + actors, 177 + extra_data: None, 178 + })) 204 179 } 205 180 206 181 // ============================================================================ ··· 209 184 210 185 /// Handles the app.bsky.actor.getSuggestions endpoint 211 186 /// 212 - /// Returns popular, high-quality accounts for discovery and onboarding. 213 - /// Ranks by follower count with quality filters (has profile, active status). 187 + /// Returns popular, high-quality accounts for discovery and onboarding 214 188 pub async fn get_suggestions( 215 189 State(state): State<GlobalState>, 216 - AtpAcceptLabelers(labelers): AtpAcceptLabelers, 190 + ExtractXrpc(req): ExtractXrpc<GetSuggestionsRequest>, 191 + _labelers: AtpAcceptLabelers, 217 192 maybe_auth: Option<AtpAuth>, 218 - Query(query): Query<GetSuggestionsParams>, 219 - ) -> XrpcResult<Response> { 220 - let limit = query.limit.unwrap_or(25).clamp(1, 100) as usize; 221 - let offset = query 193 + ) -> Result<Json<GetSuggestionsOutput<'static>>, StatusCode> { 194 + let limit = req.limit.unwrap_or(25).clamp(1, 100) as usize; 195 + let offset = req 222 196 .cursor 223 197 .as_ref() 224 198 .and_then(|c| c.parse::<usize>().ok()) 225 199 .unwrap_or(0); 226 200 227 - // Compute global suggestions 228 - // TODO: Consider adding moka cache if this becomes a bottleneck 229 - 230 201 // Get top 1000 most-followed DIDs using ProfileEntity 231 202 let all_dids = state.profile_entity.get_top_followed_actors(1000) 232 203 .await 233 - .map_err(|e| Error::server_error(Some(&e.to_string())))?; 204 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 234 205 235 206 if all_dids.is_empty() { 236 - return Ok(Json(GetSuggestionsResponse { 207 + return Ok(Json(GetSuggestionsOutput { 237 208 actors: Vec::new(), 238 209 cursor: None, 239 - }) 240 - .into_response()); 210 + rec_id: None, 211 + extra_data: None, 212 + })); 241 213 } 242 214 243 215 // Convert DIDs to actor_ids for efficient profile loading using ProfileEntity ··· 259 231 .unwrap_or_default(); 260 232 261 233 // Create actor_id to Actor map 262 - let profiles_by_id: HashMap<i32, _> = profiles 234 + let profiles_by_id: std::collections::HashMap<i32, _> = profiles 263 235 .into_iter() 264 236 .map(|actor| (actor.id, actor)) 265 237 .collect(); ··· 322 294 .await; 323 295 324 296 // Create map for order preservation 325 - let mut profiles_map: HashMap<String, ProfileView> = profiles_vec 297 + let mut profiles_map: std::collections::HashMap<String, ProfileView<'static>> = profiles_vec 326 298 .into_iter() 327 - .map(|profile| (profile.did.clone(), profile)) 299 + .map(|profile| (profile.did.to_string(), profile)) 328 300 .collect(); 329 301 330 302 // Maintain pagination order 331 - let actors: Vec<ProfileView> = page_dids 303 + let actors: Vec<ProfileView<'static>> = page_dids 332 304 .into_iter() 333 305 .filter_map(|did| profiles_map.remove(&did)) 334 306 .collect(); 335 307 336 - Ok(Json(GetSuggestionsResponse { actors, cursor }).into_response()) 337 - } 308 + Ok(Json(GetSuggestionsOutput { 309 + actors, 310 + cursor: cursor.map(|c| jacquard_common::types::string::CowStr::from(c)), 311 + rec_id: None, 312 + extra_data: None, 313 + })) 314 + }
+68 -97
parakeet/src/xrpc/app_bsky/bookmark.rs
··· 1 - use crate::common::errors::XrpcResult; 2 1 use crate::common::auth::{AtpAcceptLabelers, AtpAuth}; 3 - use crate::xrpc::{datetime_cursor, CursorQuery}; 2 + use crate::xrpc::datetime_cursor; 4 3 use crate::GlobalState; 5 - use axum::extract::{Query, State}; 4 + use axum::extract::State; 5 + use axum::http::StatusCode; 6 6 use axum::Json; 7 - use lexica::app_bsky::bookmark::{BookmarkView, BookmarkViewItem}; 8 - use lexica::app_bsky::feed::{BlockedAuthor, PostView}; 9 - use lexica::StrongRef; 10 - use serde::{Deserialize, Serialize}; 11 - 12 - #[derive(Debug, Deserialize)] 13 - pub struct CreateBookmarkReq { 14 - pub uri: String, 15 - #[expect(dead_code, reason = "CID required for XRPC API spec but not used in server-side bookmark creation")] 16 - pub cid: String, 17 - } 7 + use jacquard_axum::ExtractXrpc; 8 + use jacquard_api::app_bsky::bookmark::{ 9 + BookmarkView, BookmarkViewItem, 10 + create_bookmark::{CreateBookmarkRequest, CreateBookmarkResponse}, 11 + delete_bookmark::{DeleteBookmarkRequest, DeleteBookmarkResponse}, 12 + get_bookmarks::{GetBookmarksRequest, GetBookmarksOutput}, 13 + }; 14 + use jacquard_api::com_atproto::repo::strong_ref::StrongRef; 15 + use jacquard_common::IntoStatic; 18 16 19 17 pub async fn create_bookmark( 20 18 State(state): State<GlobalState>, 21 19 auth: AtpAuth, 22 - Json(form): Json<CreateBookmarkReq>, 23 - ) -> XrpcResult<()> { 24 - use crate::common::errors::Error; 25 - let mut conn = state.pool.get().await?; 20 + ExtractXrpc(req): ExtractXrpc<CreateBookmarkRequest>, 21 + ) -> Result<Json<CreateBookmarkResponse>, StatusCode> { 22 + let mut conn = state.pool.get().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 26 23 27 24 // Resolve auth DID to actor_id using ProfileEntity 28 25 let actor_id = state.profile_entity.resolve_identifier(&auth.0).await 29 - .map_err(|_| Error::actor_not_found(&auth.0))?; 26 + .map_err(|_| StatusCode::NOT_FOUND)?; 30 27 31 28 // Parse AT URI - bookmarks can only be for posts 32 29 // URI format: at://did/app.bsky.feed.post/rkey 33 - let parts = form.uri.strip_prefix("at://") 34 - .ok_or_else(|| Error::invalid_request(Some("Invalid AT URI".to_string())))? 30 + let parts = req.uri.strip_prefix("at://") 31 + .ok_or(StatusCode::BAD_REQUEST)? 35 32 .split('/').collect::<Vec<_>>(); 36 33 37 34 if parts.len() != 3 { 38 - return Err(Error::invalid_request(Some("Invalid AT URI format".to_string()))); 35 + return Err(StatusCode::BAD_REQUEST); 39 36 } 40 37 41 38 let (post_did, collection, rkey_str) = (parts[0], parts[1], parts[2]); 42 39 43 40 // Validate that this is a post URI 44 41 if collection != "app.bsky.feed.post" { 45 - return Err(Error::invalid_request(Some("Bookmarks can only be created for posts".to_string()))); 42 + return Err(StatusCode::BAD_REQUEST); 46 43 } 47 44 48 45 // Resolve post DID to actor_id using ProfileEntity 49 46 let post_actor_id = state.profile_entity.resolve_identifier(post_did).await 50 - .map_err(|_| Error::not_found())?; 47 + .map_err(|_| StatusCode::NOT_FOUND)?; 51 48 52 49 // Decode TID 53 50 let rkey = parakeet_db::tid_util::decode_tid(rkey_str) 54 - .map_err(|_| Error::invalid_request(Some("Invalid TID".to_string())))?; 51 + .map_err(|_| StatusCode::BAD_REQUEST)?; 55 52 56 53 // Update bookmarks array on actor 57 54 use diesel::prelude::*; ··· 84 81 .bind::<diesel::sql_types::Integer, _>(post_actor_id) 85 82 .bind::<diesel::sql_types::BigInt, _>(rkey) 86 83 .execute(&mut conn) 87 - .await?; 88 - 89 - Ok(()) 90 - } 84 + .await 85 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 91 86 92 - #[derive(Debug, Deserialize)] 93 - pub struct DeleteBookmarkReq { 94 - pub uri: String, 87 + Ok(Json(CreateBookmarkResponse)) 95 88 } 96 89 97 90 pub async fn delete_bookmark( 98 91 State(state): State<GlobalState>, 99 92 auth: AtpAuth, 100 - Json(form): Json<DeleteBookmarkReq>, 101 - ) -> XrpcResult<()> { 102 - use crate::common::errors::Error; 103 - let mut conn = state.pool.get().await?; 93 + ExtractXrpc(req): ExtractXrpc<DeleteBookmarkRequest>, 94 + ) -> Result<Json<DeleteBookmarkResponse>, StatusCode> { 95 + let mut conn = state.pool.get().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 104 96 105 97 // Resolve auth DID to actor_id using ProfileEntity 106 98 let actor_id = state.profile_entity.resolve_identifier(&auth.0).await 107 - .map_err(|_| Error::actor_not_found(&auth.0))?; 99 + .map_err(|_| StatusCode::NOT_FOUND)?; 108 100 109 101 // Parse AT URI 110 - let parts = form.uri.strip_prefix("at://") 111 - .ok_or_else(|| Error::invalid_request(Some("Invalid AT URI".to_string())))? 102 + let parts = req.uri.strip_prefix("at://") 103 + .ok_or(StatusCode::BAD_REQUEST)? 112 104 .split('/').collect::<Vec<_>>(); 113 105 114 106 if parts.len() != 3 { 115 - return Err(Error::invalid_request(Some("Invalid AT URI format".to_string()))); 107 + return Err(StatusCode::BAD_REQUEST); 116 108 } 117 109 118 110 let (post_did, collection, rkey_str) = (parts[0], parts[1], parts[2]); 119 111 120 112 // Validate that this is a post URI 121 113 if collection != "app.bsky.feed.post" { 122 - return Err(Error::invalid_request(Some("Bookmarks can only be deleted for posts".to_string()))); 114 + return Err(StatusCode::BAD_REQUEST); 123 115 } 124 116 125 117 // Resolve post DID to actor_id using ProfileEntity 126 118 let post_actor_id = state.profile_entity.resolve_identifier(post_did).await 127 - .map_err(|_| Error::not_found())?; 119 + .map_err(|_| StatusCode::NOT_FOUND)?; 128 120 129 121 // Decode TID 130 122 let rkey = parakeet_db::tid_util::decode_tid(rkey_str) 131 - .map_err(|_| Error::invalid_request(Some("Invalid TID".to_string())))?; 123 + .map_err(|_| StatusCode::BAD_REQUEST)?; 132 124 133 125 // Remove bookmark from actor's array 134 126 use diesel::prelude::*; ··· 148 140 .bind::<diesel::sql_types::Integer, _>(post_actor_id) 149 141 .bind::<diesel::sql_types::BigInt, _>(rkey) 150 142 .execute(&mut conn) 151 - .await?; 143 + .await 144 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 152 145 153 - Ok(()) 154 - } 155 - 156 - #[derive(Debug, Serialize)] 157 - pub struct GetBookmarksRes { 158 - #[serde(skip_serializing_if = "Option::is_none")] 159 - pub cursor: Option<String>, 160 - pub bookmarks: Vec<BookmarkView>, 146 + Ok(Json(DeleteBookmarkResponse)) 161 147 } 162 148 163 149 pub async fn get_bookmarks( 164 150 State(state): State<GlobalState>, 151 + ExtractXrpc(req): ExtractXrpc<GetBookmarksRequest>, 165 152 AtpAcceptLabelers(_labelers): AtpAcceptLabelers, 166 153 auth: AtpAuth, 167 - Query(query): Query<CursorQuery>, 168 - ) -> XrpcResult<Json<GetBookmarksRes>> { 169 - use crate::common::errors::Error; 170 - let mut conn = state.pool.get().await?; 154 + ) -> Result<Json<GetBookmarksOutput<'static>>, StatusCode> { 155 + let mut conn = state.pool.get().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 171 156 172 157 // Resolve auth DID to actor_id using ProfileEntity 173 158 let actor_id = state.profile_entity.resolve_identifier(&auth.0).await 174 - .map_err(|_| Error::actor_not_found(&auth.0))?; 159 + .map_err(|_| StatusCode::NOT_FOUND)?; 175 160 176 - let limit = query.limit.unwrap_or(50).clamp(1, 100); 177 - let cursor_timestamp = datetime_cursor(query.cursor.as_ref()); 161 + let limit = req.limit.unwrap_or(50).clamp(1, 100) as u8; 162 + let cursor_str = req.cursor.as_ref().map(|s| s.as_str().to_string()); 163 + let cursor_timestamp = datetime_cursor(cursor_str.as_ref()); 178 164 179 165 // Get bookmarks from ProfileEntity 180 166 let mut bookmark_data = state.profile_entity.get_bookmarks( ··· 182 168 cursor_timestamp.as_ref(), 183 169 limit, 184 170 ) 185 - .await?; 171 + .await 172 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 186 173 187 174 // Check if there's a next page 188 175 let has_next = bookmark_data.len() > limit as usize; ··· 214 201 // Convert CID bytes to string, then parse to Cid 215 202 let cid_str = parakeet_db::cid_util::digest_to_record_cid_string(&cid) 216 203 .unwrap_or_else(|| "bafyreigrey4aogz7sq5bxfaiwlcieaivxscvgs5ivgqczecmvz6jhmnxq".to_string()); // Default CID 217 - let subject = lexica::StrongRef::new_from_str(uri.clone(), &cid_str) 218 - .unwrap_or_else(|_| { 219 - // Fallback if CID parsing fails 220 - lexica::StrongRef::new_from_str( 221 - uri.clone(), 222 - "bafyreigrey4aogz7sq5bxfaiwlcieaivxscvgs5ivgqczecmvz6jhmnxq" 223 - ).unwrap() 224 - }); 204 + 205 + use jacquard_common::IntoStatic; 206 + let subject = jacquard_api::com_atproto::repo::strong_ref::StrongRef { 207 + uri: jacquard_common::types::string::AtUri::new(&uri).unwrap(), 208 + cid: jacquard_common::types::string::Cid::new(cid_str.as_bytes()).unwrap(), 209 + extra_data: None, 210 + }.into_static(); 225 211 226 212 // Get post view 227 213 match state.post_entity.get_by_uri(&uri, Some(&auth.0)).await { 228 214 Ok(Some(post_view)) => { 229 215 bookmark_views.push(BookmarkView { 230 216 subject: subject.clone(), 231 - created_at, 232 - item: BookmarkViewItem::Post(Box::new(post_view)), 217 + created_at: Some(jacquard_common::types::datetime::Datetime::from(created_at.fixed_offset())), 218 + item: BookmarkViewItem::PostView(Box::new(post_view)), 219 + extra_data: None, 233 220 }); 234 221 } 235 222 _ => { 236 223 // Post not found or error 237 224 bookmark_views.push(BookmarkView { 238 225 subject, 239 - created_at, 240 - item: BookmarkViewItem::NotFound { 241 - uri: uri.clone(), 226 + created_at: Some(jacquard_common::types::datetime::Datetime::from(created_at.fixed_offset())), 227 + item: BookmarkViewItem::NotFoundPost(Box::new(jacquard_api::app_bsky::feed::NotFoundPost { 228 + uri: jacquard_common::types::string::AtUri::new(&uri).unwrap(), 242 229 not_found: true, 243 - }, 230 + extra_data: None, 231 + }.into_static())), 232 + extra_data: None, 244 233 }); 245 234 } 246 235 } 247 236 } 248 237 249 - Ok(Json(GetBookmarksRes { 250 - cursor, 238 + Ok(Json(GetBookmarksOutput { 239 + cursor: cursor.map(|c| jacquard_common::types::string::CowStr::from(c)), 251 240 bookmarks: bookmark_views, 241 + extra_data: None, 252 242 })) 253 243 } 254 244 255 - #[derive(Debug, Serialize)] 256 - pub struct GetBookmarksCountRes { 257 - pub count: i64, 258 - } 259 - 260 - pub async fn get_bookmarks_count( 261 - State(state): State<GlobalState>, 262 - auth: AtpAuth, 263 - ) -> XrpcResult<Json<GetBookmarksCountRes>> { 264 - use crate::common::errors::Error; 265 - let mut conn = state.pool.get().await?; 266 - 267 - // Resolve auth DID to actor_id using ProfileEntity 268 - let actor_id = state.profile_entity.resolve_identifier(&auth.0).await 269 - .map_err(|_| Error::actor_not_found(&auth.0))?; 270 - 271 - // Get bookmark count from ProfileEntity 272 - let count = state.profile_entity.get_bookmarks_count(actor_id).await?; 273 - 274 - Ok(Json(GetBookmarksCountRes { count })) 275 - } 245 + // Note: get_bookmarks_count doesn't exist in the Jacquard API, 246 + // this is likely a custom extension that needs to be removed or handled differently
+69 -83
parakeet/src/xrpc/app_bsky/feed/feedgen.rs
··· 1 - use crate::common::errors::{Error, XrpcResult}; 2 1 use crate::common::auth::{AtpAcceptLabelers, AtpAuth}; 3 - use crate::xrpc::{datetime_cursor, ActorWithCursorQuery}; 2 + use crate::xrpc::datetime_cursor; 4 3 use crate::GlobalState; 5 - use axum::extract::{Query, State}; 4 + use axum::extract::State; 5 + use axum::http::StatusCode; 6 6 use axum::Json; 7 - use lexica::app_bsky::feed::GeneratorView; 8 - use serde::{Deserialize, Serialize}; 9 - 10 - #[derive(Debug, Serialize)] 11 - pub struct GetActorFeedRes { 12 - #[serde(skip_serializing_if = "Option::is_none")] 13 - cursor: Option<String>, 14 - feeds: Vec<GeneratorView>, 15 - } 7 + use jacquard_axum::ExtractXrpc; 8 + use jacquard_api::app_bsky::feed::{ 9 + GeneratorView, 10 + get_actor_feeds::{GetActorFeedsRequest, GetActorFeedsOutput}, 11 + get_feed_generator::{GetFeedGeneratorRequest, GetFeedGeneratorOutput}, 12 + get_feed_generators::{GetFeedGeneratorsRequest, GetFeedGeneratorsOutput}, 13 + get_suggested_feeds::{GetSuggestedFeedsRequest, GetSuggestedFeedsOutput}, 14 + }; 15 + use jacquard_common::IntoStatic; 16 16 17 17 pub async fn get_actor_feeds( 18 18 State(state): State<GlobalState>, 19 + ExtractXrpc(req): ExtractXrpc<GetActorFeedsRequest>, 19 20 AtpAcceptLabelers(_labelers): AtpAcceptLabelers, 20 21 maybe_auth: Option<AtpAuth>, 21 - Query(query): Query<ActorWithCursorQuery>, 22 - ) -> XrpcResult<Json<GetActorFeedRes>> { 23 - let mut conn = state.pool.get().await?; 22 + ) -> Result<Json<GetActorFeedsOutput<'static>>, StatusCode> { 23 + let mut conn = state.pool.get().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 24 24 25 25 // Get viewer DID if authenticated 26 26 let viewer_did = maybe_auth.as_ref().map(|auth| auth.0.clone()); 27 27 28 28 // Resolve actor to actor_id 29 - let actor_id = state.profile_entity.resolve_identifier(&query.actor).await?; 29 + let actor_id = state.profile_entity.resolve_identifier(req.actor.as_ref()).await 30 + .map_err(|_| StatusCode::NOT_FOUND)?; 30 31 31 32 // Check if actor is active 32 33 use diesel::prelude::*; ··· 39 40 .filter(actors::status.eq(ActorStatus::Active)) 40 41 .select(diesel::dsl::count(actors::id).gt(0)) 41 42 .first(&mut conn) 42 - .await?; 43 + .await 44 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 43 45 44 46 if !is_active { 45 - return Err(Error::actor_not_found(&query.actor)); 47 + return Err(StatusCode::NOT_FOUND); 46 48 } 47 49 48 - let limit = query.limit.unwrap_or(50).clamp(1, 100); 49 - let cursor_timestamp = datetime_cursor(query.cursor.as_ref()); 50 + let limit = req.limit.unwrap_or(50).clamp(1, 100); 51 + let cursor_timestamp = req.cursor.as_ref() 52 + .map(|c| c.as_ref()) 53 + .and_then(|c| datetime_cursor(Some(&c.to_string()))); 50 54 51 55 // Get feedgens owned by this actor using FeedGeneratorEntity 52 56 let results = state.feedgen_entity.get_actor_feedgens( ··· 55 59 limit, 56 60 ) 57 61 .await 58 - .map_err(|e| Error::server_error(Some(&e.to_string())))?; 62 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 59 63 60 64 let cursor = results 61 65 .last() ··· 63 67 64 68 // Get DIDs for all actor_ids involved 65 69 let actor_ids: Vec<i32> = results.iter().map(|r| r.1).collect(); 66 - let actors = state.profile_entity.get_profiles_by_ids(&actor_ids).await?; 70 + let actors = state.profile_entity.get_profiles_by_ids(&actor_ids).await 71 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 67 72 68 73 let mut actor_id_to_did = std::collections::HashMap::new(); 69 74 for actor in actors { ··· 82 87 // Get feed generators using entity 83 88 let mut feeds_map = state.feedgen_entity 84 89 .get_by_uris(at_uris.clone(), viewer_did.as_deref()) 85 - .await?; 90 + .await 91 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 86 92 87 93 // Preserve original order 88 94 let feeds = at_uris ··· 90 96 .filter_map(|uri| feeds_map.remove(&uri)) 91 97 .collect(); 92 98 93 - Ok(Json(GetActorFeedRes { cursor, feeds })) 94 - } 95 - 96 - #[derive(Debug, Deserialize)] 97 - pub struct GetFeedGeneratorQuery { 98 - pub feed: String, 99 - } 100 - 101 - #[derive(Debug, Serialize)] 102 - #[serde(rename_all = "camelCase")] 103 - pub struct GetFeedGeneratorRes { 104 - pub view: GeneratorView, 105 - pub is_online: bool, 106 - pub is_valid: bool, 99 + Ok(Json(GetActorFeedsOutput { 100 + cursor: cursor.map(|c| jacquard_common::CowStr::from(c)), 101 + feeds, 102 + extra_data: None, 103 + }.into_static())) 107 104 } 108 105 109 106 pub async fn get_feed_generator( 110 107 State(state): State<GlobalState>, 108 + ExtractXrpc(req): ExtractXrpc<GetFeedGeneratorRequest>, 111 109 AtpAcceptLabelers(_labelers): AtpAcceptLabelers, 112 110 maybe_auth: Option<AtpAuth>, 113 - Query(query): Query<GetFeedGeneratorQuery>, 114 - ) -> XrpcResult<Json<GetFeedGeneratorRes>> { 111 + ) -> Result<Json<GetFeedGeneratorOutput<'static>>, StatusCode> { 115 112 // Get viewer DID if authenticated 116 113 let viewer_did = maybe_auth.as_ref().map(|auth| auth.0.clone()); 117 114 118 115 // Get the feed generator using entity 119 116 let view = state.feedgen_entity 120 - .get_by_uri(&query.feed, viewer_did.as_deref()) 121 - .await? 122 - .ok_or_else(|| Error::not_found())?; 117 + .get_by_uri(&req.feed, viewer_did.as_deref()) 118 + .await 119 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? 120 + .ok_or(StatusCode::NOT_FOUND)?; 123 121 124 122 // For now, assume all feeds are online and valid 125 123 // In production, you'd check the service status 126 - Ok(Json(GetFeedGeneratorRes { 127 - view, 124 + Ok(Json(GetFeedGeneratorOutput { 125 + view: view.into_static(), 128 126 is_online: true, 129 127 is_valid: true, 128 + extra_data: None, 130 129 })) 131 130 } 132 131 133 - #[derive(Debug, Deserialize)] 134 - pub struct GetFeedGeneratorsQuery { 135 - pub feeds: Vec<String>, 136 - } 137 - 138 - #[derive(Debug, Serialize)] 139 - pub struct GetFeedGeneratorsRes { 140 - pub feeds: Vec<GeneratorView>, 141 - } 142 - 143 132 pub async fn get_feed_generators( 144 133 State(state): State<GlobalState>, 134 + ExtractXrpc(req): ExtractXrpc<GetFeedGeneratorsRequest>, 145 135 AtpAcceptLabelers(_labelers): AtpAcceptLabelers, 146 136 maybe_auth: Option<AtpAuth>, 147 - axum_extra::extract::Query(query): axum_extra::extract::Query<GetFeedGeneratorsQuery>, 148 - ) -> XrpcResult<Json<GetFeedGeneratorsRes>> { 137 + ) -> Result<Json<GetFeedGeneratorsOutput<'static>>, StatusCode> { 149 138 // Get viewer DID if authenticated 150 139 let viewer_did = maybe_auth.as_ref().map(|auth| auth.0.clone()); 151 140 152 141 // Get feed generators using entity 153 142 let feeds_map = state.feedgen_entity 154 - .get_by_uris(query.feeds.clone(), viewer_did.as_deref()) 155 - .await?; 143 + .get_by_uris(req.feeds.iter().map(|f| f.to_string()).collect(), viewer_did.as_deref()) 144 + .await 145 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 156 146 157 147 // Preserve original order 158 - let feeds = query.feeds 148 + let feeds = req.feeds 159 149 .into_iter() 160 - .filter_map(|uri| feeds_map.get(&uri).cloned()) 150 + .filter_map(|uri| feeds_map.get(&uri.to_string()).cloned()) 161 151 .collect(); 162 152 163 - Ok(Json(GetFeedGeneratorsRes { feeds })) 164 - } 165 - 166 - #[derive(Debug, Deserialize)] 167 - pub struct GetSuggestedFeedsQuery { 168 - pub limit: Option<u16>, 169 - pub cursor: Option<String>, 170 - } 171 - 172 - #[derive(Debug, Serialize)] 173 - pub struct GetSuggestedFeedsRes { 174 - #[serde(skip_serializing_if = "Option::is_none")] 175 - cursor: Option<String>, 176 - feeds: Vec<GeneratorView>, 153 + Ok(Json(GetFeedGeneratorsOutput { 154 + feeds, 155 + extra_data: None, 156 + }.into_static())) 177 157 } 178 158 179 159 /// Get suggested feeds ranked by popularity 180 160 pub async fn get_suggested_feeds( 181 161 State(state): State<GlobalState>, 162 + ExtractXrpc(req): ExtractXrpc<GetSuggestedFeedsRequest>, 182 163 AtpAcceptLabelers(_labelers): AtpAcceptLabelers, 183 164 maybe_auth: Option<AtpAuth>, 184 - Query(query): Query<GetSuggestedFeedsQuery>, 185 - ) -> XrpcResult<Json<GetSuggestedFeedsRes>> { 186 - let limit = query.limit.unwrap_or(50).clamp(1, 100) as usize; 187 - let offset = query 165 + ) -> Result<Json<GetSuggestedFeedsOutput<'static>>, StatusCode> { 166 + let limit = req.limit.unwrap_or(50).clamp(1, 100) as usize; 167 + let offset = req 188 168 .cursor 189 169 .as_ref() 190 170 .and_then(|c| c.parse::<usize>().ok()) ··· 193 173 // Fetch all feedgens ordered by like count using FeedGeneratorEntity 194 174 let feedgens_ranked = state.feedgen_entity.get_all_feedgens_by_likes() 195 175 .await 196 - .map_err(|e| Error::server_error(Some(&e.to_string())))?; 176 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 197 177 198 178 if feedgens_ranked.is_empty() { 199 - return Ok(Json(GetSuggestedFeedsRes { 179 + return Ok(Json(GetSuggestedFeedsOutput { 200 180 cursor: None, 201 181 feeds: Vec::new(), 202 - })); 182 + extra_data: None, 183 + }.into_static())); 203 184 } 204 185 205 186 // Apply pagination ··· 241 222 // Get feed generators using entity 242 223 let feeds_map = state.feedgen_entity 243 224 .get_by_uris(page_uris.clone(), viewer_did.as_deref()) 244 - .await?; 225 + .await 226 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 245 227 246 228 // Preserve original order 247 229 let feeds: Vec<GeneratorView> = page_uris ··· 249 231 .filter_map(|uri| feeds_map.get(&uri).cloned()) 250 232 .collect(); 251 233 252 - Ok(Json(GetSuggestedFeedsRes { cursor, feeds })) 234 + Ok(Json(GetSuggestedFeedsOutput { 235 + cursor: cursor.map(|c| jacquard_common::CowStr::from(c)), 236 + feeds, 237 + extra_data: None, 238 + }.into_static())) 253 239 }
+50 -60
parakeet/src/xrpc/app_bsky/feed/get_timeline.rs
··· 1 1 use crate::xrpc::datetime_cursor; 2 - use crate::common::errors::XrpcResult; 3 2 use crate::common::auth::{AtpAcceptLabelers, AtpAuth}; 4 3 use crate::GlobalState; 5 4 use axum::extract::{Query, State}; 5 + use axum::http::StatusCode; 6 6 use axum::Json; 7 - use lexica::app_bsky::feed::{FeedViewPost, PostView}; 7 + use jacquard_axum::ExtractXrpc; 8 + use jacquard_api::app_bsky::feed::{ 9 + FeedViewPost, 10 + get_timeline::{GetTimelineRequest, GetTimelineOutput}, 11 + get_author_feed::{GetAuthorFeedRequest, GetAuthorFeedOutput}, 12 + }; 13 + use jacquard_common::IntoStatic; 8 14 use serde::{Deserialize, Serialize}; 9 15 10 - #[derive(Debug, Deserialize)] 11 - pub struct GetTimelineQuery { 12 - #[expect(dead_code)] 13 - pub algorithm: Option<String>, 14 - pub limit: Option<u8>, 15 - pub cursor: Option<String>, 16 - } 17 - 18 - #[derive(Debug, Serialize)] 19 - pub struct GetTimelineRes { 20 - #[serde(skip_serializing_if = "Option::is_none")] 21 - cursor: Option<String>, 22 - feed: Vec<FeedViewPost>, 23 - } 24 - 25 16 pub async fn get_timeline( 26 17 State(state): State<GlobalState>, 18 + ExtractXrpc(req): ExtractXrpc<GetTimelineRequest>, 27 19 AtpAcceptLabelers(_labelers): AtpAcceptLabelers, 28 20 auth: AtpAuth, 29 - Query(query): Query<GetTimelineQuery>, 30 - ) -> XrpcResult<Json<GetTimelineRes>> { 21 + ) -> Result<Json<GetTimelineOutput<'static>>, StatusCode> { 31 22 let start = std::time::Instant::now(); 32 23 33 24 // Get the user's DID from auth 34 25 let user_did = auth.0.clone(); 35 26 36 27 // Set the limit, clamped to reasonable values 37 - let limit = query.limit.unwrap_or(50).clamp(1, 100); 28 + let limit = req.limit.unwrap_or(50).clamp(1, 100); 38 29 39 - tracing::info!("getTimeline request: limit={}, cursor={:?}", limit, query.cursor); 30 + tracing::info!("getTimeline request: limit={}, cursor={:?}", limit, req.cursor); 40 31 41 32 // Resolve actor_id using ProfileEntity 42 33 let user_actor_id = state.profile_entity.resolve_identifier(&user_did).await 43 - .map_err(|_| crate::common::errors::Error::actor_not_found(&user_did))?; 34 + .map_err(|_| StatusCode::NOT_FOUND)?; 44 35 45 36 // New pattern: Get profiles with their posts, then hydrate 46 37 let mut step_timer = std::time::Instant::now(); 47 38 48 39 // Parse cursor (convert to TID if provided) 49 - let cursor_tid = query.cursor.as_ref() 50 - .and_then(|c| datetime_cursor(Some(c))) 40 + let cursor_tid = req.cursor.as_ref() 41 + .map(|c| c.as_ref()) 42 + .and_then(|c| datetime_cursor(Some(&c.to_string()))) 51 43 .map(|dt| { 52 44 let tid_str = parakeet_db::tid_util::timestamp_to_tid(dt); 53 45 parakeet_db::tid_util::decode_tid(&tid_str).unwrap_or(0) ··· 152 144 reply: reply_context, 153 145 reason: None, 154 146 feed_context: None, 147 + extra_data: None, 148 + req_id: None, 155 149 }); 156 150 } 157 151 } ··· 173 167 let reposter_basic = state.profile_entity.actor_to_profile_view_basic(&reposter, Some(&user_did)).await; 174 168 175 169 // Build the repost reason 176 - let reason = Some(lexica::app_bsky::feed::FeedViewPostReason::Repost(Box::new( 177 - lexica::app_bsky::feed::FeedReasonRepost { 170 + let reason = Some(jacquard_api::app_bsky::feed::FeedViewPostReason::ReasonRepost(Box::new( 171 + jacquard_api::app_bsky::feed::ReasonRepost { 178 172 by: reposter_basic, 179 - uri: Some(format!( 173 + uri: Some(jacquard_common::types::aturi::AtUri::new(&format!( 180 174 "at://{}/app.bsky.feed.repost/{}", 181 175 reposter.did, 182 176 parakeet_db::tid_util::encode_tid(rkey) 183 - )), 177 + )).unwrap()), 184 178 cid: None, // TODO: Add CID if needed 185 - indexed_at, 179 + indexed_at: jacquard_common::types::datetime::Datetime::from(indexed_at.fixed_offset()), 180 + extra_data: None, 186 181 } 187 182 ))); 188 183 ··· 194 189 reply: reply_context, 195 190 reason, 196 191 feed_context: None, 192 + extra_data: None, 193 + req_id: None, 197 194 }); 198 195 } 199 196 } ··· 209 206 let total_time = start.elapsed().as_secs_f64() * 1000.0; 210 207 tracing::info!(" └─ getTimeline total: {:.1} ms (returning {} posts, cursor: {:?})", total_time, feed.len(), cursor); 211 208 212 - Ok(Json(GetTimelineRes { 213 - cursor, 209 + Ok(Json(GetTimelineOutput { 210 + cursor: cursor.map(|c| jacquard_common::CowStr::from(c)), 214 211 feed, 215 - })) 212 + extra_data: None, 213 + }.into_static())) 216 214 } 217 215 218 216 pub async fn get_author_feed( 219 217 State(state): State<GlobalState>, 218 + ExtractXrpc(req): ExtractXrpc<GetAuthorFeedRequest>, 220 219 AtpAcceptLabelers(_labelers): AtpAcceptLabelers, 221 220 maybe_auth: Option<AtpAuth>, 222 - Query(query): Query<GetAuthorFeedQuery>, 223 - ) -> XrpcResult<Json<GetAuthorFeedRes>> { 221 + ) -> Result<Json<GetAuthorFeedOutput<'static>>, StatusCode> { 224 222 let start = std::time::Instant::now(); 225 223 226 224 let viewer_did = maybe_auth.as_ref().map(|auth| auth.0.clone()); 227 - let limit = query.limit.unwrap_or(50).clamp(1, 100); 225 + let limit = req.limit.unwrap_or(50).clamp(1, 100); 228 226 229 227 tracing::info!("getAuthorFeed: actor={}, filter={:?}, limit={}, cursor={:?}", 230 - query.actor, query.filter, limit, query.cursor); 228 + req.actor, req.filter, limit, req.cursor); 231 229 232 230 // Resolve actor to actor_id using ProfileEntity (only DID resolution here) 233 - let actor_id = state.profile_entity.resolve_identifier(&query.actor).await 234 - .map_err(|_| crate::common::errors::Error::actor_not_found(&query.actor))?; 231 + let actor_id = state.profile_entity.resolve_identifier(req.actor.as_ref()).await 232 + .map_err(|_| StatusCode::NOT_FOUND)?; 235 233 236 234 // Parse cursor (convert to TID if provided) 237 - let cursor_tid = query.cursor.as_ref() 238 - .and_then(|c| datetime_cursor(Some(c))) 235 + let cursor_tid = req.cursor.as_ref() 236 + .map(|c| c.as_ref()) 237 + .and_then(|c| datetime_cursor(Some(&c.to_string()))) 239 238 .map(|dt| { 240 239 let tid_str = parakeet_db::tid_util::timestamp_to_tid(dt); 241 240 parakeet_db::tid_util::decode_tid(&tid_str).unwrap_or(0) ··· 245 244 let mut step_timer = std::time::Instant::now(); 246 245 247 246 let post_keys = state.profile_entity 248 - .get_author_posts(actor_id, limit as usize + 1, cursor_tid, query.filter.as_deref()) 249 - .await?; 247 + .get_author_posts(actor_id, limit as usize + 1, cursor_tid, req.filter.as_ref().map(|f| f.as_ref())) 248 + .await 249 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 250 250 251 251 let profile_time = step_timer.elapsed().as_secs_f64() * 1000.0; 252 252 tracing::info!(" ├─ Get author posts: {:.1} ms ({} posts)", profile_time, post_keys.len()); ··· 275 275 .unwrap_or_default(); 276 276 277 277 // Get reply context if needed 278 - let mut posts_with_reply: Vec<(PostView, Option<lexica::app_bsky::feed::ReplyRef>)> = Vec::new(); 278 + let mut posts_with_reply: Vec<(jacquard_api::app_bsky::feed::PostView, Option<jacquard_api::app_bsky::feed::ReplyRef>)> = Vec::new(); 279 279 280 280 for post_data in post_data_list { 281 281 // Convert to PostView ··· 300 300 reply: reply_context, 301 301 reason: None, 302 302 feed_context: None, 303 + extra_data: None, 304 + req_id: None, 303 305 }); 304 306 } 305 307 306 308 let total_time = start.elapsed().as_secs_f64() * 1000.0; 307 309 tracing::info!(" └─ getAuthorFeed total: {:.1} ms (returning {} posts)", total_time, feed.len()); 308 310 309 - Ok(Json(GetAuthorFeedRes { 310 - cursor, 311 + Ok(Json(GetAuthorFeedOutput { 312 + cursor: cursor.map(|c| jacquard_common::CowStr::from(c)), 311 313 feed, 312 - })) 314 + extra_data: None, 315 + }.into_static())) 313 316 } 314 317 315 - #[derive(Debug, Deserialize)] 316 - pub struct GetAuthorFeedQuery { 317 - pub actor: String, 318 - pub limit: Option<u8>, 319 - pub cursor: Option<String>, 320 - pub filter: Option<String>, 321 - } 322 - 323 - #[derive(Debug, Serialize)] 324 - pub struct GetAuthorFeedRes { 325 - #[serde(skip_serializing_if = "Option::is_none")] 326 - pub cursor: Option<String>, 327 - pub feed: Vec<FeedViewPost>, 328 - } 318 + // GetAuthorFeedQuery and GetAuthorFeedRes are replaced by jacquard types
+10 -6
parakeet/src/xrpc/app_bsky/feed/likes.rs
··· 4 4 use crate::GlobalState; 5 5 use axum::extract::{Query, State}; 6 6 use axum::Json; 7 - use lexica::app_bsky::feed::{FeedViewPost, Like}; 8 - use lexica::app_bsky::actor::ProfileView; 7 + use jacquard_api::app_bsky::feed::FeedViewPost; 8 + use jacquard_api::app_bsky::feed::get_likes::Like; 9 + use jacquard_api::app_bsky::actor::ProfileView; 9 10 use serde::{Deserialize, Serialize}; 10 11 11 12 #[derive(Debug, Serialize)] 12 13 pub struct FeedRes { 13 14 #[serde(skip_serializing_if = "Option::is_none")] 14 15 cursor: Option<String>, 15 - feed: Vec<FeedViewPost>, 16 + feed: Vec<FeedViewPost<'static>>, 16 17 } 17 18 18 19 pub async fn get_actor_likes( ··· 73 74 reply: None, 74 75 reason: None, 75 76 feed_context: None, 77 + extra_data: None, 78 + req_id: None, 76 79 }); 77 80 } 78 81 } ··· 93 96 pub uri: String, 94 97 #[serde(skip_serializing_if = "Option::is_none")] 95 98 pub cid: Option<String>, 96 - pub likes: Vec<Like>, 99 + pub likes: Vec<Like<'static>>, 97 100 #[serde(skip_serializing_if = "Option::is_none")] 98 101 pub cursor: Option<String>, 99 102 } ··· 146 149 let actor_view = state.profile_entity.actor_to_profile_view(&liker); 147 150 148 151 likes.push(Like { 149 - indexed_at, 150 - created_at: indexed_at, 151 152 actor: actor_view, 153 + created_at: jacquard_common::types::datetime::Datetime::from(indexed_at.fixed_offset()), 154 + indexed_at: jacquard_common::types::datetime::Datetime::from(indexed_at.fixed_offset()), 155 + extra_data: None, 152 156 }); 153 157 } 154 158 }
+4 -4
parakeet/src/xrpc/app_bsky/feed/posts/feeds.rs
··· 1 1 use axum::extract::{Query, State}; 2 2 use axum::Json; 3 - use lexica::app_bsky::feed::{FeedViewPost, GeneratorView}; 3 + use jacquard_api::app_bsky::feed::{FeedViewPost, GeneratorView}; 4 4 use serde::{Deserialize, Serialize}; 5 5 6 6 use crate::xrpc::datetime_cursor; ··· 19 19 pub struct GetFeedRes { 20 20 #[serde(skip_serializing_if = "Option::is_none")] 21 21 pub cursor: Option<String>, 22 - pub feed: Vec<FeedViewPost>, 22 + pub feed: Vec<FeedViewPost<'static>>, 23 23 } 24 24 25 25 pub async fn get_feed( ··· 67 67 68 68 #[derive(Debug, Serialize)] 69 69 pub struct GetFeedGeneratorRes { 70 - pub view: GeneratorView, 70 + pub view: GeneratorView<'static>, 71 71 pub is_online: bool, 72 72 pub is_valid: bool, 73 73 } ··· 100 100 101 101 #[derive(Debug, Serialize)] 102 102 pub struct GetFeedGeneratorsRes { 103 - pub feeds: Vec<GeneratorView>, 103 + pub feeds: Vec<GeneratorView<'static>>, 104 104 } 105 105 106 106 pub async fn get_feed_generators(
+18 -14
parakeet/src/xrpc/app_bsky/feed/posts/helpers.rs
··· 3 3 use axum_extra::TypedHeader; 4 4 use chrono::NaiveDateTime; 5 5 use diesel_async::AsyncPgConnection; 6 - use lexica::app_bsky::feed::{ 7 - BlockedAuthor, FeedSkeletonResponse, PostView, ThreadViewPost, ThreadViewPostType, 6 + use jacquard_api::app_bsky::feed::{ 7 + BlockedAuthor, PostView, ThreadViewPost, 8 + get_post_thread::GetPostThreadOutputThread as ThreadViewPostType, 9 + get_feed_skeleton::GetFeedSkeletonOutput as FeedSkeletonResponse, 8 10 }; 9 11 use reqwest::Url; 10 12 use std::collections::HashMap; ··· 14 16 #[expect(dead_code)] 15 17 const FEEDGEN_SERVICE_ID: &str = "#bsky_fg"; 16 18 17 - pub(super) async fn get_feed_skeleton( 19 + pub(super) async fn get_feed_skeleton<'a>( 18 20 client: &reqwest::Client, 19 21 feed: &str, 20 22 service: &str, 21 23 maybe_tok: Option<&TypedHeader<Authorization<Bearer>>>, 22 24 limit: Option<u8>, 23 25 cursor: Option<String>, 24 - ) -> XrpcResult<FeedSkeletonResponse> { 26 + ) -> XrpcResult<FeedSkeletonResponse<'a>> { 25 27 let mut params = vec![("feed", feed.to_owned())]; 26 28 27 29 if let Some(cursor) = cursor { ··· 65 67 HashMap::new() 66 68 } 67 69 68 - pub(super) fn postview_to_tvpt( 69 - post: PostView, 70 - parent: Option<ThreadViewPostType>, 71 - replies: Vec<ThreadViewPostType>, 72 - ) -> ThreadViewPostType { 70 + pub(super) fn postview_to_tvpt<'a>( 71 + post: PostView<'a>, 72 + parent: Option<ThreadViewPostType<'a>>, 73 + replies: Vec<ThreadViewPostType<'a>>, 74 + ) -> ThreadViewPostType<'a> { 73 75 match &post.author.viewer { 74 - Some(v) if v.blocked_by || v.blocking.is_some() => ThreadViewPostType::Blocked { 76 + Some(v) if v.blocked_by.unwrap_or(false) || v.blocking.is_some() => ThreadViewPostType::BlockedPost(Box::new(jacquard_api::app_bsky::feed::BlockedPost { 75 77 uri: post.uri.clone(), 76 78 blocked: true, 77 - author: Box::new(BlockedAuthor { 79 + author: BlockedAuthor { 78 80 did: post.author.did, 79 81 viewer: post.author.viewer, 80 - }), 81 - }, 82 + extra_data: None, 83 + }, 84 + extra_data: None, 85 + })), 82 86 _ => ThreadViewPostType::Post(Box::new(ThreadViewPost { 83 87 post, 84 88 parent, 85 - replies, 89 + replies: Some(replies.into_iter().map(|r| jacquard_api::app_bsky::feed::ThreadViewPostRepliesItem::ThreadViewPost(Box::new(r))).collect()), 86 90 })), 87 91 } 88 92 }
+5 -5
parakeet/src/xrpc/app_bsky/feed/posts/queries.rs
··· 1 1 use axum::extract::{Query, State}; 2 2 use axum::Json; 3 3 use axum_extra::extract::Query as ExtraQuery; 4 - use lexica::app_bsky::feed::PostView; 4 + use jacquard_api::app_bsky::feed::PostView; 5 5 use serde::{Deserialize, Serialize}; 6 6 7 7 use crate::common::errors::XrpcResult; ··· 15 15 16 16 #[derive(Debug, Serialize)] 17 17 pub struct PostsRes { 18 - pub posts: Vec<PostView>, 18 + pub posts: Vec<PostView<'static>>, 19 19 } 20 20 21 21 pub async fn get_posts( ··· 34 34 let posts_map = state.post_entity.get_by_uris(uris.clone(), viewer_did.as_deref()).await?; 35 35 36 36 // Preserve the order of the original URIs 37 - let posts: Vec<PostView> = uris 37 + let posts: Vec<PostView<'static>> = uris 38 38 .into_iter() 39 39 .filter_map(|uri| posts_map.get(&uri).cloned()) 40 40 .collect(); ··· 99 99 pub cid: Option<String>, 100 100 #[serde(skip_serializing_if = "Option::is_none")] 101 101 pub cursor: Option<String>, 102 - pub posts: Vec<PostView>, 102 + pub posts: Vec<PostView<'static>>, 103 103 } 104 104 105 105 pub async fn get_quotes( ··· 203 203 pub cid: Option<String>, 204 204 #[serde(skip_serializing_if = "Option::is_none")] 205 205 pub cursor: Option<String>, 206 - pub reposted_by: Vec<lexica::app_bsky::actor::ProfileView>, 206 + pub reposted_by: Vec<jacquard_api::app_bsky::actor::ProfileView<'static>>, 207 207 } 208 208 209 209 pub async fn get_reposted_by(
+48 -22
parakeet/src/xrpc/app_bsky/feed/posts/threads.rs
··· 1 1 use axum::extract::{Query, State}; 2 2 use axum::Json; 3 - use lexica::app_bsky::feed::{BlockedAuthor, PostView, ThreadViewPost, ThreadViewPostType, ThreadgateView}; 3 + use jacquard_api::app_bsky::feed::{ 4 + BlockedAuthor, PostView, ThreadViewPost, ThreadgateView, 5 + get_post_thread::GetPostThreadOutputThread as ThreadViewPostType 6 + }; 4 7 use serde::{Deserialize, Serialize}; 5 8 use std::collections::HashMap; 6 9 ··· 20 23 } 21 24 22 25 #[derive(Debug, Serialize)] 23 - pub struct GetPostThreadRes { 24 - pub thread: ThreadViewPostType, 26 + pub struct GetPostThreadRes<'a> { 27 + pub thread: ThreadViewPostType<'a>, 25 28 #[serde(skip_serializing_if = "Option::is_none")] 26 - pub threadgate: Option<ThreadgateView>, 29 + pub threadgate: Option<ThreadgateView<'static>>, 27 30 } 28 31 29 32 pub async fn get_post_thread( ··· 31 34 AtpAcceptLabelers(_labelers): AtpAcceptLabelers, 32 35 maybe_auth: Option<AtpAuth>, 33 36 Query(query): Query<GetPostThreadQuery>, 34 - ) -> XrpcResult<Json<GetPostThreadRes>> { 37 + ) -> XrpcResult<Json<GetPostThreadRes<'static>>> { 35 38 let mut conn = state.pool.get().await?; 36 39 let viewer_did = maybe_auth.as_ref().map(|auth| auth.0.clone()); 37 40 ··· 47 50 48 51 // Check if author is blocked 49 52 if let Some(viewer) = &root.author.viewer { 50 - if viewer.blocked_by || viewer.blocking.is_some() { 53 + if viewer.blocked_by.unwrap_or(false) || viewer.blocking.is_some() { 51 54 return Ok(Json(GetPostThreadRes { 52 - thread: ThreadViewPostType::Blocked { 53 - uri, 55 + thread: ThreadViewPostType::BlockedPost(Box::new(jacquard_api::app_bsky::feed::BlockedPost { 56 + uri: jacquard_common::types::aturi::AtUri::new(&uri).unwrap(), 54 57 blocked: true, 55 - author: Box::new(BlockedAuthor { 58 + author: BlockedAuthor { 56 59 did: root.author.did.clone(), 57 60 viewer: root.author.viewer.clone(), 58 - }), 59 - }, 61 + extra_data: None, 62 + }, 63 + extra_data: None, 64 + })), 60 65 threadgate, 61 66 })); 62 67 } ··· 78 83 .map_err(|_| Error::invalid_request(Some("Invalid rkey".to_string())))?; 79 84 80 85 // Get root info for parent query filtering 81 - let root_uri = root.record.get("reply") 82 - .and_then(|reply| reply.get("root")) 83 - .and_then(|root| root.get("uri")) 84 - .and_then(|uri| uri.as_str()) 85 - .map(|s| s.to_string()); 86 + let root_uri = if let jacquard_common::Data::Object(ref obj) = root.record { 87 + obj.get("reply") 88 + .and_then(|reply| { 89 + if let jacquard_common::Data::Object(ref reply_obj) = reply { 90 + reply_obj.get("root") 91 + } else { 92 + None 93 + } 94 + }) 95 + .and_then(|root| { 96 + if let jacquard_common::Data::Object(ref root_obj) = root { 97 + root_obj.get("uri") 98 + } else { 99 + None 100 + } 101 + }) 102 + .and_then(|uri| { 103 + if let jacquard_common::Data::String(ref s) = uri { 104 + Some(s.to_string()) 105 + } else { 106 + None 107 + } 108 + }) 109 + } else { 110 + None 111 + }; 86 112 87 113 let root_info = if let Some(ref root_uri_str) = root_uri { 88 - let root_parts: Vec<&str> = root_uri_str.trim_start_matches("at://").split('/').collect(); 114 + let root_parts: Vec<&str> = root_uri_str.trim_start_matches("at://").split('/').collect::<Vec<_>>(); 89 115 if root_parts.len() >= 3 { 90 116 let root_did = root_parts[0]; 91 117 let root_rkey_base32 = root_parts[2]; ··· 151 177 })) 152 178 } 153 179 154 - fn build_thread_structure( 155 - root: PostView, 180 + fn build_thread_structure<'a>( 181 + root: PostView<'a>, 156 182 parents: Vec<ThreadItem>, 157 183 children: Vec<ThreadItem>, 158 - posts_map: HashMap<String, PostView>, 159 - ) -> ThreadViewPostType { 184 + posts_map: HashMap<String, PostView<'a>>, 185 + ) -> ThreadViewPostType<'a> { 160 186 // Convert root to ThreadViewPost 161 187 let root_tvp = ThreadViewPost { 162 188 post: root, 163 189 parent: None, 164 - replies: Vec::new(), // Will be populated 190 + replies: Some(Vec::new()), // Will be populated 165 191 }; 166 192 167 193 // TODO: Build full thread structure with parents and children
+2 -2
parakeet/src/xrpc/app_bsky/feed/search.rs
··· 3 3 use crate::GlobalState; 4 4 use axum::extract::{Query, State}; 5 5 use axum::Json; 6 - use lexica::app_bsky::feed::PostView; 6 + use jacquard_api::app_bsky::feed::PostView; 7 7 use serde::{Deserialize, Serialize}; 8 8 9 9 #[derive(Debug, Deserialize)] ··· 24 24 25 25 #[derive(Debug, Serialize)] 26 26 pub struct SearchPostsRes { 27 - pub posts: Vec<PostView>, 27 + pub posts: Vec<PostView<'static>>, 28 28 #[serde(skip_serializing_if = "Option::is_none")] 29 29 pub cursor: Option<String>, 30 30 pub hits_total: Option<i64>,
+5 -4
parakeet/src/xrpc/app_bsky/graph/lists.rs
··· 4 4 use crate::GlobalState; 5 5 use axum::extract::{Query, State}; 6 6 use axum::Json; 7 - use lexica::app_bsky::graph::{ListItemView, ListView}; 7 + use jacquard_api::app_bsky::graph::{ListItemView, ListView}; 8 8 use serde::{Deserialize, Serialize}; 9 9 10 10 #[derive(Debug, Deserialize)] ··· 18 18 pub struct GetListsRes { 19 19 #[serde(skip_serializing_if = "Option::is_none")] 20 20 cursor: Option<String>, 21 - lists: Vec<ListView>, 21 + lists: Vec<ListView<'static>>, 22 22 } 23 23 24 24 pub async fn get_lists( ··· 75 75 pub struct AppBskyGraphGetListRes { 76 76 #[serde(skip_serializing_if = "Option::is_none")] 77 77 cursor: Option<String>, 78 - list: ListView, 79 - items: Vec<ListItemView>, 78 + list: ListView<'static>, 79 + items: Vec<ListItemView<'static>>, 80 80 } 81 81 82 82 #[derive(Debug, Deserialize)] ··· 178 178 Some(ListItemView { 179 179 uri: item_uri, 180 180 subject, 181 + extra_data: None, 181 182 }) 182 183 }) 183 184 .collect();
+3 -3
parakeet/src/xrpc/app_bsky/graph/mutes.rs
··· 5 5 use crate::GlobalState; 6 6 use axum::extract::{Query, State}; 7 7 use axum::Json; 8 - use lexica::app_bsky::actor::ProfileView; 8 + use jacquard_api::app_bsky::actor::ProfileView; 9 9 use serde::Serialize; 10 10 11 11 #[derive(Debug, Serialize)] 12 12 pub struct MutesRes { 13 13 #[serde(skip_serializing_if = "Option::is_none")] 14 14 pub cursor: Option<String>, 15 - pub mutes: Vec<ProfileView>, 15 + pub mutes: Vec<ProfileView<'static>>, 16 16 } 17 17 18 18 pub async fn get_mutes( ··· 93 93 pub struct MuteListsRes { 94 94 #[serde(skip_serializing_if = "Option::is_none")] 95 95 pub cursor: Option<String>, 96 - pub lists: Vec<lexica::app_bsky::graph::ListView>, 96 + pub lists: Vec<jacquard_api::app_bsky::graph::ListView<'static>>, 97 97 } 98 98 99 99 pub async fn get_muted_words(
+7 -7
parakeet/src/xrpc/app_bsky/graph/relations.rs
··· 47 47 pub struct BlocksRes { 48 48 #[serde(skip_serializing_if = "Option::is_none")] 49 49 pub cursor: Option<String>, 50 - pub blocks: Vec<ProfileView>, 50 + pub blocks: Vec<ProfileView<'static>>, 51 51 } 52 52 use axum::extract::{Query, State}; 53 53 use axum::Json; 54 - use lexica::app_bsky::actor::ProfileView; 55 - use lexica::app_bsky::graph::Relationship; 54 + use jacquard_api::app_bsky::actor::ProfileView; 55 + use jacquard_api::app_bsky::graph::Relationship; 56 56 use serde::{Deserialize, Serialize}; 57 57 58 58 #[derive(Debug, Serialize)] 59 59 pub struct SubjectRes { 60 - pub subject: ProfileView, 60 + pub subject: ProfileView<'static>, 61 61 #[serde(skip_serializing_if = "Option::is_none")] 62 62 pub cursor: Option<String>, 63 63 } ··· 180 180 181 181 #[derive(Debug, Serialize)] 182 182 pub struct GetKnownFollowersRes { 183 - pub subject: ProfileView, 184 - pub followers: Vec<ProfileView>, 183 + pub subject: ProfileView<'static>, 184 + pub followers: Vec<ProfileView<'static>>, 185 185 #[serde(skip_serializing_if = "Option::is_none")] 186 186 pub cursor: Option<String>, 187 187 } ··· 238 238 pub struct GetRelationshipsRes { 239 239 #[serde(skip_serializing_if = "Option::is_none")] 240 240 pub actor: Option<String>, 241 - pub relationships: Vec<Relationship>, 241 + pub relationships: Vec<Relationship<'static>>, 242 242 } 243 243 244 244 pub async fn get_relationships(
+3 -3
parakeet/src/xrpc/app_bsky/graph/search.rs
··· 3 3 use crate::GlobalState; 4 4 use axum::extract::{Query, State}; 5 5 use axum::Json; 6 - use lexica::app_bsky::actor::ProfileView; 6 + use jacquard_api::app_bsky::actor::ProfileView; 7 7 use serde::{Deserialize, Serialize}; 8 8 9 9 #[derive(Debug, Deserialize)] ··· 15 15 16 16 #[derive(Debug, Serialize)] 17 17 pub struct SearchActorsRes { 18 - pub actors: Vec<ProfileView>, 18 + pub actors: Vec<ProfileView<'static>>, 19 19 #[serde(skip_serializing_if = "Option::is_none")] 20 20 pub cursor: Option<String>, 21 21 } ··· 131 131 132 132 #[derive(Debug, Serialize)] 133 133 pub struct SearchStarterPacksRes { 134 - pub starter_packs: Vec<lexica::app_bsky::graph::StarterPackViewBasic>, 134 + pub starter_packs: Vec<jacquard_api::app_bsky::graph::StarterPackViewBasic<'static>>, 135 135 #[serde(skip_serializing_if = "Option::is_none")] 136 136 pub cursor: Option<String>, 137 137 }
+4 -4
parakeet/src/xrpc/app_bsky/graph/starter_packs.rs
··· 4 4 use crate::GlobalState; 5 5 use axum::extract::{Query, State}; 6 6 use axum::Json; 7 - use lexica::app_bsky::graph::{StarterPackView, StarterPackViewBasic}; 7 + use jacquard_api::app_bsky::graph::{StarterPackView, StarterPackViewBasic}; 8 8 use serde::{Deserialize, Serialize}; 9 9 10 10 #[derive(Debug, Serialize)] 11 11 #[serde(rename_all = "camelCase")] 12 12 pub struct StarterPacksRes { 13 - pub starter_packs: Vec<StarterPackViewBasic>, 13 + pub starter_packs: Vec<StarterPackViewBasic<'static>>, 14 14 #[serde(skip_serializing_if = "Option::is_none")] 15 15 pub cursor: Option<String>, 16 16 } ··· 98 98 #[derive(Debug, Serialize)] 99 99 #[serde(rename_all = "camelCase")] 100 100 pub struct GetStarterPackRes { 101 - pub starter_pack: StarterPackView, 101 + pub starter_pack: StarterPackView<'static>, 102 102 } 103 103 104 104 pub async fn get_starter_pack( ··· 122 122 #[derive(Debug, Serialize)] 123 123 #[serde(rename_all = "camelCase")] 124 124 pub struct GetStarterPacksRes { 125 - pub starter_packs: Vec<StarterPackViewBasic>, 125 + pub starter_packs: Vec<StarterPackViewBasic<'static>>, 126 126 } 127 127 128 128 #[derive(Debug, Deserialize)]
+2 -2
parakeet/src/xrpc/app_bsky/graph/suggestions.rs
··· 3 3 use crate::GlobalState; 4 4 use axum::extract::{Query, State}; 5 5 use axum::Json; 6 - use lexica::app_bsky::actor::ProfileView; 6 + use jacquard_api::app_bsky::actor::ProfileView; 7 7 use serde::{Deserialize, Serialize}; 8 8 9 9 #[derive(Debug, Deserialize)] ··· 14 14 15 15 #[derive(Debug, Serialize)] 16 16 pub struct GetSuggestionsRes { 17 - pub actors: Vec<ProfileView>, 17 + pub actors: Vec<ProfileView<'static>>, 18 18 #[serde(skip_serializing_if = "Option::is_none")] 19 19 pub cursor: Option<String>, 20 20 }
+3 -3
parakeet/src/xrpc/app_bsky/labeler.rs
··· 5 5 use axum::Json; 6 6 use axum_extra::extract::Query as ExtraQuery; 7 7 use itertools::Itertools as _; 8 - use lexica::app_bsky::labeler::{LabelerView, LabelerViewDetailed}; 8 + use jacquard_api::app_bsky::labeler::{LabelerView, LabelerViewDetailed}; 9 9 use serde::{Deserialize, Serialize}; 10 10 11 11 #[derive(Debug, Deserialize)] ··· 23 23 #[serde(tag = "$type")] 24 24 pub enum ResViewType { 25 25 #[serde(rename = "app.bsky.labeler.defs#labelerView")] 26 - View(LabelerView), 26 + View(LabelerView<'static>), 27 27 #[serde(rename = "app.bsky.labeler.defs#labelerViewDetailed")] 28 - ViewDetailed(LabelerViewDetailed), 28 + ViewDetailed(LabelerViewDetailed<'static>), 29 29 } 30 30 31 31 pub async fn get_services(
+69 -67
parakeet/src/xrpc/app_bsky/mod.rs
··· 1 1 use axum::routing::{get, post}; 2 2 use axum::Router; 3 + use jacquard_axum::IntoRouter; 4 + use jacquard_api::app_bsky; 3 5 4 6 mod actor; 5 7 mod ageassurance; ··· 40 42 pub mod thread_mutes; 41 43 } 42 44 43 - #[rustfmt::skip] 45 + /// Build the app.bsky routes using Jacquard's IntoRouter pattern 44 46 pub fn routes() -> Router<crate::GlobalState> { 45 47 Router::new() 46 - // .route("/app.bsky.actor.getPreferences", get(actor::get_preferences)) 47 - // .route("/app.bsky.actor.putPreferences", post(actor::put_preferences)) 48 - .route("/app.bsky.actor.getProfile", get(actor::get_profile)) 49 - .route("/app.bsky.actor.getProfiles", get(actor::get_profiles)) 50 - .route("/app.bsky.actor.searchActors", get(actor::search_actors)) 51 - .route("/app.bsky.actor.searchActorsTypeahead", get(actor::search_actors_typeahead)) 52 - .route("/app.bsky.actor.getSuggestions", get(actor::get_suggestions)) 48 + // Actor endpoints using IntoRouter 49 + .merge(app_bsky::actor::get_profile::GetProfileRequest::into_router(actor::get_profile)) 50 + .merge(app_bsky::actor::get_profiles::GetProfilesRequest::into_router(actor::get_profiles)) 51 + .merge(app_bsky::actor::search_actors::SearchActorsRequest::into_router(actor::search_actors)) 52 + .merge(app_bsky::actor::search_actors_typeahead::SearchActorsTypeaheadRequest::into_router(actor::search_actors_typeahead)) 53 + .merge(app_bsky::actor::get_suggestions::GetSuggestionsRequest::into_router(actor::get_suggestions)) 54 + 55 + // Age assurance endpoints - need to be converted to use ExtractXrpc 53 56 .route("/app.bsky.ageassurance.getConfig", get(ageassurance::get_config)) 54 57 .route("/app.bsky.ageassurance.getState", get(ageassurance::get_state)) 55 - .route("/app.bsky.bookmark.createBookmark", post(bookmark::create_bookmark)) 56 - .route("/app.bsky.bookmark.deleteBookmark", post(bookmark::delete_bookmark)) 57 - .route("/app.bsky.bookmark.getBookmarks", get(bookmark::get_bookmarks)) 58 - .route("/app.bsky.feed.getActorFeeds", get(feed::feedgen::get_actor_feeds)) 59 - .route("/app.bsky.feed.getActorLikes", get(feed::likes::get_actor_likes)) 60 - .route("/app.bsky.feed.getAuthorFeed", get(feed::get_timeline::get_author_feed)) 61 - .route("/app.bsky.feed.getFeed", get(feed::posts::get_feed)) 62 - .route("/app.bsky.feed.getFeedGenerator", get(feed::feedgen::get_feed_generator)) 63 - .route("/app.bsky.feed.getFeedGenerators", get(feed::feedgen::get_feed_generators)) 64 - .route("/app.bsky.feed.getLikes", get(feed::likes::get_likes)) 65 - .route("/app.bsky.feed.getListFeed", get(feed::posts::get_list_feed)) 66 - .route("/app.bsky.feed.getPostThread", get(feed::posts::get_post_thread)) 67 - .route("/app.bsky.feed.getPosts", get(feed::posts::get_posts)) 68 - .route("/app.bsky.feed.getQuotes", get(feed::posts::get_quotes)) 69 - .route("/app.bsky.feed.getRepostedBy", get(feed::posts::get_reposted_by)) 70 - .route("/app.bsky.feed.getSuggestedFeeds", get(feed::feedgen::get_suggested_feeds)) 71 - .route("/app.bsky.feed.getTimeline", get(feed::get_timeline::get_timeline)) 72 - .route("/app.bsky.feed.searchPosts", get(feed::search::search_posts)) 73 - .route("/app.bsky.graph.getActorStarterPacks", get(graph::starter_packs::get_actor_starter_packs)) 74 - .route("/app.bsky.graph.getBlocks", get(graph::relations::get_blocks)) 75 - .route("/app.bsky.graph.getFollowers", get(graph::relations::get_followers)) 76 - .route("/app.bsky.graph.getFollows", get(graph::relations::get_follows)) 77 - .route("/app.bsky.graph.getKnownFollowers", get(graph::relations::get_known_followers)) 58 + 59 + // Bookmark endpoints using IntoRouter 60 + .merge(app_bsky::bookmark::create_bookmark::CreateBookmarkRequest::into_router(bookmark::create_bookmark)) 61 + .merge(app_bsky::bookmark::delete_bookmark::DeleteBookmarkRequest::into_router(bookmark::delete_bookmark)) 62 + .merge(app_bsky::bookmark::get_bookmarks::GetBookmarksRequest::into_router(bookmark::get_bookmarks)) 63 + 64 + // Feed endpoints using IntoRouter 65 + .merge(app_bsky::feed::get_actor_feeds::GetActorFeedsRequest::into_router(feed::feedgen::get_actor_feeds)) 66 + .merge(app_bsky::feed::get_actor_likes::GetActorLikesRequest::into_router(feed::likes::get_actor_likes)) 67 + .merge(app_bsky::feed::get_author_feed::GetAuthorFeedRequest::into_router(feed::get_timeline::get_author_feed)) 68 + .merge(app_bsky::feed::get_feed::GetFeedRequest::into_router(feed::posts::get_feed)) 69 + .merge(app_bsky::feed::get_feed_generator::GetFeedGeneratorRequest::into_router(feed::feedgen::get_feed_generator)) 70 + .merge(app_bsky::feed::get_feed_generators::GetFeedGeneratorsRequest::into_router(feed::feedgen::get_feed_generators)) 71 + .merge(app_bsky::feed::get_likes::GetLikesRequest::into_router(feed::likes::get_likes)) 72 + .merge(app_bsky::feed::get_list_feed::GetListFeedRequest::into_router(feed::posts::get_list_feed)) 73 + .merge(app_bsky::feed::get_post_thread::GetPostThreadRequest::into_router(feed::posts::get_post_thread)) 74 + .merge(app_bsky::feed::get_posts::GetPostsRequest::into_router(feed::posts::get_posts)) 75 + .merge(app_bsky::feed::get_quotes::GetQuotesRequest::into_router(feed::posts::get_quotes)) 76 + .merge(app_bsky::feed::get_reposted_by::GetRepostedByRequest::into_router(feed::posts::get_reposted_by)) 77 + .merge(app_bsky::feed::get_suggested_feeds::GetSuggestedFeedsRequest::into_router(feed::feedgen::get_suggested_feeds)) 78 + .merge(app_bsky::feed::get_timeline::GetTimelineRequest::into_router(feed::get_timeline::get_timeline)) 79 + .merge(app_bsky::feed::search_posts::SearchPostsRequest::into_router(feed::search::search_posts)) 80 + 81 + // Graph endpoints using IntoRouter 82 + .merge(app_bsky::graph::get_followers::GetFollowersRequest::into_router(graph::relations::get_followers)) 83 + .merge(app_bsky::graph::get_follows::GetFollowsRequest::into_router(graph::relations::get_follows)) 84 + .merge(app_bsky::graph::get_known_followers::GetKnownFollowersRequest::into_router(graph::relations::get_known_followers)) 85 + .merge(app_bsky::graph::get_list::GetListRequest::into_router(graph::lists::get_list)) 86 + .merge(app_bsky::graph::get_lists::GetListsRequest::into_router(graph::lists::get_lists)) 87 + .merge(app_bsky::graph::get_mutes::GetMutesRequest::into_router(graph::mutes::get_mutes)) 88 + .merge(app_bsky::graph::get_relationships::GetRelationshipsRequest::into_router(graph::relations::get_relationships)) 89 + .merge(app_bsky::graph::get_starter_pack::GetStarterPackRequest::into_router(graph::starter_packs::get_starter_pack)) 90 + .merge(app_bsky::graph::get_starter_packs::GetStarterPacksRequest::into_router(graph::starter_packs::get_starter_packs)) 91 + .merge(app_bsky::graph::get_suggested_follows_by_actor::GetSuggestedFollowsByActorRequest::into_router(graph::suggestions::get_suggested_follows_by_actor)) 92 + .merge(app_bsky::graph::search_starter_packs::SearchStarterPacksRequest::into_router(graph::search::search_starter_packs)) 93 + 94 + // Custom endpoints not in jacquard yet - keep using old routing 78 95 .route("/app.bsky.graph.getList", get(graph::lists::get_list)) 79 - .route("/app.bsky.graph.getListBlocks", get(graph::lists::get_list_blocks)) 80 - .route("/app.bsky.graph.getListMutes", get(graph::lists::get_list_mutes)) 81 - .route("/app.bsky.graph.getLists", get(graph::lists::get_lists)) 82 - .route("/app.bsky.graph.getMutes", get(graph::mutes::get_mutes)) 83 - .route("/app.bsky.graph.getRelationships", get(graph::relations::get_relationships)) 84 - .route("/app.bsky.graph.getStarterPack", get(graph::starter_packs::get_starter_pack)) 85 - .route("/app.bsky.graph.getStarterPacks", get(graph::starter_packs::get_starter_packs)) 86 - .route("/app.bsky.graph.getSuggestedFollowsByActor", get(graph::suggestions::get_suggested_follows_by_actor)) 87 - .route("/app.bsky.graph.muteActor", post(graph::mutes::mute_actor)) 88 - .route("/app.bsky.graph.muteActorList", post(graph::mutes::mute_actor_list)) 89 - .route("/app.bsky.graph.muteThread", post(graph::thread_mutes::mute_thread)) 90 - .route("/app.bsky.graph.searchStarterPacks", get(graph::search::search_starter_packs)) 91 - .route("/app.bsky.graph.unmuteActor", post(graph::mutes::unmute_actor)) 92 - .route("/app.bsky.graph.unmuteActorList", post(graph::mutes::unmute_actor_list)) 93 - .route("/app.bsky.graph.unmuteThread", post(graph::thread_mutes::unmute_thread)) 94 - .route("/app.bsky.labeler.getServices", get(labeler::get_services)) 95 - .route("/app.bsky.unspecced.getTrendingTopics", get(unspecced::get_trending_topics)) 96 - .route("/app.bsky.unspecced.getConfig", get(unspecced::get_config)) 97 - .route("/app.bsky.unspecced.getSuggestedFeeds", get(unspecced::get_suggested_feeds)) 98 - .route("/app.bsky.unspecced.getSuggestedUsers", get(unspecced::get_suggested_users)) 99 - .route("/app.bsky.unspecced.getPopularFeedGenerators", get(unspecced::get_popular_feed_generators)) 100 - .route("/app.bsky.unspecced.getSuggestedStarterPacks", get(unspecced::get_suggested_starter_packs)) 101 - // TODO: app.bsky.notification.getPreferences 102 - .route("/app.bsky.notification.getUnreadCount", get(notification::get_unread_count)) 103 - // TODO: app.bsky.notification.listActivitySubscriptions 104 - .route("/app.bsky.notification.listNotifications", get(notification::list_notifications)) 105 - // TODO: app.bsky.notification.putActivitySubscriptions 106 - // TODO: app.bsky.notification.putPreferences 107 - // TODO: app.bsky.notification.putPreferencesV2 108 - .route("/app.bsky.notification.updateSeen", post(notification::update_seen)) 96 + // TODO: Implement getSuggestedDidYouFollows 97 + // .route("/app.bsky.graph.getSuggestedDidYouFollows", get(graph::suggestions::get_suggested_did_you_follows)) 98 + // TODO: Implement getThreadMutes 99 + // .route("/app.bsky.graph.getThreadMutes", get(graph::thread_mutes::get_thread_mutes)) 100 + 101 + // Labeler endpoints using IntoRouter 102 + .merge(app_bsky::labeler::get_services::GetServicesRequest::into_router(labeler::get_services)) 103 + 104 + // Notification endpoints using IntoRouter 105 + .merge(app_bsky::notification::list_notifications::ListNotificationsRequest::into_router(notification::list_notifications)) 106 + .merge(app_bsky::notification::get_unread_count::GetUnreadCountRequest::into_router(notification::get_unread_count)) 107 + .merge(app_bsky::notification::update_seen::UpdateSeenRequest::into_router(notification::update_seen)) 108 + .merge(app_bsky::notification::register_push::RegisterPushRequest::into_router(notification::register_push)) 109 + 110 + // Unspecced endpoints - need to be converted to use ExtractXrpc 109 111 .route("/app.bsky.unspecced.getPostThreadV2", get(unspecced::thread_v2::get_post_thread_v2)) 110 112 .route("/app.bsky.unspecced.getPostThreadOtherV2", get(unspecced::thread_v2::get_post_thread_other_v2)) 111 - } 112 - 113 - #[expect(dead_code)] 114 - async fn not_implemented() -> axum::http::StatusCode { 115 - axum::http::StatusCode::NOT_IMPLEMENTED 116 - } 113 + // TODO: Implement getSuggestionsSkeleton 114 + // .route("/app.bsky.unspecced.getSuggestionsSkeleton", get(unspecced::get_suggestions_skeleton)) 115 + .route("/app.bsky.unspecced.getSuggestedFeeds", get(unspecced::get_suggested_feeds)) 116 + // TODO: Implement getVendorAdvertisements 117 + // .route("/app.bsky.unspecced.getVendorAdvertisements", get(unspecced::handlers::get_vendor_advertisements)) 118 + }
+99 -189
parakeet/src/xrpc/app_bsky/notification.rs
··· 1 1 use crate::common::errors::{Error, XrpcResult}; 2 2 use crate::common::auth::{AtpAcceptLabelers, AtpAuth}; 3 3 use crate::GlobalState; 4 - use axum::extract::{Query, State}; 4 + use axum::extract::State; 5 + use axum::http::StatusCode; 5 6 use axum::Json; 7 + use jacquard_axum::ExtractXrpc; 8 + use jacquard_api::app_bsky::notification::{ 9 + list_notifications::{ListNotificationsRequest, ListNotificationsOutput, Notification}, 10 + update_seen::{UpdateSeenRequest, UpdateSeenResponse}, 11 + register_push::{RegisterPushRequest, RegisterPushResponse}, 12 + get_unread_count::{GetUnreadCountRequest, GetUnreadCountResponse, GetUnreadCountOutput}, 13 + }; 14 + use jacquard_api::app_bsky::actor::ProfileView; 15 + use jacquard_common::IntoStatic; 6 16 use chrono::{DateTime, Utc}; 7 17 use serde::{Deserialize, Serialize}; 8 - 9 - #[derive(Debug, Deserialize)] 10 - pub struct ListNotificationsQuery { 11 - #[serde(default)] 12 - pub limit: Option<usize>, 13 - #[serde(default)] 14 - pub cursor: Option<String>, 15 - #[serde(default)] 16 - #[expect(dead_code, reason = "seen_at parameter accepted for API compatibility but not yet used for filtering")] 17 - pub seen_at: Option<DateTime<Utc>>, 18 - } 19 - 20 - #[derive(Debug, Serialize)] 21 - pub struct ListNotificationsResponse { 22 - pub notifications: Vec<Notification>, 23 - #[serde(skip_serializing_if = "Option::is_none")] 24 - pub cursor: Option<String>, 25 - #[serde(rename = "seenAt", skip_serializing_if = "Option::is_none")] 26 - pub seen_at: Option<String>, 27 - pub priority: bool, 28 - } 29 - 30 - #[derive(Debug, Serialize)] 31 - pub struct Notification { 32 - pub uri: String, 33 - pub cid: String, 34 - pub author: NotificationAuthor, 35 - pub reason: String, 36 - #[serde(rename = "reasonSubject", skip_serializing_if = "Option::is_none")] 37 - pub reason_subject: Option<String>, 38 - pub record: serde_json::Value, 39 - #[serde(rename = "isRead")] 40 - pub is_read: bool, 41 - #[serde(rename = "indexedAt")] 42 - pub indexed_at: String, 43 - #[serde(skip_serializing_if = "Option::is_none")] 44 - pub labels: Option<Vec<serde_json::Value>>, 45 - } 46 - 47 - #[derive(Debug, Serialize)] 48 - pub struct NotificationAuthor { 49 - pub did: String, 50 - pub handle: String, 51 - #[serde(rename = "displayName", skip_serializing_if = "Option::is_none")] 52 - pub display_name: Option<String>, 53 - #[serde(skip_serializing_if = "Option::is_none")] 54 - pub avatar: Option<String>, 55 - #[serde(skip_serializing_if = "Option::is_none")] 56 - pub associated: Option<serde_json::Value>, 57 - #[serde(skip_serializing_if = "Option::is_none")] 58 - pub viewer: Option<serde_json::Value>, 59 - #[serde(skip_serializing_if = "Option::is_none")] 60 - pub labels: Option<Vec<serde_json::Value>>, 61 - #[serde(skip_serializing_if = "Option::is_none")] 62 - pub description: Option<String>, 63 - #[serde(rename = "createdAt", skip_serializing_if = "Option::is_none")] 64 - pub created_at: Option<String>, 65 - #[serde(rename = "indexedAt", skip_serializing_if = "Option::is_none")] 66 - pub indexed_at: Option<String>, 67 - } 68 18 69 19 pub async fn list_notifications( 70 20 State(state): State<GlobalState>, 21 + ExtractXrpc(req): ExtractXrpc<ListNotificationsRequest>, 71 22 AtpAcceptLabelers(labelers): AtpAcceptLabelers, 72 23 maybe_auth: Option<AtpAuth>, 73 - Query(query): Query<ListNotificationsQuery>, 74 - ) -> XrpcResult<Json<ListNotificationsResponse>> { 24 + ) -> Result<Json<ListNotificationsOutput<'static>>, StatusCode> { 75 25 // Check if user is authenticated 76 - let auth = maybe_auth.ok_or_else(|| { 77 - Error::new( 78 - axum::http::StatusCode::UNAUTHORIZED, 79 - "AuthenticationRequired", 80 - Some("Authentication required".to_owned()), 81 - ) 82 - })?; 26 + let auth = maybe_auth.ok_or(StatusCode::UNAUTHORIZED)?; 83 27 84 28 let start = std::time::Instant::now(); 85 29 86 - let limit = query.limit.unwrap_or(50).min(100) + 1; // +1 to check for more results 30 + let limit = req.limit.unwrap_or(50).min(100) + 1; // +1 to check for more results 87 31 88 32 // Parse cursor if provided (notification ID) 89 - let cursor_id = query 33 + let cursor_id = req 90 34 .cursor 91 35 .as_ref() 92 36 .filter(|c| !c.is_empty()) ··· 94 38 95 39 // Get database connection 96 40 let conn_start = std::time::Instant::now(); 97 - let mut conn = state.pool.get().await.map_err(|e| { 98 - Error::new( 99 - axum::http::StatusCode::INTERNAL_SERVER_ERROR, 100 - "DatabaseError", 101 - Some(format!("Failed to get database connection: {}", e)), 102 - ) 103 - })?; 41 + let mut conn = state.pool.get().await.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 104 42 tracing::info!(" → Connection pool: {:.1}ms", conn_start.elapsed().as_secs_f64() * 1000.0); 105 43 106 44 // Resolve DID to actor_id using IdCache (fast path) or database (slow path) ··· 121 59 .optional() 122 60 .map_err(|e| { 123 61 Error::new( 124 - axum::http::StatusCode::INTERNAL_SERVER_ERROR, 62 + StatusCode::INTERNAL_SERVER_ERROR, 125 63 "DatabaseError", 126 64 Some(format!("Failed to resolve actor: {}", e)), 127 65 ) ··· 129 67 130 68 let id = id.ok_or_else(|| { 131 69 Error::new( 132 - axum::http::StatusCode::NOT_FOUND, 133 - "ActorNotFound", 134 - Some("Actor not found".to_owned()), 70 + StatusCode::NOT_FOUND, 71 + "NotFound", 72 + Some("Actor not found".to_string()), 135 73 ) 136 74 })?; 137 75 ··· 150 88 let mut db_notifications = 151 89 state.notification_entity.list_notifications_raw(actor_id, cursor_id, limit as i64) 152 90 .await 153 - .map_err(|e| { 154 - Error::new( 155 - axum::http::StatusCode::INTERNAL_SERVER_ERROR, 156 - "DatabaseError", 157 - Some(format!("Failed to fetch notifications: {}", e)), 158 - ) 159 - })?; 91 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 160 92 tracing::info!(" → Fetch notifications query: {:.1}ms ({} results)", notifs_start.elapsed().as_secs_f64() * 1000.0, db_notifications.len()); 161 93 162 94 // Check if there are more results 163 - let has_more = db_notifications.len() >= limit; 95 + let has_more = db_notifications.len() >= limit as usize; 164 96 let next_cursor_id = if has_more { 165 97 db_notifications.pop().map(|n| n.id) 166 98 } else { ··· 171 103 let seen_at_start = std::time::Instant::now(); 172 104 let notification_state = state.notification_entity.get_notification_state(actor_id) 173 105 .await 174 - .map_err(|e| { 175 - Error::new( 176 - axum::http::StatusCode::INTERNAL_SERVER_ERROR, 177 - "DatabaseError", 178 - Some(format!("Failed to fetch notification state: {}", e)), 179 - ) 180 - })?; 106 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 181 107 tracing::info!(" → Get notification state: {:.1}ms", seen_at_start.elapsed().as_secs_f64() * 1000.0); 182 108 183 109 let seen_at = notification_state.and_then(|s| s.seen_at); ··· 196 122 let actor_id_vec: Vec<i32> = actor_ids_to_resolve.into_iter().collect(); 197 123 let profiles = state.profile_entity.get_profiles_by_ids(&actor_id_vec) 198 124 .await 199 - .map_err(|_| { 200 - Error::new( 201 - axum::http::StatusCode::INTERNAL_SERVER_ERROR, 202 - "InternalServerError".to_string(), 203 - Some("Database error resolving actor DIDs".to_string()), 204 - ) 205 - })?; 125 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 206 126 let actor_did_map: std::collections::HashMap<i32, String> = profiles 207 127 .into_iter() 208 128 .map(|actor| (actor.id, actor.did)) ··· 222 142 .await; 223 143 224 144 // Convert Vec to HashMap for compatibility with existing code 225 - let profiles_map: std::collections::HashMap<String, lexica::app_bsky::actor::ProfileViewDetailed> = 145 + let profiles_map: std::collections::HashMap<String, jacquard_api::app_bsky::actor::ProfileViewDetailed> = 226 146 profiles_vec.into_iter() 227 - .map(|profile| (profile.did.clone(), profile)) 147 + .map(|profile| (profile.did.as_str().to_string(), profile)) 228 148 .collect(); 229 149 tracing::info!(" → Hydrate profiles: {:.1}ms ({} profiles)", profile_start.elapsed().as_secs_f64() * 1000.0, profiles_map.len()); 230 150 ··· 278 198 // Use ProfileEntity for additional actors 279 199 let additional_profiles = state.profile_entity.get_profiles_by_ids(&actor_id_vec) 280 200 .await 281 - .map_err(|_| { 282 - Error::new( 283 - axum::http::StatusCode::INTERNAL_SERVER_ERROR, 284 - "InternalServerError".to_string(), 285 - Some("Database error resolving parent/root actor DIDs".to_string()), 286 - ) 287 - })?; 201 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 288 202 let additional_dids: std::collections::HashMap<i32, String> = additional_profiles 289 203 .into_iter() 290 204 .map(|actor| (actor.id, actor.did)) ··· 313 227 314 228 let profile = profiles_map.get(author_did); 315 229 316 - let author = if let Some(profile_view) = profile { 317 - NotificationAuthor { 318 - did: profile_view.did.clone(), 319 - handle: profile_view.handle.clone(), 320 - display_name: profile_view.display_name.clone(), 321 - avatar: profile_view.avatar.clone(), 322 - associated: profile_view 323 - .associated 324 - .as_ref() 325 - .and_then(|a| serde_json::to_value(a).ok()), 326 - viewer: profile_view 327 - .viewer 328 - .as_ref() 329 - .and_then(|v| serde_json::to_value(v).ok()), 330 - labels: Some( 331 - profile_view 332 - .labels 333 - .iter() 334 - .filter_map(|l| serde_json::to_value(l).ok()) 335 - .collect(), 336 - ), 337 - description: profile_view.description.clone(), 338 - created_at: Some(profile_view.created_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)), 339 - indexed_at: Some(profile_view.indexed_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true)), 340 - } 230 + let author = if let Some(profile_detailed) = profile { 231 + // Convert ProfileViewDetailed to ProfileView 232 + ProfileView { 233 + did: profile_detailed.did.clone(), 234 + handle: profile_detailed.handle.clone(), 235 + display_name: profile_detailed.display_name.clone(), 236 + avatar: profile_detailed.avatar.clone(), 237 + associated: profile_detailed.associated.clone(), 238 + viewer: profile_detailed.viewer.clone(), 239 + labels: profile_detailed.labels.clone(), 240 + created_at: profile_detailed.created_at.clone(), 241 + description: profile_detailed.description.clone(), 242 + indexed_at: profile_detailed.indexed_at.clone(), 243 + verification: None, 244 + debug: None, 245 + pronouns: None, 246 + status: None, 247 + extra_data: None, 248 + }.into_static() 341 249 } else { 342 - // Fallback if profile not found 343 - NotificationAuthor { 344 - did: author_did.clone(), 345 - handle: "handle.invalid".to_string(), 346 - display_name: None, 347 - avatar: None, 348 - associated: None, 349 - viewer: None, 350 - labels: None, 351 - description: None, 352 - created_at: None, 353 - indexed_at: None, 354 - } 250 + // Fallback if profile not found - build a minimal ProfileView 251 + ProfileView::new() 252 + .did(jacquard_common::types::string::Did::new(author_did).unwrap()) 253 + .handle(jacquard_common::types::string::Handle::new("handle.invalid").unwrap()) 254 + .build() 255 + .into_static() 355 256 }; 356 257 357 258 // Reconstruct the URI from normalized components ··· 377 278 .unwrap_or(false); 378 279 379 280 notifications.push(Notification { 380 - uri, 281 + uri: jacquard_common::types::string::AtUri::new(&uri).unwrap().into_static(), 381 282 // Use the real CID from database (already stored for all record types) 382 - cid: parakeet_db::cid_util::digest_to_record_cid_string(&notif.record_cid) 383 - .unwrap_or_else(|| String::from("bafyrei_invalid_cid")), 283 + cid: jacquard_common::types::string::Cid::new( 284 + parakeet_db::cid_util::digest_to_record_cid_string(&notif.record_cid) 285 + .unwrap_or_else(|| String::from("bafyrei_invalid_cid")) 286 + .as_bytes() 287 + ).unwrap().into_static(), 384 288 author, 385 - reason: notif.reason.to_string(), 386 - reason_subject, 387 - record, 289 + reason: notif.reason.to_string().into(), 290 + reason_subject: reason_subject.map(|s| jacquard_common::types::string::AtUri::new(&s).unwrap().into_static()), 291 + // Convert serde_json::Value to jacquard Data by serializing and deserializing 292 + record: serde_json::from_value::<jacquard_common::types::value::Data>(record) 293 + .unwrap_or(jacquard_common::types::value::Data::Null), 388 294 is_read, 389 - indexed_at: notif.indexed_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true), 295 + indexed_at: jacquard_common::types::string::Datetime::new( 296 + &notif.indexed_at.to_rfc3339_opts(chrono::SecondsFormat::Millis, true) 297 + ).unwrap(), 390 298 labels: Some(Vec::new()), 391 - }); 299 + extra_data: None, 300 + }.into_static()); 392 301 } 393 302 tracing::info!(" → Build responses: {:.1}ms (record_fetch: {:.1}ms)", build_start.elapsed().as_secs_f64() * 1000.0, record_fetch_time); 394 303 ··· 400 309 401 310 tracing::info!("→ listNotifications: {:.1}ms total ({} notifications)", start.elapsed().as_secs_f64() * 1000.0, notifications.len()); 402 311 403 - Ok(Json(ListNotificationsResponse { 312 + Ok(Json(ListNotificationsOutput { 404 313 notifications, 405 - cursor, 406 - seen_at: seen_at_response, 407 - priority: false, 408 - })) 409 - } 410 - 411 - #[derive(Debug, Deserialize)] 412 - pub struct GetUnreadCountQuery { 413 - #[serde(default)] 414 - #[expect(dead_code, reason = "seen_at parameter accepted for API compatibility but not yet used for calculation")] 415 - pub seen_at: Option<DateTime<Utc>>, 314 + cursor: cursor.map(|c| jacquard_common::types::string::CowStr::from(c)), 315 + seen_at: seen_at.map(|dt| jacquard_common::types::datetime::Datetime::from(dt.fixed_offset())), 316 + priority: Some(false), 317 + extra_data: None, 318 + }.into_static())) 416 319 } 417 320 418 - #[derive(Debug, Serialize)] 419 - pub struct GetUnreadCountResponse { 420 - pub count: i64, 421 - } 321 + // GetUnreadCountResponse is imported from jacquard_api 422 322 423 323 pub async fn get_unread_count( 424 324 State(state): State<GlobalState>, 425 325 maybe_auth: Option<AtpAuth>, 426 - Query(_query): Query<GetUnreadCountQuery>, 427 - ) -> XrpcResult<Json<GetUnreadCountResponse>> { 326 + ExtractXrpc(_query): ExtractXrpc<GetUnreadCountRequest>, 327 + ) -> XrpcResult<Json<GetUnreadCountOutput<'static>>> { 428 328 // Check if user is authenticated 429 329 let auth = maybe_auth.ok_or_else(|| { 430 330 Error::new( ··· 437 337 // Get database connection 438 338 let mut conn = state.pool.get().await.map_err(|e| { 439 339 Error::new( 440 - axum::http::StatusCode::INTERNAL_SERVER_ERROR, 340 + StatusCode::INTERNAL_SERVER_ERROR, 441 341 "DatabaseError", 442 342 Some(format!("Failed to get database connection: {}", e)), 443 343 ) ··· 460 360 .optional() 461 361 .map_err(|e| { 462 362 Error::new( 463 - axum::http::StatusCode::INTERNAL_SERVER_ERROR, 363 + StatusCode::INTERNAL_SERVER_ERROR, 464 364 "DatabaseError", 465 365 Some(format!("Failed to resolve actor: {}", e)), 466 366 ) ··· 468 368 469 369 let id = id.ok_or_else(|| { 470 370 Error::new( 471 - axum::http::StatusCode::NOT_FOUND, 472 - "ActorNotFound", 473 - Some("Actor not found".to_owned()), 371 + StatusCode::NOT_FOUND, 372 + "NotFound", 373 + Some("Actor not found".to_string()), 474 374 ) 475 375 })?; 476 376 ··· 494 394 ) 495 395 })?; 496 396 497 - Ok(Json(GetUnreadCountResponse { count })) 397 + Ok(Json(GetUnreadCountOutput { count, extra_data: None }.into_static())) 498 398 } 499 399 500 400 #[derive(Debug, Deserialize)] ··· 520 420 // Get database connection 521 421 let mut conn = state.pool.get().await.map_err(|e| { 522 422 Error::new( 523 - axum::http::StatusCode::INTERNAL_SERVER_ERROR, 423 + StatusCode::INTERNAL_SERVER_ERROR, 524 424 "DatabaseError", 525 425 Some(format!("Failed to get database connection: {}", e)), 526 426 ) ··· 543 443 .optional() 544 444 .map_err(|e| { 545 445 Error::new( 546 - axum::http::StatusCode::INTERNAL_SERVER_ERROR, 446 + StatusCode::INTERNAL_SERVER_ERROR, 547 447 "DatabaseError", 548 448 Some(format!("Failed to resolve actor: {}", e)), 549 449 ) ··· 551 451 552 452 let id = id.ok_or_else(|| { 553 453 Error::new( 554 - axum::http::StatusCode::NOT_FOUND, 555 - "ActorNotFound", 556 - Some("Actor not found".to_owned()), 454 + StatusCode::NOT_FOUND, 455 + "NotFound", 456 + Some("Actor not found".to_string()), 557 457 ) 558 458 })?; 559 459 ··· 704 604 "uri": fallback_uri, 705 605 }) 706 606 } 607 + 608 + pub async fn register_push( 609 + State(_state): State<GlobalState>, 610 + _auth: AtpAuth, 611 + ExtractXrpc(_req): ExtractXrpc<RegisterPushRequest>, 612 + ) -> Result<Json<RegisterPushResponse>, StatusCode> { 613 + // Push notification registration not implemented yet 614 + // This would typically store device tokens for push notifications 615 + Ok(Json(RegisterPushResponse)) 616 + }
+19 -15
parakeet/src/xrpc/app_bsky/unspecced/handlers.rs
··· 4 4 use crate::GlobalState; 5 5 use axum::extract::{Query, State}; 6 6 use axum::Json; 7 - use lexica::app_bsky::actor::ProfileView; 8 - use lexica::app_bsky::feed::GeneratorView; 9 - use lexica::app_bsky::graph::StarterPackViewBasic; 7 + use jacquard_api::app_bsky::actor::ProfileView; 8 + use jacquard_api::app_bsky::feed::GeneratorView; 9 + use jacquard_api::app_bsky::graph::StarterPackViewBasic; 10 + use jacquard_common::IntoStatic; 10 11 use parakeet_db::models::ProfileStats; 11 12 use serde::{Deserialize, Serialize}; 12 13 ··· 120 121 121 122 #[derive(Debug, Serialize)] 122 123 pub struct GetSuggestedFeedsResponse { 123 - pub feeds: Vec<GeneratorView>, 124 + pub feeds: Vec<GeneratorView<'static>>, 124 125 } 125 126 126 127 /// Handles the app.bsky.unspecced.getSuggestedFeeds endpoint ··· 200 201 } 201 202 202 203 #[derive(Debug, Serialize)] 203 - pub struct GetSuggestedUsersResponse { 204 - pub actors: Vec<ProfileView>, 204 + pub struct GetSuggestedUsersResponse<'a> { 205 + pub actors: Vec<ProfileView<'a>>, 205 206 } 206 207 207 208 /// Handles the app.bsky.unspecced.getSuggestedUsers endpoint ··· 213 214 AtpAcceptLabelers(labelers): AtpAcceptLabelers, 214 215 maybe_auth: Option<AtpAuth>, 215 216 Query(query): Query<GetSuggestedUsersQuery>, 216 - ) -> XrpcResult<Json<GetSuggestedUsersResponse>> { 217 + ) -> XrpcResult<Json<GetSuggestedUsersResponse<'static>>> { 217 218 let limit = query.limit.unwrap_or(25).clamp(1, 100) as usize; 218 219 // Category parameter is accepted but ignored for now 219 220 ··· 307 308 // Create map for order preservation 308 309 let mut profiles_map: HashMap<String, ProfileView> = profiles_vec 309 310 .into_iter() 310 - .map(|profile| (profile.did.clone(), profile)) 311 + .map(|profile| (profile.did.as_str().to_string(), profile)) 311 312 .collect(); 312 313 313 314 // Maintain pagination order ··· 332 333 pub struct GetPopularFeedGeneratorsResponse { 333 334 #[serde(skip_serializing_if = "Option::is_none")] 334 335 pub cursor: Option<String>, 335 - pub feeds: Vec<GeneratorView>, 336 + pub feeds: Vec<GeneratorView<'static>>, 336 337 } 337 338 338 339 /// Handles the app.bsky.unspecced.getPopularFeedGenerators endpoint ··· 487 488 488 489 #[derive(Debug, Serialize)] 489 490 #[serde(rename_all = "camelCase")] 490 - pub struct GetSuggestedStarterPacksResponse { 491 - pub starter_packs: Vec<StarterPackViewBasic>, 491 + pub struct GetSuggestedStarterPacksResponse<'a> { 492 + pub starter_packs: Vec<StarterPackViewBasic<'a>>, 492 493 } 493 494 494 495 /// Handles the app.bsky.unspecced.getSuggestedStarterPacks endpoint ··· 500 501 AtpAcceptLabelers(labelers): AtpAcceptLabelers, 501 502 maybe_auth: Option<AtpAuth>, 502 503 Query(query): Query<GetSuggestedStarterPacksQuery>, 503 - ) -> XrpcResult<Json<GetSuggestedStarterPacksResponse>> { 504 + ) -> XrpcResult<Json<GetSuggestedStarterPacksResponse<'static>>> { 504 505 let limit = query.limit.unwrap_or(25).clamp(1, 100) as usize; 505 506 506 507 // Compute rankings (no caching - recalculated each time) ··· 577 578 for uri in page_uris { 578 579 if let Ok(Some(pack)) = state.starterpack_entity.get_by_uri(&uri, maybe_did.as_deref()).await { 579 580 // Convert StarterPackView to StarterPackViewBasic 580 - let basic = lexica::app_bsky::graph::StarterPackViewBasic { 581 + // Note: StarterPackView doesn't have list_item_count directly, get it from list if available 582 + let list_item_count = pack.list.as_ref().and_then(|l| l.list_item_count); 583 + let basic = jacquard_api::app_bsky::graph::StarterPackViewBasic { 581 584 uri: pack.uri, 582 585 cid: pack.cid, 583 586 record: pack.record, 584 587 creator: pack.creator, 585 - list_item_count: pack.list_item_count, 588 + list_item_count, 586 589 joined_week_count: pack.joined_week_count, 587 590 joined_all_time_count: pack.joined_all_time_count, 588 591 labels: pack.labels, 589 592 indexed_at: pack.indexed_at, 590 - }; 593 + extra_data: None, 594 + }.into_static(); 591 595 starter_packs.push(basic); 592 596 } 593 597 }
+11 -8
parakeet/src/xrpc/app_bsky/unspecced/thread_v2/models.rs
··· 1 - use lexica::app_bsky::feed::{PostView, ThreadgateView}; 1 + use jacquard_api::app_bsky::feed::{PostView, ThreadgateView}; 2 + use jacquard_api::app_bsky::unspecced::get_post_thread_v2::{ThreadItem, ThreadItemValue}; 2 3 use serde::{Deserialize, Serialize}; 3 4 4 - // Re-export the correct types from lexica 5 - pub use lexica::app_bsky::unspecced::{ThreadItemPost, ThreadV2Item, ThreadV2ItemType}; 5 + // Use jacquard types - renaming for compatibility 6 + pub type ThreadV2Item<'a> = ThreadItem<'a>; 7 + pub type ThreadItemPost<'a> = PostView<'a>; 8 + pub type ThreadV2ItemType<'a> = ThreadItemValue<'a>; 6 9 7 10 /// Post thread sorting options 8 11 #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize, Default)] ··· 20 23 #[derive(Debug, Serialize, Deserialize)] 21 24 #[serde(rename_all = "camelCase")] 22 25 pub struct GetPostThreadV2Res { 23 - pub thread: Vec<ThreadV2Item>, 26 + pub thread: Vec<ThreadV2Item<'static>>, 24 27 #[serde(skip_serializing_if = "Option::is_none")] 25 - pub threadgate: Option<ThreadgateView>, 28 + pub threadgate: Option<ThreadgateView<'static>>, 26 29 pub has_other_replies: bool, 27 30 } 28 31 ··· 30 33 #[derive(Debug, Serialize, Deserialize)] 31 34 #[serde(rename_all = "camelCase")] 32 35 pub struct GetPostThreadOtherV2Res { 33 - pub thread: Vec<ThreadV2Item>, 36 + pub thread: Vec<ThreadV2Item<'static>>, 34 37 } 35 38 36 39 /// Represents a post with its hydrated view and parent/child relationships 37 40 #[expect(dead_code)] 38 41 #[derive(Debug)] 39 - pub struct ThreadNode { 42 + pub struct ThreadNode<'a> { 40 43 pub uri: String, 41 - pub post_view: Option<PostView>, 44 + pub post_view: Option<PostView<'a>>, 42 45 pub parent_uri: Option<String>, 43 46 pub depth: i32, 44 47 pub children: Vec<String>,
+29 -15
parakeet/src/xrpc/app_bsky/unspecced/thread_v2/other_replies.rs
··· 14 14 use super::sorting::{sort_other_replies, sort_thread_items}; 15 15 16 16 /// Convert a PostView into the appropriate ThreadV2ItemType based on viewer state and labels 17 - fn post_to_thread_item_type(post: lexica::app_bsky::feed::PostView, is_authenticated: bool) -> ThreadV2ItemType { 17 + fn post_to_thread_item_type(post: jacquard_api::app_bsky::feed::PostView, is_authenticated: bool) -> ThreadV2ItemType { 18 18 // Check if author is blocked by viewer or viewer is blocked by author 19 19 if let Some(viewer) = &post.author.viewer { 20 - if viewer.blocked_by || viewer.blocking.is_some() { 21 - return ThreadV2ItemType::Blocked { 22 - author: Box::new(lexica::app_bsky::feed::BlockedAuthor { 23 - did: post.author.did.clone(), 24 - viewer: Some(viewer.clone()), 25 - }), 26 - }; 20 + if viewer.blocked_by.unwrap_or(false) || viewer.blocking.is_some() { 21 + return ThreadV2ItemType::ThreadItemBlocked(Box::new( 22 + jacquard_api::app_bsky::unspecced::ThreadItemBlocked { 23 + author: jacquard_api::app_bsky::feed::BlockedAuthor { 24 + did: post.author.did.clone(), 25 + viewer: Some(viewer.clone()), 26 + extra_data: None, 27 + }, 28 + extra_data: None, 29 + } 30 + )); 27 31 } 28 32 } 29 33 ··· 31 35 if !is_authenticated { 32 36 let has_no_unauth_label = post.author.labels.iter().any(|label| label.val == "!no-unauthenticated"); 33 37 if has_no_unauth_label { 34 - return ThreadV2ItemType::NoUnauthenticated {}; 38 + return ThreadV2ItemType::ThreadItemNoUnauthenticated(Box::new( 39 + jacquard_api::app_bsky::unspecced::ThreadItemNoUnauthenticated { extra_data: None } 40 + )); 35 41 } 36 42 } 37 43 38 44 // Return normal post 39 - ThreadV2ItemType::Post(Box::new(ThreadItemPost { 45 + ThreadV2ItemType::ThreadItemPost(Box::new(ThreadItemPost { 40 46 post, 41 47 more_parents: false, 42 48 more_replies: 0, ··· 156 162 for (reply_uri, _) in direct_replies { 157 163 if let Some(post_view) = replies_hydrated.get(&reply_uri) { 158 164 thread_items.push(ThreadV2Item { 159 - uri: reply_uri.clone(), 165 + uri: jacquard_common::types::aturi::AtUri::new(&reply_uri).unwrap(), 160 166 depth: 1, // All direct replies have depth 1 161 167 value: post_to_thread_item_type(post_view.clone(), is_authenticated), 168 + extra_data: None, 162 169 }); 163 170 } else { 164 171 // Post not found or couldn't be hydrated 165 172 thread_items.push(ThreadV2Item { 166 - uri: reply_uri.clone(), 173 + uri: jacquard_common::types::aturi::AtUri::new(&reply_uri).unwrap(), 167 174 depth: 1, 168 - value: ThreadV2ItemType::NotFound {}, 175 + value: ThreadV2ItemType::ThreadItemNotFound(Box::new( 176 + jacquard_api::app_bsky::unspecced::ThreadItemNotFound { extra_data: None } 177 + )), 178 + extra_data: None, 169 179 }); 170 180 } 171 181 } ··· 175 185 sort_thread_items( 176 186 &mut thread_items, 177 187 |item| &item.uri, 178 - |item| item.depth, 188 + |item| item.depth as i32, 179 189 |item| match &item.value { 180 - ThreadV2ItemType::Post(post) => Some(post.post.indexed_at), 190 + ThreadV2ItemType::ThreadItemPost(post) => { 191 + // Convert jacquard Datetime to chrono DateTime 192 + chrono::DateTime::parse_from_rfc3339(post.post.indexed_at.as_str()).ok() 193 + .map(|dt| dt.with_timezone(&chrono::Utc)) 194 + }, 181 195 _ => None, 182 196 }, 183 197 );
+10 -4
parakeet/src/xrpc/app_bsky/unspecced/thread_v2/post_thread.rs
··· 57 57 // Get the threadgate if available 58 58 let threadgate = anchor_post.threadgate.clone(); 59 59 60 + // Clone Arcs to extend lifetimes 61 + let post_entity = state.post_entity.clone(); 62 + let profile_entity = state.profile_entity.clone(); 63 + let id_cache = state.id_cache.clone(); 64 + let pool = state.pool.clone(); 65 + 60 66 // Create a thread builder 61 67 let builder = ThreadBuilder { 62 - post_entity: &state.post_entity, 63 - profile_entity: &state.profile_entity, 68 + post_entity: &post_entity, 69 + profile_entity: &profile_entity, 64 70 // post_cache: &state.post_cache, // TODO: Fix 65 71 anchor_uri: uri, 66 72 anchor_post, ··· 71 77 sort: Some(query.sort), 72 78 prioritize_followed_users: Some(query.prioritize_followed_users), 73 79 is_authenticated, 74 - id_cache: &state.id_cache, 75 - pool: &state.pool, 80 + id_cache: &id_cache, 81 + pool: &pool, 76 82 }; 77 83 78 84 // Build the thread
+1 -1
parakeet/src/xrpc/app_bsky/unspecced/thread_v2/sorting.rs
··· 1 1 use chrono::{DateTime, Utc}; 2 - use lexica::app_bsky::feed::PostView; 2 + use jacquard_api::app_bsky::feed::PostView; 3 3 use std::cmp::Ordering; 4 4 use std::collections::HashMap; 5 5
+105 -36
parakeet/src/xrpc/app_bsky/unspecced/thread_v2/thread_builder.rs
··· 1 1 use diesel_async::pooled_connection::deadpool::Pool; 2 2 use diesel_async::AsyncPgConnection; 3 - use lexica::app_bsky::feed::{PostView, ThreadgateView}; 3 + use jacquard_api::app_bsky::feed::{PostView, ThreadgateView}; 4 4 use std::collections::HashMap; 5 5 use std::sync::Arc; 6 6 ··· 13 13 pub post_entity: &'a crate::entities::PostEntity, 14 14 pub profile_entity: &'a crate::entities::ProfileEntity, 15 15 pub anchor_uri: String, 16 - pub anchor_post: PostView, 17 - pub threadgate: Option<ThreadgateView>, 16 + pub anchor_post: PostView<'static>, 17 + pub threadgate: Option<ThreadgateView<'static>>, 18 18 pub above: bool, 19 19 pub below: i32, 20 20 pub branching_factor: i32, ··· 27 27 28 28 impl ThreadBuilder<'_> { 29 29 /// Convert a PostView into the appropriate ThreadV2ItemType based on viewer state and labels 30 - fn post_to_thread_item_type(&self, post: PostView, op_thread: bool) -> ThreadV2ItemType { 30 + fn post_to_thread_item_type(&self, post: PostView<'static>, op_thread: bool) -> ThreadV2ItemType<'static> { 31 31 // Check if author is blocked by viewer or viewer is blocked by author 32 32 if let Some(viewer) = &post.author.viewer { 33 33 if viewer.blocked_by || viewer.blocking.is_some() { 34 34 return ThreadV2ItemType::Blocked { 35 - author: Box::new(lexica::app_bsky::feed::BlockedAuthor { 35 + author: Box::new(jacquard_api::app_bsky::feed::BlockedAuthor { 36 36 did: post.author.did.clone(), 37 37 viewer: Some(viewer.clone()), 38 + extra_data: None, 38 39 }), 39 40 }; 40 41 } ··· 77 78 78 79 // Step 2: Add the anchor post at depth 0 79 80 thread_items.push(ThreadV2Item { 80 - uri: self.anchor_post.uri.clone(), 81 + uri: jacquard_common::types::aturi::AtUri::new(&self.anchor_post.uri).unwrap(), 81 82 depth: 0, 82 83 value: self.post_to_thread_item_type(self.anchor_post.clone(), true), 84 + extra_data: None, 83 85 }); 84 86 85 87 // Step 3: Add child posts if requested (below > 0) ··· 102 104 async fn add_parent_posts( 103 105 &self, 104 106 conn: &mut diesel_async::AsyncPgConnection, 105 - thread_items: &mut Vec<ThreadV2Item>, 107 + thread_items: &mut Vec<ThreadV2Item<'_>>, 106 108 ) -> crate::common::errors::XrpcResult<()> { 107 109 // Use actor_id-based query with IdCache 108 110 // NO FALLBACK - we require IdCache to be populated (should always be from hydrate_post) 109 111 let db_start = std::time::Instant::now(); 110 112 111 113 // EARLY EXIT 112 - let has_parent = self.anchor_post.record.get("reply") 113 - .and_then(|reply| reply.get("parent")) 114 - .is_some(); 114 + let has_parent = if let jacquard_common::Data::Object(ref obj) = self.anchor_post.record { 115 + obj.get("reply") 116 + .and_then(|reply| { 117 + if let jacquard_common::Data::Object(ref reply_obj) = reply { 118 + reply_obj.get("parent") 119 + } else { 120 + None 121 + } 122 + }) 123 + .is_some() 124 + } else { 125 + false 126 + }; 115 127 116 128 if !has_parent { 117 129 thread_items.extend(vec![]); ··· 125 137 let anchor_rkey_base32 = parts[2]; 126 138 127 139 // Extract root post from anchor's record (for filtering by thread) 128 - let root_uri = self.anchor_post.record.get("reply") 129 - .and_then(|reply| reply.get("root")) 130 - .and_then(|root| root.get("uri")) 131 - .and_then(|uri| uri.as_str()) 132 - .map(|s| s.to_string()); 140 + let root_uri = if let jacquard_common::Data::Object(ref obj) = self.anchor_post.record { 141 + obj.get("reply") 142 + .and_then(|reply| { 143 + if let jacquard_common::Data::Object(ref reply_obj) = reply { 144 + reply_obj.get("root") 145 + } else { 146 + None 147 + } 148 + }) 149 + .and_then(|root| { 150 + if let jacquard_common::Data::Object(ref root_obj) = root { 151 + root_obj.get("uri") 152 + } else { 153 + None 154 + } 155 + }) 156 + .and_then(|uri| { 157 + if let jacquard_common::Data::String(ref s) = uri { 158 + Some(s.as_str().to_string()) 159 + } else { 160 + None 161 + } 162 + }) 163 + } else { 164 + None 165 + }; 133 166 134 167 // Get actor_id from cache (should always be cached after hydrate_post) 135 168 let cached_actor = self.id_cache.get_actor_id(anchor_did).await ··· 140 173 141 174 // Get root actor_id if we have a root URI 142 175 let root_info = if let Some(ref root_uri_str) = root_uri { 143 - let root_parts: Vec<&str> = root_uri_str.trim_start_matches("at://").split('/').collect(); 176 + let root_parts: Vec<&str> = root_uri_str.trim_start_matches("at://").split('/').collect::<Vec<_>>(); 144 177 if root_parts.len() >= 3 { 145 178 let root_did = root_parts[0]; 146 179 let root_rkey_base32 = root_parts[2]; ··· 201 234 202 235 // Check if anchor post has a parent that wasn't returned by get_thread_parents 203 236 // (happens when parent post doesn't exist in our database - deleted or never indexed) 204 - let anchor_parent_uri = self.anchor_post.record.get("reply") 205 - .and_then(|reply| reply.get("parent")) 206 - .and_then(|parent| parent.get("uri")) 207 - .and_then(|uri| uri.as_str()) 208 - .map(|s| s.to_string()); 237 + let anchor_parent_uri = if let jacquard_common::Data::Object(ref obj) = self.anchor_post.record { 238 + obj.get("reply") 239 + .and_then(|reply| { 240 + if let jacquard_common::Data::Object(ref reply_obj) = reply { 241 + reply_obj.get("parent") 242 + } else { 243 + None 244 + } 245 + }) 246 + .and_then(|parent| { 247 + if let jacquard_common::Data::Object(ref parent_obj) = parent { 248 + parent_obj.get("uri") 249 + } else { 250 + None 251 + } 252 + }) 253 + .and_then(|uri| { 254 + if let jacquard_common::Data::String(ref s) = uri { 255 + Some(s.as_str().to_string()) 256 + } else { 257 + None 258 + } 259 + }) 260 + } else { 261 + None 262 + }; 209 263 210 264 if let Some(parent_uri) = &anchor_parent_uri { 211 265 // Check if this parent is in our parents list ··· 214 268 // If not found and parents list is empty, create a tombstone for it 215 269 if !found_direct_parent && parents.is_empty() { 216 270 thread_items.push(ThreadV2Item { 217 - uri: parent_uri.clone(), 271 + uri: jacquard_common::types::aturi::AtUri::new(&parent_uri).unwrap(), 218 272 depth: -1, // Immediate parent at depth -1 219 - value: ThreadV2ItemType::NotFound {}, 273 + value: ThreadV2ItemType::ThreadItemNotFound(Box::new( 274 + jacquard_api::app_bsky::unspecced::ThreadItemNotFound { extra_data: None } 275 + )), 276 + extra_data: None, 220 277 }); 221 278 } 222 279 } ··· 227 284 228 285 if let Some(post_view) = parents_hydrated.get(parent_uri) { 229 286 thread_items.push(ThreadV2Item { 230 - uri: parent_uri.clone(), 231 - depth, 287 + uri: jacquard_common::types::aturi::AtUri::new(parent_uri).unwrap(), 288 + depth: depth as i64, 232 289 value: self.post_to_thread_item_type(post_view.clone(), true), 290 + extra_data: None, 233 291 }); 234 292 } else { 235 293 thread_items.push(ThreadV2Item { 236 - uri: parent_uri.clone(), 237 - depth, 238 - value: ThreadV2ItemType::NotFound {}, 294 + uri: jacquard_common::types::aturi::AtUri::new(parent_uri).unwrap(), 295 + depth: depth as i64, 296 + value: ThreadV2ItemType::ThreadItemNotFound(Box::new( 297 + jacquard_api::app_bsky::unspecced::ThreadItemNotFound { extra_data: None } 298 + )), 299 + extra_data: None, 239 300 }); 240 301 } 241 302 } ··· 247 308 async fn add_child_posts( 248 309 &self, 249 310 conn: &mut diesel_async::AsyncPgConnection, 250 - thread_items: &mut Vec<ThreadV2Item>, 311 + thread_items: &mut Vec<ThreadV2Item<'_>>, 251 312 ) -> crate::common::errors::XrpcResult<()> { 252 313 // Get all replies from database with branching factor 253 314 // Query with branching_factor + 1 to know if there are more replies ··· 343 404 for (child_uri, _sql_depth) in sorted_children { 344 405 if let Some(post_view) = replies_hydrated.get(&child_uri) { 345 406 thread_items.push(ThreadV2Item { 346 - uri: child_uri.clone(), 407 + extra_data: None, 408 + uri: jacquard_common::types::aturi::AtUri::new(&child_uri).unwrap(), 347 409 depth: 1, // Direct children are always depth 1 348 410 value: self.post_to_thread_item_type(post_view.clone(), false), 349 411 }); ··· 360 422 } 361 423 } else { 362 424 thread_items.push(ThreadV2Item { 363 - uri: child_uri.clone(), 425 + extra_data: None, 426 + uri: jacquard_common::types::aturi::AtUri::new(&child_uri).unwrap(), 364 427 depth: 1, 365 - value: ThreadV2ItemType::NotFound {}, 428 + value: ThreadV2ItemType::ThreadItemNotFound(Box::new( 429 + jacquard_api::app_bsky::unspecced::ThreadItemNotFound { extra_data: None } 430 + )), 366 431 }); 367 432 } 368 433 } ··· 378 443 parent_depth: i32, 379 444 children_by_parent: &HashMap<String, Vec<(String, i32)>>, 380 445 replies_hydrated: &HashMap<String, PostView>, 381 - thread_items: &mut Vec<ThreadV2Item>, 446 + thread_items: &mut Vec<ThreadV2Item<'_>>, 382 447 ) { 383 448 if let Some(children) = children_by_parent.get(parent_uri) { 384 449 // Sort children according to the requested sort mode ··· 399 464 400 465 if let Some(post_view) = replies_hydrated.get(child_uri) { 401 466 thread_items.push(ThreadV2Item { 402 - uri: child_uri.clone(), 467 + extra_data: None, 468 + uri: jacquard_common::types::aturi::AtUri::new(&child_uri).unwrap(), 403 469 depth: child_depth, 404 470 value: self.post_to_thread_item_type(post_view.clone(), false), 405 471 }); ··· 416 482 } 417 483 } else { 418 484 thread_items.push(ThreadV2Item { 419 - uri: child_uri.clone(), 485 + extra_data: None, 486 + uri: jacquard_common::types::aturi::AtUri::new(&child_uri).unwrap(), 420 487 depth: child_depth, 421 - value: ThreadV2ItemType::NotFound {}, 488 + value: ThreadV2ItemType::ThreadItemNotFound(Box::new( 489 + jacquard_api::app_bsky::unspecced::ThreadItemNotFound { extra_data: None } 490 + )), 422 491 }); 423 492 } 424 493 }
+3 -2
parakeet/src/xrpc/community_lexicon/bookmarks.rs
··· 4 4 use crate::GlobalState; 5 5 use axum::extract::{Query, State}; 6 6 use axum::Json; 7 - use lexica::community_lexicon::bookmarks::Bookmark; 7 + use jacquard_api::community_lexicon::bookmarks::bookmark::Bookmark; 8 8 use serde::{Deserialize, Serialize}; 9 9 10 10 #[derive(Debug, Deserialize)] ··· 18 18 pub struct GetActorBookmarksRes { 19 19 #[serde(skip_serializing_if = "Option::is_none")] 20 20 cursor: Option<String>, 21 - bookmarks: Vec<Bookmark>, 21 + bookmarks: Vec<Bookmark<'static>>, 22 22 } 23 23 24 24 pub async fn get_actor_bookmarks( ··· 101 101 subject: subject_uri, 102 102 tags: Vec::new(), // Tags not supported in current schema 103 103 created_at: bookmark.created_at(), 104 + extra_data: None, 104 105 }) 105 106 }) 106 107 .collect();