tangled
alpha
login
or
join now
parakeet.at
/
parakeet
63
fork
atom
Parakeet is a Rust-based Bluesky AppServer aiming to implement most of the functionality required to support the Bluesky client
appview
atproto
bluesky
rust
appserver
63
fork
atom
overview
issues
12
pulls
pipelines
feat(parakeet): getLists and getList
mia.omg.lol
1 year ago
aa779732
fcbb56af
+356
-8
11 changed files
expand all
collapse all
unified
split
Cargo.lock
lexica
src
app_bsky
actor.rs
graph.rs
mod.rs
parakeet
Cargo.toml
src
hydration
list.rs
mod.rs
loaders.rs
xrpc
app_bsky
graph
lists.rs
mod.rs
mod.rs
+1
Cargo.lock
···
1842
1842
"lexica",
1843
1843
"parakeet-db",
1844
1844
"serde",
1845
1845
+
"serde_json",
1845
1846
"tokio",
1846
1847
"tower-http",
1847
1848
"tracing",
+4
-4
lexica/src/app_bsky/actor.rs
···
1
1
use chrono::prelude::*;
2
2
use serde::Serialize;
3
3
4
4
-
#[derive(Default, Debug, Serialize)]
4
4
+
#[derive(Clone, Default, Debug, Serialize)]
5
5
#[serde(rename_all = "camelCase")]
6
6
pub struct ProfileAssociated {
7
7
pub lists: i64,
···
12
12
pub chat: Option<ProfileAssociatedChat>,
13
13
}
14
14
15
15
-
#[derive(Debug, Serialize)]
15
15
+
#[derive(Clone, Debug, Serialize)]
16
16
#[serde(rename_all = "camelCase")]
17
17
pub struct ProfileAssociatedChat {
18
18
pub allow_incoming: ChatAllowIncoming,
19
19
}
20
20
21
21
-
#[derive(Debug, Serialize)]
21
21
+
#[derive(Copy, Clone, Debug, Serialize)]
22
22
#[serde(rename_all = "lowercase")]
23
23
pub enum ChatAllowIncoming {
24
24
All,
···
45
45
pub created_at: DateTime<Utc>,
46
46
}
47
47
48
48
-
#[derive(Debug, Serialize)]
48
48
+
#[derive(Clone, Debug, Serialize)]
49
49
#[serde(rename_all = "camelCase")]
50
50
pub struct ProfileView {
51
51
pub did: String,
+81
lexica/src/app_bsky/graph.rs
···
1
1
+
use std::str::FromStr;
2
2
+
use crate::app_bsky::actor::ProfileView;
3
3
+
use crate::app_bsky::richtext::FacetMain;
4
4
+
use chrono::prelude::*;
5
5
+
use serde::{Deserialize, Serialize};
6
6
+
7
7
+
#[derive(Debug, Serialize)]
8
8
+
#[serde(rename_all = "camelCase")]
9
9
+
pub struct ListViewBasic {
10
10
+
pub uri: String,
11
11
+
pub cid: String,
12
12
+
pub name: String,
13
13
+
pub purpose: ListPurpose,
14
14
+
15
15
+
#[serde(skip_serializing_if = "Option::is_none")]
16
16
+
pub avatar: Option<String>,
17
17
+
pub list_item_count: i64,
18
18
+
19
19
+
// #[serde(skip_serializing_if = "Option::is_none")]
20
20
+
// pub viewer: Option<()>,
21
21
+
// pub labels: Vec<()>,
22
22
+
23
23
+
pub indexed_at: NaiveDateTime,
24
24
+
}
25
25
+
26
26
+
#[derive(Debug, Serialize)]
27
27
+
#[serde(rename_all = "camelCase")]
28
28
+
pub struct ListView {
29
29
+
pub uri: String,
30
30
+
pub cid: String,
31
31
+
pub name: String,
32
32
+
pub creator: ProfileView,
33
33
+
pub purpose: ListPurpose,
34
34
+
35
35
+
#[serde(skip_serializing_if = "Option::is_none")]
36
36
+
pub description: Option<String>,
37
37
+
#[serde(skip_serializing_if = "Option::is_none")]
38
38
+
pub description_facets: Option<Vec<FacetMain>>,
39
39
+
40
40
+
#[serde(skip_serializing_if = "Option::is_none")]
41
41
+
pub avatar: Option<String>,
42
42
+
pub list_item_count: i64,
43
43
+
44
44
+
// #[serde(skip_serializing_if = "Option::is_none")]
45
45
+
// pub viewer: Option<()>,
46
46
+
// pub labels: Vec<()>,
47
47
+
48
48
+
pub indexed_at: NaiveDateTime,
49
49
+
}
50
50
+
51
51
+
#[derive(Debug, Serialize)]
52
52
+
pub struct ListItemView {
53
53
+
pub uri: String,
54
54
+
pub subject: ProfileView,
55
55
+
}
56
56
+
57
57
+
#[derive(Debug, Deserialize, Serialize)]
58
58
+
pub enum ListPurpose {
59
59
+
/// A list of actors to apply an aggregate moderation action (mute/block) on.
60
60
+
#[serde(rename = "app.bsky.graph.defs#modlist")]
61
61
+
ModList,
62
62
+
/// A list of actors used for curation purposes such as list feeds or interaction gating.
63
63
+
#[serde(rename = "app.bsky.graph.defs#curatelist")]
64
64
+
CurateList,
65
65
+
/// A list of actors used for only for reference purposes such as within a starter pack.
66
66
+
#[serde(rename = "app.bsky.graph.defs#referencelist")]
67
67
+
ReferenceList,
68
68
+
}
69
69
+
70
70
+
impl FromStr for ListPurpose {
71
71
+
type Err = ();
72
72
+
73
73
+
fn from_str(s: &str) -> Result<Self, Self::Err> {
74
74
+
match s {
75
75
+
"app.bsky.graph.defs#modlist" => Ok(Self::ModList),
76
76
+
"app.bsky.graph.defs#curatelist" => Ok(Self::CurateList),
77
77
+
"app.bsky.graph.defs#referencelist" => Ok(Self::ReferenceList),
78
78
+
_ => Err(()),
79
79
+
}
80
80
+
}
81
81
+
}
+1
lexica/src/app_bsky/mod.rs
···
1
1
pub mod actor;
2
2
+
pub mod graph;
2
3
pub mod richtext;
+1
parakeet/Cargo.toml
···
16
16
lexica = { path = "../lexica" }
17
17
parakeet-db = { path = "../parakeet-db" }
18
18
serde = { version = "1.0.217", features = ["derive"] }
19
19
+
serde_json = "1.0.134"
19
20
tokio = { version = "1.42.0", features = ["full"] }
20
21
tower-http = { version = "0.6.2", features = ["trace"] }
21
22
tracing = "0.1.40"
+91
parakeet/src/hydration/list.rs
···
1
1
+
use crate::hydration::profile::{hydrate_profile, hydrate_profiles};
2
2
+
use crate::loaders::Dataloaders;
3
3
+
use lexica::app_bsky::actor::ProfileView;
4
4
+
use lexica::app_bsky::graph::{ListPurpose, ListView, ListViewBasic};
5
5
+
use parakeet_db::models;
6
6
+
use std::collections::HashMap;
7
7
+
use std::str::FromStr;
8
8
+
9
9
+
fn build_basic(list: models::List, list_item_count: i64) -> Option<ListViewBasic> {
10
10
+
let purpose = ListPurpose::from_str(&list.list_type).ok()?;
11
11
+
12
12
+
Some(ListViewBasic {
13
13
+
uri: list.at_uri,
14
14
+
cid: list.cid,
15
15
+
name: list.name,
16
16
+
purpose,
17
17
+
avatar: list
18
18
+
.avatar_cid
19
19
+
.map(|v| format!("https://localhost/list/{v}")),
20
20
+
list_item_count,
21
21
+
indexed_at: list.indexed_at,
22
22
+
})
23
23
+
}
24
24
+
25
25
+
fn build_listview(
26
26
+
list: models::List,
27
27
+
list_item_count: i64,
28
28
+
creator: ProfileView,
29
29
+
) -> Option<ListView> {
30
30
+
let purpose = ListPurpose::from_str(&list.list_type).ok()?;
31
31
+
32
32
+
let description_facets = list
33
33
+
.description_facets
34
34
+
.and_then(|v| serde_json::from_value(v).ok());
35
35
+
36
36
+
Some(ListView {
37
37
+
uri: list.at_uri,
38
38
+
cid: list.cid,
39
39
+
name: list.name,
40
40
+
creator,
41
41
+
purpose,
42
42
+
description: list.description,
43
43
+
description_facets,
44
44
+
avatar: list
45
45
+
.avatar_cid
46
46
+
.map(|v| format!("https://localhost/list/{v}")),
47
47
+
list_item_count,
48
48
+
indexed_at: list.indexed_at,
49
49
+
})
50
50
+
}
51
51
+
52
52
+
pub async fn hydrate_list_basic(loaders: &Dataloaders, list: String) -> Option<ListViewBasic> {
53
53
+
let (list, count) = loaders.list.load(list).await?;
54
54
+
55
55
+
build_basic(list, count)
56
56
+
}
57
57
+
58
58
+
pub async fn hydrate_lists_basic(
59
59
+
loaders: &Dataloaders,
60
60
+
lists: Vec<String>,
61
61
+
) -> HashMap<String, ListViewBasic> {
62
62
+
let lists = loaders.list.load_many(lists).await;
63
63
+
64
64
+
lists
65
65
+
.into_iter()
66
66
+
.filter_map(|(uri, (list, count))| build_basic(list, count).map(|v| (uri, v)))
67
67
+
.collect()
68
68
+
}
69
69
+
70
70
+
pub async fn hydrate_list(loaders: &Dataloaders, list: String) -> Option<ListView> {
71
71
+
let (list, count) = loaders.list.load(list).await?;
72
72
+
let profile = hydrate_profile(loaders, list.owner.clone()).await?;
73
73
+
74
74
+
build_listview(list, count, profile)
75
75
+
}
76
76
+
77
77
+
pub async fn hydrate_lists(loaders: &Dataloaders, lists: Vec<String>) -> HashMap<String, ListView> {
78
78
+
let lists = loaders.list.load_many(lists).await;
79
79
+
80
80
+
let creators = lists.values().map(|(list, _)| list.owner.clone()).collect();
81
81
+
let creators = hydrate_profiles(loaders, creators).await;
82
82
+
83
83
+
lists
84
84
+
.into_iter()
85
85
+
.filter_map(|(uri, (list, count))| {
86
86
+
let creator = creators.get(&list.owner)?;
87
87
+
88
88
+
build_listview(list, count, creator.to_owned()).map(|v| (uri, v))
89
89
+
})
90
90
+
.collect()
91
91
+
}
+4
-1
parakeet/src/hydration/mod.rs
···
1
1
-
pub mod profile;
1
1
+
#![allow(unused)]
2
2
+
3
3
+
pub mod list;
4
4
+
pub mod profile;
+43
-3
parakeet/src/loaders.rs
···
8
8
9
9
pub struct Dataloaders {
10
10
pub handle: Loader<String, String, HandleLoader>,
11
11
+
pub list: Loader<String, ListLoaderRet, ListLoader>,
11
12
pub profile: Loader<String, ProfileLoaderRet, ProfileLoader>,
12
13
}
13
14
···
17
18
pub fn new(pool: Pool<AsyncPgConnection>) -> Dataloaders {
18
19
Dataloaders {
19
20
handle: Loader::new(HandleLoader(pool.clone())),
21
21
+
list: Loader::new(ListLoader(pool.clone())),
20
22
profile: Loader::new(ProfileLoader(pool.clone())),
21
23
}
22
24
}
···
64
66
Option::<models::FollowStats>::as_select(),
65
67
))
66
68
.filter(schema::actors::did.eq_any(keys))
67
67
-
.load::<(String, Option<String>, models::Profile, Option<models::FollowStats>)>(&mut conn)
69
69
+
.load::<(
70
70
+
String,
71
71
+
Option<String>,
72
72
+
models::Profile,
73
73
+
Option<models::FollowStats>,
74
74
+
)>(&mut conn)
75
75
+
.await;
76
76
+
77
77
+
match res {
78
78
+
Ok(res) => {
79
79
+
HashMap::from_iter(res.into_iter().map(|(did, handle, profile, follow_stats)| {
80
80
+
(did, (handle, profile, follow_stats))
81
81
+
}))
82
82
+
}
83
83
+
Err(e) => {
84
84
+
tracing::error!("profile load failed: {e}");
85
85
+
HashMap::new()
86
86
+
}
87
87
+
}
88
88
+
}
89
89
+
}
90
90
+
91
91
+
pub struct ListLoader(Pool<AsyncPgConnection>);
92
92
+
type ListLoaderRet = (models::List, i64);
93
93
+
impl BatchFn<String, ListLoaderRet> for ListLoader {
94
94
+
async fn load(&mut self, keys: &[String]) -> HashMap<String, ListLoaderRet> {
95
95
+
let mut conn = self.0.get().await.unwrap();
96
96
+
97
97
+
let res = schema::lists::table
98
98
+
.left_join(
99
99
+
schema::list_items::table.on(schema::list_items::at_uri.eq(schema::lists::at_uri)),
100
100
+
)
101
101
+
.group_by(schema::lists::all_columns)
102
102
+
.select((
103
103
+
models::List::as_select(),
104
104
+
diesel::dsl::count(schema::lists::at_uri.assume_not_null()),
105
105
+
))
106
106
+
.filter(schema::lists::at_uri.eq_any(keys))
107
107
+
.load::<(models::List, i64)>(&mut conn)
68
108
.await;
69
109
70
110
match res {
71
111
Ok(res) => HashMap::from_iter(
72
112
res.into_iter()
73
73
-
.map(|(did, handle, profile, follow_stats)| (did, (handle, profile, follow_stats))),
113
113
+
.map(|(list, count)| (list.at_uri.clone(), (list, count))),
74
114
),
75
115
Err(e) => {
76
76
-
tracing::error!("profile load failed: {e}");
116
116
+
tracing::error!("list load failed: {e}");
77
117
HashMap::new()
78
118
}
79
119
}
+127
parakeet/src/xrpc/app_bsky/graph/lists.rs
···
1
1
+
use crate::xrpc::error::{Error, XrpcResult};
2
2
+
use crate::xrpc::{datetime_cursor, get_actor_did, ActorWithCursorQuery};
3
3
+
use crate::{hydration, GlobalState};
4
4
+
use axum::extract::{Query, State};
5
5
+
use axum::Json;
6
6
+
use diesel::prelude::*;
7
7
+
use diesel_async::RunQueryDsl;
8
8
+
use lexica::app_bsky::graph::{ListItemView, ListView};
9
9
+
use parakeet_db::{models, schema};
10
10
+
use serde::{Deserialize, Serialize};
11
11
+
12
12
+
#[derive(Debug, Deserialize)]
13
13
+
pub struct ListWithCursorQuery {
14
14
+
pub list: String,
15
15
+
pub limit: Option<u8>,
16
16
+
pub cursor: Option<String>,
17
17
+
}
18
18
+
19
19
+
#[derive(Debug, Serialize)]
20
20
+
pub struct AppBskyGraphGetListsRes {
21
21
+
#[serde(skip_serializing_if = "Option::is_none")]
22
22
+
cursor: Option<String>,
23
23
+
lists: Vec<ListView>,
24
24
+
}
25
25
+
26
26
+
pub async fn get_lists(
27
27
+
State(state): State<GlobalState>,
28
28
+
Query(query): Query<ActorWithCursorQuery>,
29
29
+
) -> XrpcResult<Json<AppBskyGraphGetListsRes>> {
30
30
+
let mut conn = state.pool.get().await?;
31
31
+
32
32
+
let did = get_actor_did(&state.dataloaders, query.actor).await?;
33
33
+
34
34
+
let limit = query.limit.unwrap_or(50).clamp(1, 100);
35
35
+
36
36
+
let mut items_query = schema::lists::table
37
37
+
.select((schema::lists::created_at, schema::lists::at_uri))
38
38
+
.filter(schema::lists::owner.eq(did))
39
39
+
.into_boxed();
40
40
+
41
41
+
if let Some(cursor) = datetime_cursor(query.cursor.as_ref()) {
42
42
+
items_query = items_query.filter(schema::lists::created_at.lt(cursor));
43
43
+
}
44
44
+
45
45
+
let results = items_query
46
46
+
.order(schema::lists::created_at.desc())
47
47
+
.limit(limit as i64)
48
48
+
.load::<(chrono::DateTime<chrono::Utc>, String)>(&mut conn)
49
49
+
.await?;
50
50
+
51
51
+
let cursor = results
52
52
+
.last()
53
53
+
.map(|(last, _)| last.timestamp_millis().to_string());
54
54
+
55
55
+
let at_uris = results.iter().map(|(_, uri)| uri.clone()).collect();
56
56
+
57
57
+
let mut lists = hydration::list::hydrate_lists(&state.dataloaders, at_uris).await;
58
58
+
59
59
+
let lists = results
60
60
+
.into_iter()
61
61
+
.filter_map(|(_, uri)| lists.remove(&uri))
62
62
+
.collect();
63
63
+
64
64
+
Ok(Json(AppBskyGraphGetListsRes { cursor, lists }))
65
65
+
}
66
66
+
67
67
+
#[derive(Debug, Serialize)]
68
68
+
pub struct AppBskyGraphGetListRes {
69
69
+
#[serde(skip_serializing_if = "Option::is_none")]
70
70
+
cursor: Option<String>,
71
71
+
list: ListView,
72
72
+
items: Vec<ListItemView>,
73
73
+
}
74
74
+
75
75
+
pub async fn get_list(
76
76
+
State(state): State<GlobalState>,
77
77
+
Query(query): Query<ListWithCursorQuery>,
78
78
+
) -> XrpcResult<Json<AppBskyGraphGetListRes>> {
79
79
+
let mut conn = state.pool.get().await?;
80
80
+
81
81
+
let Some(list) = hydration::list::hydrate_list(&state.dataloaders, query.list).await else {
82
82
+
return Err(Error::not_found());
83
83
+
};
84
84
+
85
85
+
let limit = query.limit.unwrap_or(50).clamp(1, 100);
86
86
+
87
87
+
let mut items_query = schema::list_items::table
88
88
+
.select(models::ListItem::as_select())
89
89
+
.filter(schema::list_items::list_uri.eq(&list.uri))
90
90
+
.into_boxed();
91
91
+
92
92
+
if let Some(cursor) = datetime_cursor(query.cursor.as_ref()) {
93
93
+
items_query = items_query.filter(schema::list_items::created_at.lt(cursor));
94
94
+
}
95
95
+
96
96
+
let results = items_query
97
97
+
.order(schema::list_items::created_at.desc())
98
98
+
.limit(limit as i64)
99
99
+
.load(&mut conn)
100
100
+
.await?;
101
101
+
102
102
+
let cursor = results
103
103
+
.last()
104
104
+
.map(|last| last.created_at.and_utc().timestamp_millis().to_string());
105
105
+
106
106
+
let dids = results.iter().map(|item| item.subject.clone()).collect();
107
107
+
108
108
+
let mut profiles = hydration::profile::hydrate_profiles(&state.dataloaders, dids).await;
109
109
+
110
110
+
let items = results
111
111
+
.into_iter()
112
112
+
.filter_map(|item| {
113
113
+
let subject = profiles.remove(&item.subject)?;
114
114
+
115
115
+
Some(ListItemView {
116
116
+
uri: item.at_uri,
117
117
+
subject,
118
118
+
})
119
119
+
})
120
120
+
.collect();
121
121
+
122
122
+
Ok(Json(AppBskyGraphGetListRes {
123
123
+
cursor,
124
124
+
list,
125
125
+
items,
126
126
+
}))
127
127
+
}
+1
parakeet/src/xrpc/app_bsky/graph/mod.rs
···
1
1
+
pub mod lists;
1
2
pub mod relations;
+2
parakeet/src/xrpc/app_bsky/mod.rs
···
10
10
.route("/app.bsky.actor.getProfiles", get(actor::get_profiles))
11
11
.route("/app.bsky.graph.getFollowers", get(graph::relations::get_followers))
12
12
.route("/app.bsky.graph.getFollows", get(graph::relations::get_follows))
13
13
+
.route("/app.bsky.graph.getList", get(graph::lists::get_list))
14
14
+
.route("/app.bsky.graph.getLists", get(graph::lists::get_lists))
13
15
}