Compare commits
4 Commits
4890501512
...
d50c13a2db
| Author | SHA1 | Date | |
|---|---|---|---|
| d50c13a2db | |||
| 004f3cd4d2 | |||
| 970f5a1644 | |||
| ecba9267cf |
@@ -45,9 +45,19 @@ struct FeedRow {
|
|||||||
like_count: i64,
|
like_count: i64,
|
||||||
boost_count: i64,
|
boost_count: i64,
|
||||||
reply_count: i64,
|
reply_count: i64,
|
||||||
|
liked_by_viewer: bool,
|
||||||
|
boosted_by_viewer: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
const FEED_SELECT: &str = "
|
fn feed_select(viewer: Option<uuid::Uuid>) -> String {
|
||||||
|
let viewer_checks = match viewer {
|
||||||
|
Some(uid) => format!(
|
||||||
|
"EXISTS(SELECT 1 FROM likes WHERE user_id='{uid}' AND thought_id=t.id) AS liked_by_viewer,
|
||||||
|
EXISTS(SELECT 1 FROM boosts WHERE user_id='{uid}' AND thought_id=t.id) AS boosted_by_viewer"
|
||||||
|
),
|
||||||
|
None => "false AS liked_by_viewer, false AS boosted_by_viewer".to_string(),
|
||||||
|
};
|
||||||
|
format!("
|
||||||
SELECT
|
SELECT
|
||||||
t.id AS thought_id, t.user_id AS t_user_id, t.content,
|
t.id AS thought_id, t.user_id AS t_user_id, t.content,
|
||||||
t.in_reply_to_id, t.in_reply_to_url, t.ap_id AS t_ap_id,
|
t.in_reply_to_id, t.in_reply_to_url, t.ap_id AS t_ap_id,
|
||||||
@@ -60,8 +70,10 @@ const FEED_SELECT: &str = "
|
|||||||
u.created_at AS author_created_at, u.updated_at AS author_updated_at,
|
u.created_at AS author_created_at, u.updated_at AS author_updated_at,
|
||||||
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,
|
(SELECT COUNT(*) FROM likes l WHERE l.thought_id=t.id) AS like_count,
|
||||||
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,
|
(SELECT COUNT(*) FROM boosts b WHERE b.thought_id=t.id) AS boost_count,
|
||||||
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count
|
(SELECT COUNT(*) FROM thoughts r WHERE r.in_reply_to_id=t.id) AS reply_count,
|
||||||
FROM thoughts t JOIN users u ON u.id=t.user_id";
|
{viewer_checks}
|
||||||
|
FROM thoughts t JOIN users u ON u.id=t.user_id")
|
||||||
|
}
|
||||||
|
|
||||||
fn row_to_entry(r: FeedRow) -> FeedEntry {
|
fn row_to_entry(r: FeedRow) -> FeedEntry {
|
||||||
let thought = Thought {
|
let thought = Thought {
|
||||||
@@ -89,18 +101,20 @@ fn row_to_entry(r: FeedRow) -> FeedEntry {
|
|||||||
public_key: r.public_key, private_key: r.private_key,
|
public_key: r.public_key, private_key: r.private_key,
|
||||||
created_at: r.author_created_at, updated_at: r.author_updated_at,
|
created_at: r.author_created_at, updated_at: r.author_updated_at,
|
||||||
};
|
};
|
||||||
FeedEntry { thought, author, like_count: r.like_count, boost_count: r.boost_count, reply_count: r.reply_count, liked_by_viewer: false, boosted_by_viewer: false }
|
FeedEntry { thought, author, like_count: r.like_count, boost_count: r.boost_count, reply_count: r.reply_count, liked_by_viewer: r.liked_by_viewer, boosted_by_viewer: r.boosted_by_viewer }
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
impl FeedRepository for PgFeedRepository {
|
impl FeedRepository for PgFeedRepository {
|
||||||
async fn home_feed(&self, following_ids: &[UserId], page: &PageParams, _viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
async fn home_feed(&self, following_ids: &[UserId], page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
|
let ids: Vec<uuid::Uuid> = following_ids.iter().map(|id| id.as_uuid()).collect();
|
||||||
|
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||||
let total: i64 = sqlx::query_scalar(
|
let total: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id=ANY($1) AND t.visibility='public'"
|
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id=ANY($1) AND t.visibility='public'"
|
||||||
).bind(&ids).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
).bind(&ids).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
let sql = format!("{FEED_SELECT} WHERE t.user_id=ANY($1) AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
|
let sel = feed_select(viewer);
|
||||||
|
let sql = format!("{sel} WHERE t.user_id=ANY($1) AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
|
||||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||||
.bind(&ids).bind(page.limit()).bind(page.offset())
|
.bind(&ids).bind(page.limit()).bind(page.offset())
|
||||||
.fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
@@ -108,12 +122,14 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
Ok(Paginated { items: rows.into_iter().map(row_to_entry).collect(), total, page: page.page, per_page: page.per_page })
|
Ok(Paginated { items: rows.into_iter().map(row_to_entry).collect(), total, page: page.page, per_page: page.per_page })
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn public_feed(&self, page: &PageParams, _viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
async fn public_feed(&self, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||||
let total: i64 = sqlx::query_scalar(
|
let total: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'"
|
"SELECT COUNT(*) FROM thoughts t WHERE t.local=true AND t.visibility='public'"
|
||||||
).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
).fetch_one(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
let sql = format!("{FEED_SELECT} WHERE t.local=true AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $1 OFFSET $2");
|
let sel = feed_select(viewer);
|
||||||
|
let sql = format!("{sel} WHERE t.local=true AND t.visibility='public' ORDER BY t.created_at DESC LIMIT $1 OFFSET $2");
|
||||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||||
.bind(page.limit()).bind(page.offset())
|
.bind(page.limit()).bind(page.offset())
|
||||||
.fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.fetch_all(&self.pool).await.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
@@ -121,7 +137,8 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
Ok(Paginated { items: rows.into_iter().map(row_to_entry).collect(), total, page: page.page, per_page: page.per_page })
|
Ok(Paginated { items: rows.into_iter().map(row_to_entry).collect(), total, page: page.page, per_page: page.per_page })
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn search(&self, query: &str, page: &PageParams, _viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
async fn search(&self, query: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||||
let total: i64 = sqlx::query_scalar(
|
let total: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'"
|
"SELECT COUNT(*) FROM thoughts t WHERE t.content % $1 AND t.visibility='public'"
|
||||||
)
|
)
|
||||||
@@ -130,7 +147,8 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
let sql = format!("{FEED_SELECT} WHERE t.content % $1 AND t.visibility='public' ORDER BY similarity(t.content, $1) DESC LIMIT $2 OFFSET $3");
|
let sel = feed_select(viewer);
|
||||||
|
let sql = format!("{sel} WHERE t.content % $1 AND t.visibility='public' ORDER BY similarity(t.content, $1) DESC LIMIT $2 OFFSET $3");
|
||||||
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||||
.bind(query)
|
.bind(query)
|
||||||
.bind(page.limit())
|
.bind(page.limit())
|
||||||
@@ -142,7 +160,8 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
Ok(Paginated { items: rows.into_iter().map(row_to_entry).collect(), total, page: page.page, per_page: page.per_page })
|
Ok(Paginated { items: rows.into_iter().map(row_to_entry).collect(), total, page: page.page, per_page: page.per_page })
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn tag_feed(&self, tag_name: &str, page: &PageParams, _viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
async fn tag_feed(&self, tag_name: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||||
let total: i64 = sqlx::query_scalar(
|
let total: i64 = sqlx::query_scalar(
|
||||||
"SELECT COUNT(*) FROM thoughts t
|
"SELECT COUNT(*) FROM thoughts t
|
||||||
JOIN thought_tags tt ON tt.thought_id = t.id
|
JOIN thought_tags tt ON tt.thought_id = t.id
|
||||||
@@ -154,8 +173,9 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let sel = feed_select(viewer);
|
||||||
let sql = format!(
|
let sql = format!(
|
||||||
"{FEED_SELECT}
|
"{sel}
|
||||||
JOIN thought_tags tt ON tt.thought_id = t.id
|
JOIN thought_tags tt ON tt.thought_id = t.id
|
||||||
JOIN tags tg ON tg.id = tt.tag_id
|
JOIN tags tg ON tg.id = tt.tag_id
|
||||||
WHERE tg.name = $1 AND t.visibility = 'public'
|
WHERE tg.name = $1 AND t.visibility = 'public'
|
||||||
@@ -176,6 +196,36 @@ impl FeedRepository for PgFeedRepository {
|
|||||||
per_page: page.per_page,
|
per_page: page.per_page,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn user_feed(&self, user_id: &UserId, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
let viewer = viewer_id.map(|v| v.as_uuid());
|
||||||
|
let uid = user_id.as_uuid();
|
||||||
|
|
||||||
|
let total: i64 = sqlx::query_scalar(
|
||||||
|
"SELECT COUNT(*) FROM thoughts t WHERE t.user_id = $1 AND t.visibility = 'public'"
|
||||||
|
)
|
||||||
|
.bind(uid)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
let sel = feed_select(viewer);
|
||||||
|
let sql = format!("{sel} WHERE t.user_id = $1 AND t.visibility = 'public' ORDER BY t.created_at DESC LIMIT $2 OFFSET $3");
|
||||||
|
let rows = sqlx::query_as::<_, FeedRow>(&sql)
|
||||||
|
.bind(uid)
|
||||||
|
.bind(page.limit())
|
||||||
|
.bind(page.offset())
|
||||||
|
.fetch_all(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
|
Ok(Paginated {
|
||||||
|
items: rows.into_iter().map(row_to_entry).collect(),
|
||||||
|
total,
|
||||||
|
page: page.page,
|
||||||
|
per_page: page.per_page,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -4,12 +4,11 @@ use sqlx::PgPool;
|
|||||||
use domain::{
|
use domain::{
|
||||||
errors::DomainError,
|
errors::DomainError,
|
||||||
models::{
|
models::{
|
||||||
feed::{FeedEntry, PageParams, Paginated},
|
feed::{PageParams, Paginated},
|
||||||
thought::{Thought, Visibility},
|
thought::{Thought, Visibility},
|
||||||
user::User,
|
|
||||||
},
|
},
|
||||||
ports::ThoughtRepository,
|
ports::ThoughtRepository,
|
||||||
value_objects::{Content, Email, PasswordHash, ThoughtId, UserId, Username},
|
value_objects::{Content, ThoughtId, UserId},
|
||||||
};
|
};
|
||||||
|
|
||||||
pub struct PgThoughtRepository { pool: PgPool }
|
pub struct PgThoughtRepository { pool: PgPool }
|
||||||
@@ -119,47 +118,32 @@ impl ThoughtRepository for PgThoughtRepository {
|
|||||||
.map(|rows| rows.into_iter().map(Thought::from).collect())
|
.map(|rows| rows.into_iter().map(Thought::from).collect())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn list_by_user(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<FeedEntry>, DomainError> {
|
async fn list_by_user(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<Thought>, DomainError> {
|
||||||
let total: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM thoughts WHERE user_id=$1")
|
let uid = user_id.as_uuid();
|
||||||
.bind(user_id.as_uuid())
|
let total: i64 = sqlx::query_scalar(
|
||||||
.fetch_one(&self.pool)
|
"SELECT COUNT(*) FROM thoughts WHERE user_id = $1"
|
||||||
.await
|
)
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.bind(uid)
|
||||||
|
.fetch_one(&self.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
let rows = sqlx::query_as::<_, ThoughtRow>(
|
let rows = sqlx::query_as::<_, ThoughtRow>(
|
||||||
&format!("{THOUGHT_SELECT} WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3")
|
&format!("{THOUGHT_SELECT} WHERE user_id=$1 ORDER BY created_at DESC LIMIT $2 OFFSET $3")
|
||||||
)
|
)
|
||||||
.bind(user_id.as_uuid())
|
.bind(uid)
|
||||||
.bind(page.limit())
|
.bind(page.limit())
|
||||||
.bind(page.offset())
|
.bind(page.offset())
|
||||||
.fetch_all(&self.pool)
|
.fetch_all(&self.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
.map_err(|e| DomainError::Internal(e.to_string()))?;
|
||||||
|
|
||||||
let author = sqlx::query_as::<_, crate::user::UserRow>(
|
Ok(Paginated {
|
||||||
"SELECT id,username,email,password_hash,display_name,bio,avatar_url,header_url,custom_css,local,ap_id,inbox_url,public_key,private_key,created_at,updated_at FROM users WHERE id=$1"
|
items: rows.into_iter().map(Thought::from).collect(),
|
||||||
)
|
total,
|
||||||
.bind(user_id.as_uuid())
|
page: page.page,
|
||||||
.fetch_optional(&self.pool)
|
per_page: page.per_page,
|
||||||
.await
|
})
|
||||||
.map_err(|e| DomainError::Internal(e.to_string()))?
|
|
||||||
.ok_or(DomainError::NotFound)?;
|
|
||||||
let author = User::from(author);
|
|
||||||
|
|
||||||
let items = rows.into_iter().map(|r| {
|
|
||||||
let thought = Thought::from(r);
|
|
||||||
FeedEntry {
|
|
||||||
thought,
|
|
||||||
author: author.clone(),
|
|
||||||
like_count: 0,
|
|
||||||
boost_count: 0,
|
|
||||||
reply_count: 0,
|
|
||||||
liked_by_viewer: false,
|
|
||||||
boosted_by_viewer: false,
|
|
||||||
}
|
|
||||||
}).collect();
|
|
||||||
|
|
||||||
Ok(Paginated { items, total, page: page.page, per_page: page.per_page })
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use domain::{
|
|||||||
feed::{FeedEntry, PageParams, Paginated, UserSummary},
|
feed::{FeedEntry, PageParams, Paginated, UserSummary},
|
||||||
user::User,
|
user::User,
|
||||||
},
|
},
|
||||||
ports::{FeedRepository, FollowRepository, ThoughtRepository, UserRepository},
|
ports::{FeedRepository, FollowRepository, TagRepository, UserRepository},
|
||||||
value_objects::UserId,
|
value_objects::UserId,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -17,8 +17,8 @@ pub async fn get_public_feed(feed: &dyn FeedRepository, viewer_id: Option<&UserI
|
|||||||
feed.public_feed(&page, viewer_id).await
|
feed.public_feed(&page, viewer_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_user_feed(thoughts: &dyn ThoughtRepository, user_id: &UserId, page: PageParams) -> Result<Paginated<FeedEntry>, DomainError> {
|
pub async fn get_user_feed(feed: &dyn FeedRepository, user_id: &UserId, page: PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
thoughts.list_by_user(user_id, &page).await
|
feed.user_feed(user_id, &page, viewer_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_followers(follows: &dyn FollowRepository, user_id: &UserId, page: PageParams) -> Result<Paginated<User>, DomainError> {
|
pub async fn get_followers(follows: &dyn FollowRepository, user_id: &UserId, page: PageParams) -> Result<Paginated<User>, DomainError> {
|
||||||
@@ -29,8 +29,8 @@ pub async fn get_following(follows: &dyn FollowRepository, user_id: &UserId, pag
|
|||||||
follows.list_following(user_id, &page).await
|
follows.list_following(user_id, &page).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn get_by_tag(feed: &dyn FeedRepository, tag_name: &str, page: PageParams) -> Result<Paginated<FeedEntry>, DomainError> {
|
pub async fn get_by_tag(feed: &dyn FeedRepository, tag_name: &str, page: PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
feed.tag_feed(tag_name, &page, None).await
|
feed.tag_feed(tag_name, &page, viewer_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn search(feed: &dyn FeedRepository, query: &str, page: PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
pub async fn search(feed: &dyn FeedRepository, query: &str, page: PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
@@ -40,3 +40,7 @@ pub async fn search(feed: &dyn FeedRepository, query: &str, page: PageParams, vi
|
|||||||
pub async fn list_users(users: &dyn UserRepository) -> Result<Vec<UserSummary>, DomainError> {
|
pub async fn list_users(users: &dyn UserRepository) -> Result<Vec<UserSummary>, DomainError> {
|
||||||
users.list_with_stats().await
|
users.list_with_stats().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn get_popular_tags(tags: &dyn TagRepository, limit: usize) -> Result<Vec<(String, i64)>, DomainError> {
|
||||||
|
tags.popular_tags(limit).await
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
pub mod api_keys;
|
pub mod api_keys;
|
||||||
pub mod auth;
|
pub mod auth;
|
||||||
pub mod feed;
|
pub mod feed;
|
||||||
|
pub mod notifications;
|
||||||
pub mod profile;
|
pub mod profile;
|
||||||
|
pub mod search;
|
||||||
pub mod social;
|
pub mod social;
|
||||||
pub mod thoughts;
|
pub mod thoughts;
|
||||||
|
|||||||
30
crates/application/src/use_cases/notifications.rs
Normal file
30
crates/application/src/use_cases/notifications.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::feed::{PageParams, Paginated},
|
||||||
|
models::notification::Notification,
|
||||||
|
ports::NotificationRepository,
|
||||||
|
value_objects::{NotificationId, UserId},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn list_notifications(
|
||||||
|
repo: &dyn NotificationRepository,
|
||||||
|
user_id: &UserId,
|
||||||
|
page: PageParams,
|
||||||
|
) -> Result<Paginated<Notification>, DomainError> {
|
||||||
|
repo.list_for_user(user_id, &page).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_notification_read(
|
||||||
|
repo: &dyn NotificationRepository,
|
||||||
|
id: &NotificationId,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
repo.mark_read(id, user_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn mark_all_notifications_read(
|
||||||
|
repo: &dyn NotificationRepository,
|
||||||
|
user_id: &UserId,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
repo.mark_all_read(user_id).await
|
||||||
|
}
|
||||||
26
crates/application/src/use_cases/search.rs
Normal file
26
crates/application/src/use_cases/search.rs
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
use domain::{
|
||||||
|
errors::DomainError,
|
||||||
|
models::{
|
||||||
|
feed::{FeedEntry, PageParams, Paginated},
|
||||||
|
user::User,
|
||||||
|
},
|
||||||
|
ports::SearchPort,
|
||||||
|
value_objects::UserId,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub async fn search_thoughts(
|
||||||
|
search: &dyn SearchPort,
|
||||||
|
query: &str,
|
||||||
|
page: PageParams,
|
||||||
|
viewer_id: Option<&UserId>,
|
||||||
|
) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
search.search_thoughts(query, &page, viewer_id).await
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn search_users(
|
||||||
|
search: &dyn SearchPort,
|
||||||
|
query: &str,
|
||||||
|
page: PageParams,
|
||||||
|
) -> Result<Paginated<User>, DomainError> {
|
||||||
|
search.search_users(query, &page).await
|
||||||
|
}
|
||||||
@@ -5,6 +5,14 @@ use std::net::SocketAddr;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tower_http::cors::{AllowOrigin, CorsLayer};
|
use tower_http::cors::{AllowOrigin, CorsLayer};
|
||||||
use tracing_subscriber::EnvFilter;
|
use tracing_subscriber::EnvFilter;
|
||||||
|
use activitypub_base::{
|
||||||
|
actor_handler::actor_handler,
|
||||||
|
followers_handler::{followers_handler, following_handler},
|
||||||
|
inbox::inbox_handler,
|
||||||
|
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
|
||||||
|
outbox::outbox_handler,
|
||||||
|
webfinger::webfinger_handler,
|
||||||
|
};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
@@ -32,7 +40,19 @@ async fn main() {
|
|||||||
.allow_headers(tower_http::cors::Any)
|
.allow_headers(tower_http::cors::Any)
|
||||||
};
|
};
|
||||||
|
|
||||||
let base = presentation::routes::router(&infra.fed_config)
|
let ap_router = axum::Router::new()
|
||||||
|
.route("/.well-known/webfinger", axum::routing::get(webfinger_handler))
|
||||||
|
.route("/.well-known/nodeinfo", axum::routing::get(nodeinfo_well_known_handler))
|
||||||
|
.route("/nodeinfo/2.0", axum::routing::get(nodeinfo_handler))
|
||||||
|
.route("/users/{username}", axum::routing::get(actor_handler))
|
||||||
|
.route("/users/{username}/inbox", axum::routing::post(inbox_handler))
|
||||||
|
.route("/users/{username}/outbox", axum::routing::get(outbox_handler))
|
||||||
|
.route("/users/{username}/followers", axum::routing::get(followers_handler))
|
||||||
|
.route("/users/{username}/following", axum::routing::get(following_handler))
|
||||||
|
.layer(infra.fed_config.middleware());
|
||||||
|
|
||||||
|
let base = presentation::routes::router()
|
||||||
|
.merge(ap_router)
|
||||||
.with_state(infra.state)
|
.with_state(infra.state)
|
||||||
.layer(cors);
|
.layer(cors);
|
||||||
|
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ pub trait ThoughtRepository: Send + Sync {
|
|||||||
async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError>;
|
async fn delete(&self, id: &ThoughtId, user_id: &UserId) -> Result<(), DomainError>;
|
||||||
async fn update_content(&self, id: &ThoughtId, content: &Content) -> Result<(), DomainError>;
|
async fn update_content(&self, id: &ThoughtId, content: &Content) -> Result<(), DomainError>;
|
||||||
async fn get_thread(&self, id: &ThoughtId) -> Result<Vec<Thought>, DomainError>;
|
async fn get_thread(&self, id: &ThoughtId) -> Result<Vec<Thought>, DomainError>;
|
||||||
async fn list_by_user(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<FeedEntry>, DomainError>;
|
async fn list_by_user(&self, user_id: &UserId, page: &PageParams) -> Result<Paginated<Thought>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -139,6 +139,7 @@ pub trait FeedRepository: Send + Sync {
|
|||||||
async fn public_feed(&self, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError>;
|
async fn public_feed(&self, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError>;
|
||||||
async fn search(&self, query: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError>;
|
async fn search(&self, query: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError>;
|
||||||
async fn tag_feed(&self, tag_name: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError>;
|
async fn tag_feed(&self, tag_name: &str, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError>;
|
||||||
|
async fn user_feed(&self, user_id: &UserId, page: &PageParams, viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
|
|||||||
@@ -96,7 +96,7 @@ pub struct TestStore {
|
|||||||
.filter(|t| t.in_reply_to_id.as_ref() == Some(id) || &t.id == id)
|
.filter(|t| t.in_reply_to_id.as_ref() == Some(id) || &t.id == id)
|
||||||
.cloned().collect())
|
.cloned().collect())
|
||||||
}
|
}
|
||||||
async fn list_by_user(&self, _user_id: &UserId, _page: &PageParams) -> Result<Paginated<FeedEntry>, DomainError> {
|
async fn list_by_user(&self, _user_id: &UserId, _page: &PageParams) -> Result<Paginated<Thought>, DomainError> {
|
||||||
Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 })
|
Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,6 +290,9 @@ pub struct TestStore {
|
|||||||
async fn tag_feed(&self, _tag_name: &str, _page: &PageParams, _viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
async fn tag_feed(&self, _tag_name: &str, _page: &PageParams, _viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 })
|
Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 })
|
||||||
}
|
}
|
||||||
|
async fn user_feed(&self, _user_id: &UserId, _page: &PageParams, _viewer_id: Option<&UserId>) -> Result<Paginated<FeedEntry>, DomainError> {
|
||||||
|
Ok(Paginated { items: vec![], total: 0, page: 1, per_page: 20 })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[async_trait] impl SearchPort for TestStore {
|
#[async_trait] impl SearchPort for TestStore {
|
||||||
|
|||||||
@@ -18,9 +18,7 @@ tracing = { workspace = true }
|
|||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
activitypub-base = { workspace = true }
|
|
||||||
url = { workspace = true }
|
url = { workspace = true }
|
||||||
activitypub_federation = "0.7.0-beta.11"
|
|
||||||
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] }
|
utoipa = { version = "5.5.0", features = ["axum_extras", "uuid", "chrono"] }
|
||||||
utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false }
|
utoipa-scalar = { version = "0.3.0", features = ["axum"], default-features = false }
|
||||||
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }
|
utoipa-swagger-ui = { version = "9.0.2", features = ["axum", "vendored"] }
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
use axum::{extract::{Path, Query, State}, Json};
|
use axum::{extract::{Path, Query, State}, Json};
|
||||||
use api_types::requests::{PaginationQuery, SearchQuery};
|
use api_types::requests::{PaginationQuery, SearchQuery};
|
||||||
use api_types::responses::ThoughtResponse;
|
use api_types::responses::ThoughtResponse;
|
||||||
use application::use_cases::feed::{get_home_feed, get_public_feed, get_followers, get_following, get_user_feed, get_by_tag};
|
use application::use_cases::feed::{get_home_feed, get_public_feed, get_followers, get_following, get_user_feed, get_by_tag, get_popular_tags as uc_get_popular_tags};
|
||||||
|
use application::use_cases::search::{search_thoughts, search_users};
|
||||||
use domain::models::feed::PageParams;
|
use domain::models::feed::PageParams;
|
||||||
use crate::{errors::ApiError, extractors::{AuthUser, OptionalAuthUser}, handlers::auth::to_user_response, state::AppState};
|
use crate::{errors::ApiError, extractors::{AuthUser, OptionalAuthUser}, handlers::auth::to_user_response, state::AppState};
|
||||||
use application::use_cases::profile::get_user_by_username;
|
use application::use_cases::profile::get_user_by_username;
|
||||||
@@ -72,8 +73,8 @@ pub async fn search_handler(
|
|||||||
let query = q.q.trim().to_string();
|
let query = q.q.trim().to_string();
|
||||||
|
|
||||||
let (thoughts_result, users_result) = tokio::join!(
|
let (thoughts_result, users_result) = tokio::join!(
|
||||||
s.search.search_thoughts(&query, &page, viewer.as_ref()),
|
search_thoughts(&*s.search, &query, PageParams { page: page.page, per_page: page.per_page }, viewer.as_ref()),
|
||||||
s.search.search_users(&query, &page),
|
search_users(&*s.search, &query, PageParams { page: page.page, per_page: page.per_page }),
|
||||||
);
|
);
|
||||||
|
|
||||||
let thoughts = thoughts_result?.items.into_iter().map(|e| serde_json::json!({
|
let thoughts = thoughts_result?.items.into_iter().map(|e| serde_json::json!({
|
||||||
@@ -120,11 +121,12 @@ pub async fn get_followers_handler(State(s): State<AppState>, Path(username): Pa
|
|||||||
pub async fn user_thoughts_handler(
|
pub async fn user_thoughts_handler(
|
||||||
State(s): State<AppState>,
|
State(s): State<AppState>,
|
||||||
Path(username): Path<String>,
|
Path(username): Path<String>,
|
||||||
|
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let user = get_user_by_username(&*s.users, &username).await?;
|
let user = get_user_by_username(&*s.users, &username).await?;
|
||||||
let page = PageParams { page: q.page(), per_page: q.per_page() };
|
let page = PageParams { page: q.page(), per_page: q.per_page() };
|
||||||
let result = get_user_feed(&*s.thoughts, &user.id, page).await?;
|
let result = get_user_feed(&*s.feed, &user.id, page, viewer.as_ref()).await?;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"total": result.total,
|
"total": result.total,
|
||||||
"page": result.page,
|
"page": result.page,
|
||||||
@@ -138,7 +140,7 @@ pub async fn get_popular_tags(
|
|||||||
Query(params): Query<std::collections::HashMap<String, String>>,
|
Query(params): Query<std::collections::HashMap<String, String>>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let limit: usize = params.get("limit").and_then(|v| v.parse().ok()).unwrap_or(20);
|
let limit: usize = params.get("limit").and_then(|v| v.parse().ok()).unwrap_or(20);
|
||||||
let tags = s.tags.popular_tags(limit.min(100)).await?;
|
let tags = uc_get_popular_tags(&*s.tags, limit.min(100)).await?;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"tags": tags.iter().map(|(name, count)| serde_json::json!({
|
"tags": tags.iter().map(|(name, count)| serde_json::json!({
|
||||||
"name": name,
|
"name": name,
|
||||||
@@ -158,10 +160,11 @@ pub async fn get_popular_tags(
|
|||||||
pub async fn tag_thoughts_handler(
|
pub async fn tag_thoughts_handler(
|
||||||
State(s): State<AppState>,
|
State(s): State<AppState>,
|
||||||
Path(tag_name): Path<String>,
|
Path(tag_name): Path<String>,
|
||||||
|
OptionalAuthUser(viewer): OptionalAuthUser,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let page = PageParams { page: q.page(), per_page: q.per_page() };
|
let page = PageParams { page: q.page(), per_page: q.per_page() };
|
||||||
let result = get_by_tag(&*s.feed, &tag_name, page).await?;
|
let result = get_by_tag(&*s.feed, &tag_name, page, viewer.as_ref()).await?;
|
||||||
Ok(Json(serde_json::json!({
|
Ok(Json(serde_json::json!({
|
||||||
"tag": tag_name,
|
"tag": tag_name,
|
||||||
"total": result.total,
|
"total": result.total,
|
||||||
|
|||||||
@@ -1,21 +1,28 @@
|
|||||||
use axum::{extract::{Path, State}, http::StatusCode, Json};
|
use axum::{extract::{Path, State}, http::StatusCode, Json};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use domain::{models::feed::PageParams, value_objects::NotificationId};
|
use domain::{models::feed::PageParams, value_objects::NotificationId};
|
||||||
|
use application::use_cases::notifications::{
|
||||||
|
list_notifications as uc_list_notifications,
|
||||||
|
mark_notification_read as uc_mark_notification_read,
|
||||||
|
mark_all_notifications_read,
|
||||||
|
};
|
||||||
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
use crate::{errors::ApiError, extractors::AuthUser, state::AppState};
|
||||||
|
|
||||||
#[utoipa::path(get, path = "/notifications", responses((status = 200, description = "Notification summary")), security(("bearer_auth" = [])))]
|
#[utoipa::path(get, path = "/notifications", responses((status = 200, description = "Notification summary")), security(("bearer_auth" = [])))]
|
||||||
pub async fn list_notifications(State(s): State<AppState>, AuthUser(uid): AuthUser) -> Result<Json<serde_json::Value>, ApiError> {
|
pub async fn list_notifications(State(s): State<AppState>, AuthUser(uid): AuthUser) -> Result<Json<serde_json::Value>, ApiError> {
|
||||||
let page = PageParams { page: 1, per_page: 20 };
|
let page = PageParams { page: 1, per_page: 20 };
|
||||||
let result = s.notifications.list_for_user(&uid, &page).await?;
|
let result = uc_list_notifications(&*s.notifications, &uid, page).await?;
|
||||||
Ok(Json(serde_json::json!({ "total": result.total, "unread": result.items.iter().filter(|n| !n.read).count() })))
|
Ok(Json(serde_json::json!({ "total": result.total, "unread": result.items.iter().filter(|n| !n.read).count() })))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(post, path = "/notifications/{id}/read", params(("id" = uuid::Uuid, Path, description = "Notification ID")), responses((status = 204, description = "Marked read")), security(("bearer_auth" = [])))]
|
#[utoipa::path(post, path = "/notifications/{id}/read", params(("id" = uuid::Uuid, Path, description = "Notification ID")), responses((status = 204, description = "Marked read")), security(("bearer_auth" = [])))]
|
||||||
pub async fn mark_notification_read(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
pub async fn mark_notification_read(State(s): State<AppState>, AuthUser(uid): AuthUser, Path(id): Path<Uuid>) -> Result<StatusCode, ApiError> {
|
||||||
s.notifications.mark_read(&NotificationId::from_uuid(id), &uid).await?;
|
uc_mark_notification_read(&*s.notifications, &NotificationId::from_uuid(id), &uid).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(post, path = "/notifications/read-all", responses((status = 204, description = "All marked read")), security(("bearer_auth" = [])))]
|
#[utoipa::path(post, path = "/notifications/read-all", responses((status = 204, description = "All marked read")), security(("bearer_auth" = [])))]
|
||||||
pub async fn mark_all_read(State(s): State<AppState>, AuthUser(uid): AuthUser) -> Result<StatusCode, ApiError> {
|
pub async fn mark_all_read(State(s): State<AppState>, AuthUser(uid): AuthUser) -> Result<StatusCode, ApiError> {
|
||||||
s.notifications.mark_all_read(&uid).await?;
|
mark_all_notifications_read(&*s.notifications, &uid).await?;
|
||||||
Ok(StatusCode::NO_CONTENT)
|
Ok(StatusCode::NO_CONTENT)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
use axum::{extract::{Path, Query, State}, Json};
|
use axum::{extract::{Path, Query, State}, Json};
|
||||||
use api_types::{requests::UpdateProfileRequest, responses::{ErrorResponse, UserResponse}};
|
use api_types::{requests::UpdateProfileRequest, responses::{ErrorResponse, UserResponse}};
|
||||||
use application::use_cases::profile::{get_user_by_username, update_profile};
|
use application::use_cases::profile::{get_user_by_username, update_profile};
|
||||||
|
use application::use_cases::search::search_users;
|
||||||
|
use application::use_cases::feed::list_users;
|
||||||
use crate::{errors::ApiError, extractors::AuthUser, handlers::auth::to_user_response, state::AppState};
|
use crate::{errors::ApiError, extractors::AuthUser, handlers::auth::to_user_response, state::AppState};
|
||||||
|
|
||||||
#[utoipa::path(
|
#[utoipa::path(
|
||||||
@@ -54,14 +56,14 @@ pub async fn get_users(
|
|||||||
let page_params = PageParams { page, per_page };
|
let page_params = PageParams { page, per_page };
|
||||||
|
|
||||||
if let Some(q) = params.get("q").filter(|q| !q.trim().is_empty()) {
|
if let Some(q) = params.get("q").filter(|q| !q.trim().is_empty()) {
|
||||||
let result = s.search.search_users(q, &page_params).await?;
|
let result = search_users(&*s.search, q, page_params).await?;
|
||||||
let users: Vec<_> = result.items.iter().map(|u| crate::handlers::auth::to_user_response(u)).collect();
|
let users: Vec<_> = result.items.iter().map(|u| crate::handlers::auth::to_user_response(u)).collect();
|
||||||
return Ok(Json(serde_json::json!({
|
return Ok(Json(serde_json::json!({
|
||||||
"items": users, "total": result.total, "page": result.page, "per_page": result.per_page
|
"items": users, "total": result.total, "page": result.page, "per_page": result.per_page
|
||||||
})));
|
})));
|
||||||
}
|
}
|
||||||
|
|
||||||
let all = s.users.list_with_stats().await?;
|
let all = list_users(&*s.users).await?;
|
||||||
let total = all.len() as i64;
|
let total = all.len() as i64;
|
||||||
let start = ((page - 1) * per_page) as usize;
|
let start = ((page - 1) * per_page) as usize;
|
||||||
let items: Vec<_> = all.into_iter()
|
let items: Vec<_> = all.into_iter()
|
||||||
|
|||||||
@@ -1,20 +1,10 @@
|
|||||||
use axum::{
|
use axum::{
|
||||||
routing::{delete, get, patch, post, put},
|
routing::{delete, get, post, put},
|
||||||
Router,
|
Router,
|
||||||
};
|
};
|
||||||
use activitypub_base::{
|
|
||||||
actor_handler::actor_handler,
|
|
||||||
followers_handler::{followers_handler, following_handler},
|
|
||||||
inbox::inbox_handler,
|
|
||||||
nodeinfo::{nodeinfo_handler, nodeinfo_well_known_handler},
|
|
||||||
outbox::outbox_handler,
|
|
||||||
webfinger::webfinger_handler,
|
|
||||||
ApFederationConfig,
|
|
||||||
};
|
|
||||||
use activitypub_federation::config::FederationMiddleware;
|
|
||||||
use crate::{handlers::*, openapi, state::AppState};
|
use crate::{handlers::*, openapi, state::AppState};
|
||||||
|
|
||||||
pub fn router(fed_config: &ApFederationConfig) -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
let api_routes = Router::new()
|
let api_routes = Router::new()
|
||||||
// health
|
// health
|
||||||
.route("/health", get(health::health_handler))
|
.route("/health", get(health::health_handler))
|
||||||
@@ -74,20 +64,5 @@ pub fn router(fed_config: &ApFederationConfig) -> Router<AppState> {
|
|||||||
)
|
)
|
||||||
.route("/api-keys/{id}", delete(api_keys::delete_api_key_handler));
|
.route("/api-keys/{id}", delete(api_keys::delete_api_key_handler));
|
||||||
|
|
||||||
let ap_routes = Router::new()
|
openapi::serve(api_routes)
|
||||||
.route("/.well-known/webfinger", get(webfinger_handler))
|
|
||||||
.route("/.well-known/nodeinfo", get(nodeinfo_well_known_handler))
|
|
||||||
.route("/nodeinfo/2.0", get(nodeinfo_handler))
|
|
||||||
.route("/users/{username}", get(actor_handler))
|
|
||||||
.route("/users/{username}/inbox", post(inbox_handler))
|
|
||||||
.route("/users/{username}/outbox", get(outbox_handler))
|
|
||||||
.route("/users/{username}/followers", get(followers_handler))
|
|
||||||
.route("/users/{username}/following", get(following_handler));
|
|
||||||
|
|
||||||
let combined = Router::new()
|
|
||||||
.merge(api_routes)
|
|
||||||
.merge(ap_routes)
|
|
||||||
.layer(FederationMiddleware::new(fed_config.0.clone()));
|
|
||||||
|
|
||||||
openapi::serve(combined)
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user