Parakeet is a Rust-based Bluesky AppView aiming to implement most of the functionality required to support the Bluesky client

feat: Starter Packs

closes #2

Changed files
+483 -3
consumer
lexica
src
app_bsky
migrations
2025-04-09-175735_starterpacks
parakeet
src
parakeet-db
+48
consumer/src/indexer/db.rs
··· 693 .execute(conn) 694 .await 695 }
··· 693 .execute(conn) 694 .await 695 } 696 + 697 + pub async fn upsert_starterpack( 698 + conn: &mut AsyncPgConnection, 699 + did: &str, 700 + cid: Cid, 701 + at_uri: &str, 702 + rec: records::AppBskyGraphStarterPack, 703 + ) -> QueryResult<usize> { 704 + let record = serde_json::to_value(&rec).unwrap(); 705 + 706 + let feeds = rec 707 + .feeds 708 + .map(|v| v.into_iter().map(|item| item.uri).collect()); 709 + 710 + let description_facets = rec 711 + .description_facets 712 + .and_then(|v| serde_json::to_value(v).ok()); 713 + 714 + 715 + let data = models::NewStarterPack { 716 + at_uri, 717 + cid: cid.to_string(), 718 + owner: did, 719 + record, 720 + name: &rec.name, 721 + description: rec.description, 722 + description_facets, 723 + list: &rec.list, 724 + feeds, 725 + created_at: rec.created_at.naive_utc(), 726 + indexed_at: Utc::now().naive_utc(), 727 + }; 728 + 729 + diesel::insert_into(schema::starterpacks::table) 730 + .values(&data) 731 + .on_conflict(schema::starterpacks::at_uri) 732 + .do_update() 733 + .set(&data) 734 + .execute(conn) 735 + .await 736 + } 737 + 738 + pub async fn delete_starterpack(conn: &mut AsyncPgConnection, at_uri: &str) -> QueryResult<usize> { 739 + diesel::delete(schema::starterpacks::table) 740 + .filter(schema::starterpacks::at_uri.eq(at_uri)) 741 + .execute(conn) 742 + .await 743 + }
+4
consumer/src/indexer/mod.rs
··· 420 421 db::insert_list_item(conn, at_uri, record).await?; 422 } 423 RecordTypes::ChatBskyActorDeclaration(record) => { 424 if rkey == "self" { 425 db::upsert_chat_decl(conn, repo, record).await?; ··· 458 } 459 CollectionType::BskyListBlock => db::delete_list_block(conn, at_uri).await?, 460 CollectionType::BskyListItem => db::delete_list_item(conn, at_uri).await?, 461 CollectionType::ChatActorDecl => db::delete_chat_decl(conn, at_uri).await?, 462 _ => unreachable!(), 463 };
··· 420 421 db::insert_list_item(conn, at_uri, record).await?; 422 } 423 + RecordTypes::AppBskyGraphStarterPack(record) => { 424 + db::upsert_starterpack(conn, repo, cid, at_uri, record).await?; 425 + }, 426 RecordTypes::ChatBskyActorDeclaration(record) => { 427 if rkey == "self" { 428 db::upsert_chat_decl(conn, repo, record).await?; ··· 461 } 462 CollectionType::BskyListBlock => db::delete_list_block(conn, at_uri).await?, 463 CollectionType::BskyListItem => db::delete_list_item(conn, at_uri).await?, 464 + CollectionType::BskyStarterPack => db::delete_starterpack(conn, at_uri).await?, 465 CollectionType::ChatActorDecl => db::delete_chat_decl(conn, at_uri).await?, 466 _ => unreachable!(), 467 };
+18
consumer/src/indexer/records.rs
··· 294 } 295 296 #[derive(Debug, Deserialize, Serialize)] 297 #[serde(rename_all = "camelCase")] 298 pub struct ChatBskyActorDeclaration { 299 pub allow_incoming: ChatAllowIncoming,
··· 294 } 295 296 #[derive(Debug, Deserialize, Serialize)] 297 + #[serde(tag = "$type")] 298 + #[serde(rename = "app.bsky.graph.starterpack")] 299 + #[serde(rename_all = "camelCase")] 300 + pub struct AppBskyGraphStarterPack { 301 + pub name: String, 302 + pub description: Option<String>, 303 + pub description_facets: Option<Vec<FacetMain>>, 304 + pub list: String, 305 + pub feeds: Option<Vec<StarterPackFeedItem>>, 306 + pub created_at: DateTime<Utc>, 307 + } 308 + 309 + #[derive(Debug, Deserialize, Serialize)] 310 + pub struct StarterPackFeedItem { 311 + pub uri: String, 312 + } 313 + 314 + #[derive(Debug, Deserialize, Serialize)] 315 #[serde(rename_all = "camelCase")] 316 pub struct ChatBskyActorDeclaration { 317 pub allow_incoming: ChatAllowIncoming,
+5
consumer/src/indexer/types.rs
··· 29 AppBskyGraphListBlock(records::AppBskyGraphListBlock), 30 #[serde(rename = "app.bsky.graph.listitem")] 31 AppBskyGraphListItem(records::AppBskyGraphListItem), 32 #[serde(rename = "chat.bsky.actor.declaration")] 33 ChatBskyActorDeclaration(records::ChatBskyActorDeclaration), 34 } ··· 47 BskyList, 48 BskyListBlock, 49 BskyListItem, 50 ChatActorDecl, 51 Unsupported, 52 } ··· 66 "app.bsky.graph.list" => CollectionType::BskyList, 67 "app.bsky.graph.listblock" => CollectionType::BskyListBlock, 68 "app.bsky.graph.listitem" => CollectionType::BskyListItem, 69 "chat.bsky.actor.declaration" => CollectionType::ChatActorDecl, 70 _ => CollectionType::Unsupported, 71 } ··· 86 CollectionType::BskyListBlock => false, 87 CollectionType::BskyListItem => false, 88 CollectionType::ChatActorDecl => true, 89 CollectionType::Unsupported => false, 90 } 91 }
··· 29 AppBskyGraphListBlock(records::AppBskyGraphListBlock), 30 #[serde(rename = "app.bsky.graph.listitem")] 31 AppBskyGraphListItem(records::AppBskyGraphListItem), 32 + #[serde(rename = "app.bsky.graph.starterpack")] 33 + AppBskyGraphStarterPack(records::AppBskyGraphStarterPack), 34 #[serde(rename = "chat.bsky.actor.declaration")] 35 ChatBskyActorDeclaration(records::ChatBskyActorDeclaration), 36 } ··· 49 BskyList, 50 BskyListBlock, 51 BskyListItem, 52 + BskyStarterPack, 53 ChatActorDecl, 54 Unsupported, 55 } ··· 69 "app.bsky.graph.list" => CollectionType::BskyList, 70 "app.bsky.graph.listblock" => CollectionType::BskyListBlock, 71 "app.bsky.graph.listitem" => CollectionType::BskyListItem, 72 + "app.bsky.graph.starterpack" => CollectionType::BskyStarterPack, 73 "chat.bsky.actor.declaration" => CollectionType::ChatActorDecl, 74 _ => CollectionType::Unsupported, 75 } ··· 90 CollectionType::BskyListBlock => false, 91 CollectionType::BskyListItem => false, 92 CollectionType::ChatActorDecl => true, 93 + CollectionType::BskyStarterPack => true, 94 CollectionType::Unsupported => false, 95 } 96 }
+42 -2
lexica/src/app_bsky/graph.rs
··· 1 - use crate::app_bsky::actor::ProfileView; 2 use crate::app_bsky::richtext::FacetMain; 3 use chrono::prelude::*; 4 use serde::{Deserialize, Serialize}; ··· 48 pub indexed_at: NaiveDateTime, 49 } 50 51 - #[derive(Debug, Serialize)] 52 pub struct ListItemView { 53 pub uri: String, 54 pub subject: ProfileView, ··· 79 } 80 } 81 }
··· 1 + use crate::app_bsky::actor::{ProfileView, ProfileViewBasic}; 2 + use crate::app_bsky::feed::GeneratorView; 3 use crate::app_bsky::richtext::FacetMain; 4 use chrono::prelude::*; 5 use serde::{Deserialize, Serialize}; ··· 49 pub indexed_at: NaiveDateTime, 50 } 51 52 + #[derive(Clone, Debug, Serialize)] 53 pub struct ListItemView { 54 pub uri: String, 55 pub subject: ProfileView, ··· 80 } 81 } 82 } 83 + 84 + #[derive(Clone, Debug, Serialize)] 85 + #[serde(rename_all = "camelCase")] 86 + pub struct StarterPackView { 87 + pub uri: String, 88 + pub cid: String, 89 + pub record: serde_json::Value, 90 + pub creator: ProfileViewBasic, 91 + 92 + #[serde(skip_serializing_if = "Option::is_none")] 93 + pub list: Option<ListViewBasic>, 94 + #[serde(skip_serializing_if = "Vec::is_empty")] 95 + pub list_items_sample: Vec<ListItemView>, 96 + #[serde(skip_serializing_if = "Vec::is_empty")] 97 + pub feeds: Vec<GeneratorView>, 98 + 99 + pub list_item_count: i64, 100 + pub joined_week_count: i64, 101 + pub joined_all_time_count: i64, 102 + 103 + // pub labels: Vec<()>, 104 + pub indexed_at: NaiveDateTime, 105 + } 106 + 107 + #[derive(Clone, Debug, Serialize)] 108 + #[serde(rename_all = "camelCase")] 109 + pub struct StarterPackViewBasic { 110 + pub uri: String, 111 + pub cid: String, 112 + pub record: serde_json::Value, 113 + pub creator: ProfileViewBasic, 114 + 115 + pub list_item_count: i64, 116 + pub joined_week_count: i64, 117 + pub joined_all_time_count: i64, 118 + 119 + // pub labels: Vec<()>, 120 + pub indexed_at: NaiveDateTime, 121 + }
+1
migrations/2025-04-09-175735_starterpacks/down.sql
···
··· 1 + drop table starterpacks;
+18
migrations/2025-04-09-175735_starterpacks/up.sql
···
··· 1 + create table starterpacks 2 + ( 3 + at_uri text primary key, 4 + owner text not null references actors (did), 5 + cid text not null, 6 + record jsonb not null, 7 + 8 + name text not null, 9 + description text, 10 + description_facets jsonb, 11 + list text not null, 12 + feeds text[], 13 + 14 + created_at timestamptz not null default now(), 15 + indexed_at timestamp not null default now() 16 + ); 17 + 18 + create index starterpacks_owner_index on starterpacks using hash (owner);
+39
parakeet-db/src/models.rs
··· 554 pub did: &'a str, 555 pub allow_incoming: String, 556 }
··· 554 pub did: &'a str, 555 pub allow_incoming: String, 556 } 557 + 558 + #[derive(Clone, Debug, Queryable, Selectable, Identifiable)] 559 + #[diesel(table_name = crate::schema::starterpacks)] 560 + #[diesel(primary_key(at_uri))] 561 + #[diesel(check_for_backend(diesel::pg::Pg))] 562 + pub struct StaterPack { 563 + pub at_uri: String, 564 + pub cid: String, 565 + pub owner: String, 566 + pub record: serde_json::Value, 567 + 568 + pub name: String, 569 + pub description: Option<String>, 570 + pub description_facets: Option<serde_json::Value>, 571 + pub list: String, 572 + pub feeds: Option<Vec<Option<String>>>, 573 + 574 + pub created_at: NaiveDateTime, 575 + pub indexed_at: NaiveDateTime, 576 + } 577 + 578 + #[derive(Insertable, AsChangeset)] 579 + #[diesel(table_name = crate::schema::starterpacks)] 580 + #[diesel(check_for_backend(diesel::pg::Pg))] 581 + pub struct NewStarterPack<'a> { 582 + pub at_uri: &'a str, 583 + pub cid: String, 584 + pub owner: &'a str, 585 + pub record: serde_json::Value, 586 + 587 + pub name: &'a str, 588 + pub description: Option<String>, 589 + pub description_facets: Option<serde_json::Value>, 590 + pub list: &'a str, 591 + pub feeds: Option<Vec<String>>, 592 + 593 + pub created_at: NaiveDateTime, 594 + pub indexed_at: NaiveDateTime, 595 + }
+18
parakeet-db/src/schema.rs
··· 234 } 235 236 diesel::table! { 237 threadgates (at_uri) { 238 at_uri -> Text, 239 cid -> Text, ··· 264 diesel::joinable!(posts -> actors (did)); 265 diesel::joinable!(profiles -> actors (did)); 266 diesel::joinable!(reposts -> actors (did)); 267 diesel::joinable!(threadgates -> posts (post_uri)); 268 269 diesel::allow_tables_to_appear_in_same_query!( ··· 287 posts, 288 profiles, 289 reposts, 290 threadgates, 291 );
··· 234 } 235 236 diesel::table! { 237 + starterpacks (at_uri) { 238 + at_uri -> Text, 239 + owner -> Text, 240 + cid -> Text, 241 + record -> Jsonb, 242 + name -> Text, 243 + description -> Nullable<Text>, 244 + description_facets -> Nullable<Jsonb>, 245 + list -> Text, 246 + feeds -> Nullable<Array<Nullable<Text>>>, 247 + created_at -> Timestamptz, 248 + indexed_at -> Timestamp, 249 + } 250 + } 251 + 252 + diesel::table! { 253 threadgates (at_uri) { 254 at_uri -> Text, 255 cid -> Text, ··· 280 diesel::joinable!(posts -> actors (did)); 281 diesel::joinable!(profiles -> actors (did)); 282 diesel::joinable!(reposts -> actors (did)); 283 + diesel::joinable!(starterpacks -> actors (owner)); 284 diesel::joinable!(threadgates -> posts (post_uri)); 285 286 diesel::allow_tables_to_appear_in_same_query!( ··· 304 posts, 305 profiles, 306 reposts, 307 + starterpacks, 308 threadgates, 309 );
+3 -1
parakeet/src/hydration/mod.rs
··· 5 pub mod list; 6 pub mod posts; 7 pub mod profile; 8 9 pub use feedgen::*; 10 pub use list::*; 11 pub use posts::*; 12 - pub use profile::*;
··· 5 pub mod list; 6 pub mod posts; 7 pub mod profile; 8 + pub mod starter_packs; 9 10 pub use feedgen::*; 11 pub use list::*; 12 pub use posts::*; 13 + pub use profile::*; 14 + pub use starter_packs::*;
+145
parakeet/src/hydration/starter_packs.rs
···
··· 1 + use crate::hydration::{ 2 + hydrate_feedgens, hydrate_list_basic, hydrate_lists_basic, hydrate_profile_basic, 3 + hydrate_profiles_basic, 4 + }; 5 + use crate::loaders::Dataloaders; 6 + use lexica::app_bsky::actor::ProfileViewBasic; 7 + use lexica::app_bsky::feed::GeneratorView; 8 + use lexica::app_bsky::graph::{ListViewBasic, StarterPackView, StarterPackViewBasic}; 9 + use parakeet_db::models; 10 + use std::collections::HashMap; 11 + 12 + fn build_basic( 13 + starter_pack: models::StaterPack, 14 + creator: ProfileViewBasic, 15 + list_item_count: i64, 16 + ) -> StarterPackViewBasic { 17 + StarterPackViewBasic { 18 + uri: starter_pack.at_uri, 19 + cid: starter_pack.cid, 20 + record: starter_pack.record, 21 + creator, 22 + list_item_count, 23 + joined_week_count: 0, 24 + joined_all_time_count: 0, 25 + indexed_at: starter_pack.indexed_at, 26 + } 27 + } 28 + 29 + fn build_spview( 30 + starter_pack: models::StaterPack, 31 + creator: ProfileViewBasic, 32 + list: Option<ListViewBasic>, 33 + feeds: Vec<GeneratorView>, 34 + ) -> StarterPackView { 35 + let list_item_count = list.as_ref().map(|list| list.list_item_count).unwrap_or(0); 36 + 37 + StarterPackView { 38 + uri: starter_pack.at_uri, 39 + cid: starter_pack.cid, 40 + record: starter_pack.record, 41 + creator, 42 + list, 43 + list_items_sample: vec![], // TODO: we should do this, but the app seems to be okay without? 44 + feeds, 45 + list_item_count, 46 + joined_week_count: 0, 47 + joined_all_time_count: 0, 48 + indexed_at: starter_pack.indexed_at, 49 + } 50 + } 51 + 52 + pub async fn hydrate_starterpack_basic( 53 + loaders: &Dataloaders, 54 + pack: String, 55 + ) -> Option<StarterPackViewBasic> { 56 + let sp = loaders.starterpacks.load(pack).await?; 57 + let creator = hydrate_profile_basic(loaders, sp.owner.clone()).await?; 58 + let (_, list_item_count) = loaders.list.load(sp.list.clone()).await?; 59 + 60 + Some(build_basic(sp, creator, list_item_count)) 61 + } 62 + 63 + pub async fn hydrate_starterpacks_basic( 64 + loaders: &Dataloaders, 65 + packs: Vec<String>, 66 + ) -> HashMap<String, StarterPackViewBasic> { 67 + let packs = loaders.starterpacks.load_many(packs).await; 68 + 69 + let (creators, lists) = packs 70 + .values() 71 + .map(|pack| (pack.owner.clone(), pack.list.clone())) 72 + .unzip(); 73 + 74 + let creators = hydrate_profiles_basic(loaders, creators).await; 75 + let lists = loaders.list.load_many(lists).await; 76 + 77 + packs 78 + .into_iter() 79 + .filter_map(|(at_uri, pack)| { 80 + let creator = creators.get(&pack.owner).cloned()?; 81 + let list_item_count = lists.get(&pack.list).map(|(_, v)| *v).unwrap_or(0); 82 + 83 + Some((at_uri, build_basic(pack, creator, list_item_count))) 84 + }) 85 + .collect() 86 + } 87 + 88 + pub async fn hydrate_starterpack(loaders: &Dataloaders, pack: String) -> Option<StarterPackView> { 89 + let sp = loaders.starterpacks.load(pack).await?; 90 + 91 + let creator = hydrate_profile_basic(loaders, sp.owner.clone()).await?; 92 + let list = hydrate_list_basic(loaders, sp.list.clone()).await; 93 + 94 + let feeds = sp 95 + .feeds 96 + .clone() 97 + .unwrap_or_default() 98 + .into_iter() 99 + .flatten() 100 + .collect(); 101 + let feeds = hydrate_feedgens(loaders, feeds) 102 + .await 103 + .into_values() 104 + .collect(); 105 + 106 + Some(build_spview(sp, creator, list, feeds)) 107 + } 108 + 109 + pub async fn hydrate_starterpacks( 110 + loaders: &Dataloaders, 111 + packs: Vec<String>, 112 + ) -> HashMap<String, StarterPackView> { 113 + let packs = loaders.starterpacks.load_many(packs).await; 114 + 115 + let (creators, lists) = packs 116 + .values() 117 + .map(|pack| (pack.owner.clone(), pack.list.clone())) 118 + .unzip(); 119 + let feeds = packs 120 + .values() 121 + .filter_map(|pack| pack.feeds.clone()) 122 + .flat_map(|feeds| feeds.into_iter().flatten()) 123 + .collect(); 124 + 125 + let creators = hydrate_profiles_basic(loaders, creators).await; 126 + let lists = hydrate_lists_basic(loaders, lists).await; 127 + let feeds = hydrate_feedgens(loaders, feeds).await; 128 + 129 + packs 130 + .into_iter() 131 + .filter_map(|(at_uri, pack)| { 132 + let creator = creators.get(&pack.owner).cloned()?; 133 + let list = lists.get(&pack.list).cloned(); 134 + let feeds = pack.feeds.as_ref().map(|v| { 135 + v.iter() 136 + .flatten() 137 + .filter_map(|feed| feeds.get(feed).cloned()) 138 + .collect() 139 + }); 140 + let feeds = feeds.unwrap_or_default(); 141 + 142 + Some((at_uri, build_spview(pack, creator, list, feeds))) 143 + }) 144 + .collect() 145 + }
+27
parakeet/src/loaders.rs
··· 16 pub list: Loader<String, ListLoaderRet, ListLoader>, 17 pub posts: Loader<String, PostLoaderRet, PostLoader>, 18 pub profile: Loader<String, ProfileLoaderRet, ProfileLoader>, 19 } 20 21 impl Dataloaders { ··· 29 list: Loader::new(ListLoader(pool.clone())), 30 posts: Loader::new(PostLoader(pool.clone())), 31 profile: Loader::new(ProfileLoader(pool.clone())), 32 } 33 } 34 } ··· 274 )) 275 } 276 }
··· 16 pub list: Loader<String, ListLoaderRet, ListLoader>, 17 pub posts: Loader<String, PostLoaderRet, PostLoader>, 18 pub profile: Loader<String, ProfileLoaderRet, ProfileLoader>, 19 + pub starterpacks: Loader<String, StarterPackLoaderRet, StarterPackLoader>, 20 } 21 22 impl Dataloaders { ··· 30 list: Loader::new(ListLoader(pool.clone())), 31 posts: Loader::new(PostLoader(pool.clone())), 32 profile: Loader::new(ProfileLoader(pool.clone())), 33 + starterpacks: Loader::new(StarterPackLoader(pool.clone())), 34 } 35 } 36 } ··· 276 )) 277 } 278 } 279 + 280 + pub struct StarterPackLoader(Pool<AsyncPgConnection>); 281 + type StarterPackLoaderRet = models::StaterPack; 282 + impl BatchFn<String, StarterPackLoaderRet> for StarterPackLoader { 283 + async fn load(&mut self, keys: &[String]) -> HashMap<String, StarterPackLoaderRet> { 284 + let mut conn = self.0.get().await.unwrap(); 285 + 286 + let res = schema::starterpacks::table 287 + .select(models::StaterPack::as_select()) 288 + .filter(schema::starterpacks::at_uri.eq_any(keys)) 289 + .load(&mut conn) 290 + .await; 291 + 292 + match res { 293 + Ok(res) => HashMap::from_iter( 294 + res.into_iter() 295 + .map(|starterpack| (starterpack.at_uri.clone(), (starterpack))), 296 + ), 297 + Err(e) => { 298 + tracing::error!("starterpack load failed: {e}"); 299 + HashMap::new() 300 + } 301 + } 302 + } 303 + }
+1
parakeet/src/xrpc/app_bsky/graph/mod.rs
··· 1 pub mod lists; 2 pub mod relations;
··· 1 pub mod lists; 2 pub mod relations; 3 + pub mod starter_packs;
+111
parakeet/src/xrpc/app_bsky/graph/starter_packs.rs
···
··· 1 + use crate::xrpc::error::{Error, XrpcResult}; 2 + use crate::xrpc::{datetime_cursor, get_actor_did, ActorWithCursorQuery}; 3 + use crate::{hydration, GlobalState}; 4 + use axum::extract::{Query, State}; 5 + use axum::Json; 6 + use axum_extra::extract::Query as ExtraQuery; 7 + use diesel::prelude::*; 8 + use diesel_async::RunQueryDsl; 9 + use lexica::app_bsky::graph::{StarterPackView, StarterPackViewBasic}; 10 + use parakeet_db::schema; 11 + use serde::{Deserialize, Serialize}; 12 + 13 + #[derive(Debug, Serialize)] 14 + #[serde(rename_all = "camelCase")] 15 + pub struct StarterPacksRes { 16 + pub starter_packs: Vec<StarterPackViewBasic>, 17 + #[serde(skip_serializing_if = "Option::is_none")] 18 + cursor: Option<String>, 19 + } 20 + 21 + pub async fn get_actor_starter_packs( 22 + State(state): State<GlobalState>, 23 + Query(query): Query<ActorWithCursorQuery>, 24 + ) -> XrpcResult<Json<StarterPacksRes>> { 25 + let mut conn = state.pool.get().await?; 26 + 27 + let subj_did = get_actor_did(&state.dataloaders, query.actor).await?; 28 + 29 + let limit = query.limit.unwrap_or(50).clamp(1, 100); 30 + 31 + let mut sp_query = schema::starterpacks::table 32 + .select(( 33 + schema::starterpacks::created_at, 34 + schema::starterpacks::at_uri, 35 + )) 36 + .filter(schema::starterpacks::owner.eq(subj_did)) 37 + .into_boxed(); 38 + 39 + if let Some(cursor) = datetime_cursor(query.cursor.as_ref()) { 40 + sp_query = sp_query.filter(schema::starterpacks::created_at.lt(cursor)); 41 + } 42 + 43 + let results = sp_query 44 + .order(schema::starterpacks::created_at.desc()) 45 + .limit(limit as i64) 46 + .load::<(chrono::DateTime<chrono::Utc>, String)>(&mut conn) 47 + .await?; 48 + 49 + let cursor = results 50 + .last() 51 + .map(|(last, _)| last.timestamp_millis().to_string()); 52 + 53 + let uris = results.iter().map(|(_, uri)| uri.clone()).collect(); 54 + 55 + let mut starter_packs = hydration::hydrate_starterpacks_basic(&state.dataloaders, uris).await; 56 + 57 + let starter_packs = results 58 + .into_iter() 59 + .filter_map(|(_, uri)| starter_packs.remove(&uri)) 60 + .collect(); 61 + 62 + Ok(Json(StarterPacksRes { 63 + starter_packs, 64 + cursor, 65 + })) 66 + } 67 + 68 + #[derive(Debug, Deserialize)] 69 + #[serde(rename_all = "camelCase")] 70 + pub struct GetStarterPackQuery { 71 + pub starter_pack: String, 72 + } 73 + 74 + #[derive(Debug, Serialize)] 75 + #[serde(rename_all = "camelCase")] 76 + pub struct GetStarterPackRes { 77 + pub starter_pack: StarterPackView, 78 + } 79 + 80 + pub async fn get_starter_pack( 81 + State(state): State<GlobalState>, 82 + Query(query): Query<GetStarterPackQuery>, 83 + ) -> XrpcResult<Json<GetStarterPackRes>> { 84 + let Some(starter_pack) = 85 + hydration::hydrate_starterpack(&state.dataloaders, query.starter_pack).await 86 + else { 87 + return Err(Error::not_found()); 88 + }; 89 + 90 + Ok(Json(GetStarterPackRes { starter_pack })) 91 + } 92 + 93 + #[derive(Debug, Deserialize)] 94 + pub struct GetStarterPacksQuery { 95 + pub uris: Vec<String>, 96 + } 97 + 98 + pub async fn get_starter_packs( 99 + State(state): State<GlobalState>, 100 + ExtraQuery(query): ExtraQuery<GetStarterPacksQuery>, 101 + ) -> XrpcResult<Json<StarterPacksRes>> { 102 + let starter_packs = hydration::hydrate_starterpacks_basic(&state.dataloaders, query.uris) 103 + .await 104 + .into_values() 105 + .collect(); 106 + 107 + Ok(Json(StarterPacksRes { 108 + starter_packs, 109 + cursor: None, 110 + })) 111 + }
+3
parakeet/src/xrpc/app_bsky/mod.rs
··· 19 .route("/app.bsky.feed.getRepostedBy", get(feed::posts::get_reposted_by)) 20 .route("/app.bsky.feed.getFeedGenerator", get(feed::feedgen::get_feed_generator)) 21 .route("/app.bsky.feed.getFeedGenerators", get(feed::feedgen::get_feed_generators)) 22 .route("/app.bsky.graph.getFollowers", get(graph::relations::get_followers)) 23 .route("/app.bsky.graph.getFollows", get(graph::relations::get_follows)) 24 .route("/app.bsky.graph.getList", get(graph::lists::get_list)) 25 .route("/app.bsky.graph.getLists", get(graph::lists::get_lists)) 26 }
··· 19 .route("/app.bsky.feed.getRepostedBy", get(feed::posts::get_reposted_by)) 20 .route("/app.bsky.feed.getFeedGenerator", get(feed::feedgen::get_feed_generator)) 21 .route("/app.bsky.feed.getFeedGenerators", get(feed::feedgen::get_feed_generators)) 22 + .route("/app.bsky.graph.getActorStarterPacks", get(graph::starter_packs::get_actor_starter_packs)) 23 .route("/app.bsky.graph.getFollowers", get(graph::relations::get_followers)) 24 .route("/app.bsky.graph.getFollows", get(graph::relations::get_follows)) 25 .route("/app.bsky.graph.getList", get(graph::lists::get_list)) 26 .route("/app.bsky.graph.getLists", get(graph::lists::get_lists)) 27 + .route("/app.bsky.graph.getStarterPack", get(graph::starter_packs::get_starter_pack)) 28 + .route("/app.bsky.graph.getStarterPacks", get(graph::starter_packs::get_starter_packs)) 29 }