diff --git a/Cargo.lock b/Cargo.lock index 48c1fdf..de65306 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1519,6 +1519,7 @@ dependencies = [ "regex", "serde", "serde_json", + "serde_urlencoded", "strum", "thiserror", "tokio", diff --git a/Cargo.toml b/Cargo.toml index 050d458..e205fae 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -115,6 +115,7 @@ percent-encoding = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +serde_urlencoded = { workspace = true } toml = { workspace = true } unicode-segmentation = { workspace = true } diff --git a/core/src/web_model/mod.rs b/core/src/web_model/mod.rs index 71813a4..6245ae2 100644 --- a/core/src/web_model/mod.rs +++ b/core/src/web_model/mod.rs @@ -112,6 +112,8 @@ pub mod rel { link_rel!(pub RelWebFingerProfilePage, "http://webfinger.net/rel/profile-page"); link_rel!(pub RelSelf, "self"); + link_rel!(pub RelNext, "next"); + link_rel!(pub RelPrev, "prev"); link_rel!(pub RelOStatusSubscribe, "http://ostatus.org/schema/1.0/subscribe"); link_rel!(pub RelNodeInfo20, "http://nodeinfo.diaspora.software/ns/schema/2.0"); link_rel!(pub RelNodeInfo21, "http://nodeinfo.diaspora.software/ns/schema/2.1"); diff --git a/ext_calckey_model/src/lib.rs b/ext_calckey_model/src/lib.rs index 46845c8..8293802 100644 --- a/ext_calckey_model/src/lib.rs +++ b/ext_calckey_model/src/lib.rs @@ -1,7 +1,6 @@ pub mod emoji; pub mod model_ext; pub mod note_model; -pub mod paginated; pub mod poll; pub mod user_model; @@ -27,7 +26,7 @@ use thiserror::Error; use tokio::select; use tokio_util::sync::CancellationToken; use tracing::log::LevelFilter; -use tracing::{error, info}; +use tracing::{error, info, trace}; #[derive(Debug)] pub struct ConnectorConfig { @@ -445,7 +444,7 @@ impl CalckeySub { } }; - println!("Got message: {:#?}", parsed); + trace!("Got message: {:#?}", parsed); handler(parsed).await; } diff --git a/ext_calckey_model/src/model_ext.rs b/ext_calckey_model/src/model_ext.rs index 50d6b65..fa7a204 100644 --- a/ext_calckey_model/src/model_ext.rs +++ b/ext_calckey_model/src/model_ext.rs @@ -1,9 +1,12 @@ +use chrono::{DateTime, Utc}; use ext_calckey_model_migration::{ Alias, Expr, IntoIden, Quote, SelectExpr, SelectStatement, TableRef, }; +use magnetar_sdk::types::SpanFilter; use sea_orm::{ - ColumnTrait, Condition, DynIden, EntityTrait, Iden, Iterable, JoinType, RelationDef, - RelationTrait, + ColumnTrait, Condition, ConnectionTrait, Cursor, CursorTrait, DbErr, DynIden, EntityTrait, + FromQueryResult, Iden, IntoIdentity, Iterable, JoinType, RelationDef, RelationTrait, Select, + SelectModel, SelectorTrait, }; use std::fmt::Write; @@ -126,12 +129,21 @@ impl SelectColumnsExt for SelectStatement { } pub trait AliasColumnExt { + fn col_tuple(&self, col: impl IntoIden) -> (MagIden, MagIden); + fn col(&self, col: impl IntoIden) -> Expr; } impl AliasColumnExt for T { + fn col_tuple(&self, col: impl IntoIden) -> (MagIden, MagIden) { + ( + MagIden::alias(&self.to_string()), + MagIden::DynIden(col.into_iden()), + ) + } + fn col(&self, col: impl IntoIden) -> Expr { - Expr::col((Alias::new(self.to_string()).into_iden(), col.into_iden())) + Expr::col(self.col_tuple(col)) } } @@ -181,6 +193,8 @@ impl EntityPrefixExt for T { pub trait AliasSourceExt { fn with_from_alias(&self, alias: &MagIden) -> RelationDef; + fn with_to_alias(&self, alias: &MagIden) -> RelationDef; + fn with_alias(&self, from: &MagIden, to: &MagIden) -> RelationDef; } @@ -191,6 +205,12 @@ impl AliasSourceExt for T { def } + fn with_to_alias(&self, alias: &MagIden) -> RelationDef { + let mut def = self.def(); + def.to_tbl = def.to_tbl.alias(Alias::new(alias.clone().to_string())); + def + } + fn with_alias(&self, from: &MagIden, to: &MagIden) -> RelationDef { let mut def = self.def(); def.from_tbl = def.from_tbl.alias(from.clone().into_iden()); @@ -198,3 +218,109 @@ impl AliasSourceExt for T { def } } + +pub trait CursorPaginationExt { + type Selector: SelectorTrait + Send + Sync; + + fn cursor_by_columns_and_span( + self, + order_columns: C, + pagination: &SpanFilter, + limit: Option, + ) -> Cursor + where + C: IntoIdentity; + + async fn get_paginated_model( + self, + db: &T, + columns: C, + curr: &SpanFilter, + prev: &mut Option, + next: &mut Option, + limit: u64, + ) -> Result, DbErr> + where + M: FromQueryResult + ModelPagination, + C: IntoIdentity, + T: ConnectionTrait; +} + +impl CursorPaginationExt for Select +where + E: EntityTrait, + M: FromQueryResult + Sized + Send + Sync, +{ + type Selector = SelectModel; + + fn cursor_by_columns_and_span( + self, + order_columns: C, + pagination: &SpanFilter, + limit: Option, + ) -> Cursor + where + C: IntoIdentity, + { + let mut cursor = self.cursor_by(order_columns); + + if let Some(start) = pagination.start() { + cursor.after(start); + } + + if let Some(end) = pagination.end() { + cursor.before(end); + } + + if let Some(lim) = limit { + if pagination.is_desc() { + cursor.last(lim); + } else { + cursor.first(lim); + } + } + + cursor + } + + async fn get_paginated_model( + self, + db: &T, + columns: C, + curr: &SpanFilter, + prev: &mut Option, + next: &mut Option, + limit: u64, + ) -> Result, DbErr> + where + Q: FromQueryResult + ModelPagination, + C: IntoIdentity, + T: ConnectionTrait, + { + let mut result = self + .cursor_by_columns_and_span(columns, curr, Some(limit + 1)) + .into_model::() + .all(db) + .await?; + + if curr.is_desc() { + result.reverse(); + } + + if result.len() > limit as usize { + result.pop(); + let last = result.last(); + *next = last.and_then(|c| curr.next(c.time(), c.id())); + } + + let first = result.first(); + *prev = first.and_then(|c| curr.prev(c.time(), c.id())); + + Ok(result) + } +} + +pub trait ModelPagination { + fn id(&self) -> &str; + fn time(&self) -> DateTime; +} diff --git a/ext_calckey_model/src/note_model/data.rs b/ext_calckey_model/src/note_model/data.rs index 5e47596..eebf291 100644 --- a/ext_calckey_model/src/note_model/data.rs +++ b/ext_calckey_model/src/note_model/data.rs @@ -1,9 +1,8 @@ -use crate::model_ext::{AliasSuffixExt, EntityPrefixExt, MagIden}; +use crate::model_ext::{AliasSuffixExt, EntityPrefixExt, MagIden, ModelPagination}; use crate::note_model::{INTERACTION_REACTION, INTERACTION_RENOTE, RENOTE, REPLY, USER}; use crate::user_model::UserData; +use chrono::{DateTime, Utc}; use ck::note; -use ext_calckey_model_migration::IntoIden; -use sea_orm::sea_query::Alias; use sea_orm::Iden; use sea_orm::{DbErr, FromQueryResult, QueryResult}; use serde::{Deserialize, Serialize}; @@ -48,6 +47,16 @@ pub struct NoteData { pub renote: Option>, } +impl ModelPagination for NoteData { + fn id(&self) -> &str { + &self.note.id + } + + fn time(&self) -> DateTime { + self.note.created_at.into() + } +} + impl FromQueryResult for NoteData { fn from_query_result(res: &QueryResult, prefix: &str) -> Result { let prefix = if prefix.is_empty() { diff --git a/ext_calckey_model/src/note_model/mod.rs b/ext_calckey_model/src/note_model/mod.rs index 0ba0c14..dd9e2d2 100644 --- a/ext_calckey_model/src/note_model/mod.rs +++ b/ext_calckey_model/src/note_model/mod.rs @@ -3,8 +3,8 @@ pub mod data; use ext_calckey_model_migration::SelectStatement; use sea_orm::sea_query::{Asterisk, Expr, IntoIden, Query, SelectExpr, SimpleExpr}; use sea_orm::{ - Condition, CursorTrait, EntityName, EntityTrait, Iden, JoinType, QueryFilter, QueryOrder, - QuerySelect, QueryTrait, Select, + Condition, EntityTrait, Iden, JoinType, QueryFilter, QueryOrder, QuerySelect, QueryTrait, + Select, }; use ck::{note, note_reaction, user, user_note_pining}; @@ -12,10 +12,9 @@ use data::{sub_interaction_reaction, sub_interaction_renote, NoteData}; use magnetar_sdk::types::SpanFilter; use crate::model_ext::{ - join_columns_default, AliasColumnExt, AliasSourceExt, AliasSuffixExt, EntityPrefixExt, MagIden, - SelectColumnsExt, + join_columns_default, AliasColumnExt, AliasSourceExt, AliasSuffixExt, CursorPaginationExt, + EntityPrefixExt, MagIden, SelectColumnsExt, }; -use crate::paginated::PaginatedModel; use crate::user_model::{UserResolveOptions, UserResolver}; use crate::{CalckeyDbError, CalckeyModel}; @@ -138,21 +137,17 @@ impl NoteResolver { let visibility_filter = options .visibility_filter .with_note_and_user_tables(¬e::Entity.base_prefix()); - let time_filter = options - .time_range - .as_ref() - .and_then(note::Entity::range_into_expr); + let id_filter = options.ids.as_ref().map(ids_into_expr); - let notes = select + let note = select .filter(visibility_filter) .apply_if(id_filter, Select::::filter) - .apply_if(time_filter, Select::::filter) .into_model::() .one(self.db.inner()) .await?; - Ok(notes) + Ok(note) } pub async fn get_many( @@ -163,26 +158,36 @@ impl NoteResolver { let visibility_filter = options .visibility_filter .with_note_and_user_tables(¬e::Entity.base_prefix()); - let time_filter = options - .time_range - .as_ref() - .and_then(note::Entity::range_into_expr); let id_filter = options.ids.as_ref().map(ids_into_expr); - let notes = select + let notes_select = select .filter(visibility_filter) .apply_if(id_filter, Select::::filter) - .apply_if(time_filter, Select::::filter) .apply_if(options.only_pins_from.as_deref(), |s, _| { s.order_by_desc(Expr::col(( note::Entity.base_prefix().into_iden().join_str(PIN), user_note_pining::Column::CreatedAt, ))) - }) - .apply_if(options.limit, Select::::limit) - .into_model::() - .all(self.db.inner()) - .await?; + }); + + let notes = if let Some(pagination) = &options.time_range { + notes_select + .get_paginated_model::( + self.db.inner(), + (note::Column::CreatedAt, note::Column::Id), + pagination, + &mut None, + &mut None, + options.limit.unwrap_or(20), + ) + .await? + } else { + notes_select + .apply_if(options.limit, Select::::limit) + .into_model::() + .all(self.db.inner()) + .await? + }; Ok(notes) } diff --git a/ext_calckey_model/src/paginated.rs b/ext_calckey_model/src/paginated.rs deleted file mode 100644 index 9571b58..0000000 --- a/ext_calckey_model/src/paginated.rs +++ /dev/null @@ -1,95 +0,0 @@ -use ext_calckey_model_migration::{Expr, SimpleExpr}; -use magnetar_sdk::types::{EndFilter, RangeFilter, SpanFilter, StartFilter}; -use sea_orm::Iden; - -pub trait PaginatedModel: 'static { - fn time_column() -> impl Iden; - fn id_column() -> impl Iden; - - fn range_into_expr(filter: &SpanFilter) -> Option { - match filter { - SpanFilter::Range(RangeFilter { - time_start, - time_end, - id_start, - id_end, - }) => Some( - Expr::tuple([ - Expr::col(Self::time_column()).into(), - Expr::col(Self::id_column()).into(), - ]) - .between( - Expr::tuple([ - Expr::value(time_start.clone()), - Expr::value(id_start.clone()), - ]), - Expr::tuple([Expr::value(time_end.clone()), Expr::value(id_end.clone())]), - ), - ), - SpanFilter::Start(StartFilter { - id_start, - time_start, - }) => Some( - Expr::tuple([ - Expr::col(Self::time_column()).into(), - Expr::col(Self::id_column()).into(), - ]) - .gt(Expr::tuple([ - Expr::value(time_start.clone()), - Expr::value(id_start.clone()), - ])), - ), - SpanFilter::End(EndFilter { id_end, time_end }) => Some( - Expr::tuple([ - Expr::col(Self::time_column()).into(), - Expr::col(Self::id_column()).into(), - ]) - .lt(Expr::tuple([ - Expr::value(time_end.clone()), - Expr::value(id_end.clone()), - ])), - ), - SpanFilter::None(_) => None, - } - } -} - -impl PaginatedModel for ck::note::Entity { - fn time_column() -> impl Iden { - ck::note::Column::CreatedAt - } - - fn id_column() -> impl Iden { - ck::note::Column::Id - } -} - -impl PaginatedModel for ck::user::Entity { - fn time_column() -> impl Iden { - ck::user::Column::CreatedAt - } - - fn id_column() -> impl Iden { - ck::user::Column::Id - } -} - -impl PaginatedModel for ck::following::Entity { - fn time_column() -> impl Iden { - ck::following::Column::CreatedAt - } - - fn id_column() -> impl Iden { - ck::following::Column::Id - } -} - -impl PaginatedModel for ck::follow_request::Entity { - fn time_column() -> impl Iden { - ck::follow_request::Column::CreatedAt - } - - fn id_column() -> impl Iden { - ck::follow_request::Column::Id - } -} diff --git a/ext_calckey_model/src/user_model.rs b/ext_calckey_model/src/user_model.rs index b8ec42b..a980bf0 100644 --- a/ext_calckey_model/src/user_model.rs +++ b/ext_calckey_model/src/user_model.rs @@ -1,25 +1,25 @@ -use crate::model_ext::{AliasSourceExt, AliasSuffixExt, EntityPrefixExt, MagIden}; -use crate::{model_ext::SelectColumnsExt, CalckeyModel}; -use ck::{drive_file, user}; +use crate::model_ext::{ + AliasSourceExt, AliasSuffixExt, CursorPaginationExt, EntityPrefixExt, MagIden, ModelPagination, +}; +use crate::{model_ext::SelectColumnsExt, CalckeyDbError, CalckeyModel}; +use chrono::{DateTime, Utc}; +use ck::{drive_file, follow_request, following, user, user_profile}; use ext_calckey_model_migration::{IntoIden, SelectStatement}; -use sea_orm::sea_query::Alias; -use sea_orm::{DbErr, FromQueryResult, Iden, JoinType, QueryResult}; +use magnetar_sdk::types::SpanFilter; +use sea_orm::{ + ColumnTrait, DbErr, EntityTrait, FromQueryResult, Iden, JoinType, QueryFilter, QueryResult, + QuerySelect, +}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct UserData { pub user: user::Model, + pub profile: Option, pub avatar: Option, pub banner: Option, } -pub struct UserResolveOptions { - pub with_avatar_and_banner: bool, -} - -const AVATAR: &str = "avatar."; -const BANNER: &str = "banner."; - impl FromQueryResult for UserData { fn from_query_result(res: &QueryResult, prefix: &str) -> Result { let prefix = if prefix.is_empty() { @@ -30,6 +30,10 @@ impl FromQueryResult for UserData { Ok(UserData { user: user::Model::from_query_result(res, &prefix.to_string())?, + profile: user_profile::Model::from_query_result_optional( + res, + &prefix.join_str_as_str(PROFILE), + )?, avatar: drive_file::Model::from_query_result_optional( res, &prefix.join_str_as_str(AVATAR), @@ -42,6 +46,75 @@ impl FromQueryResult for UserData { } } +impl ModelPagination for UserData { + fn id(&self) -> &str { + &self.user.id + } + + fn time(&self) -> DateTime { + self.user.created_at.into() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserFollowData { + pub follow: following::Model, + pub user: UserData, +} + +impl FromQueryResult for UserFollowData { + fn from_query_result(res: &QueryResult, prefix: &str) -> Result { + Ok(UserFollowData { + user: UserData::from_query_result(res, prefix)?, + follow: following::Model::from_query_result(res, prefix)?, + }) + } +} + +impl ModelPagination for UserFollowData { + fn id(&self) -> &str { + &self.follow.id + } + + fn time(&self) -> DateTime { + self.follow.created_at.into() + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserFollowRequestData { + pub follow_request: follow_request::Model, + pub user: UserData, +} + +impl FromQueryResult for UserFollowRequestData { + fn from_query_result(res: &QueryResult, prefix: &str) -> Result { + Ok(UserFollowRequestData { + user: UserData::from_query_result(res, prefix)?, + follow_request: follow_request::Model::from_query_result(res, prefix)?, + }) + } +} + +impl ModelPagination for UserFollowRequestData { + fn id(&self) -> &str { + &self.follow_request.id + } + + fn time(&self) -> DateTime { + self.follow_request.created_at.into() + } +} + +pub struct UserResolveOptions { + pub with_avatar_and_banner: bool, + pub with_profile: bool, +} + +const PROFILE: &str = "profile."; +const AVATAR: &str = "avatar."; +const BANNER: &str = "banner."; + pub struct UserResolver { db: CalckeyModel, } @@ -57,10 +130,23 @@ impl UserResolver { user_tbl: &MagIden, UserResolveOptions { with_avatar_and_banner, + with_profile, }: &UserResolveOptions, ) { q.add_aliased_columns::(user_tbl); + if *with_profile { + let profile_tbl = user_tbl.join_str(PROFILE); + + q.add_aliased_columns::(&profile_tbl); + + q.join_columns( + JoinType::LeftJoin, + user::Relation::UserProfile.with_from_alias(user_tbl), + &profile_tbl, + ); + } + if *with_avatar_and_banner { let avatar_tbl = user_tbl.join_str(AVATAR); let banner_tbl = user_tbl.join_str(BANNER); @@ -81,7 +167,120 @@ impl UserResolver { } } - pub async fn get_followers(&self, user_id: &str) -> Vec { - todo!() + pub async fn get_follow_requests( + &self, + options: &UserResolveOptions, + followee: &str, + pagination: &SpanFilter, + prev: &mut Option, + next: &mut Option, + limit: u64, + ) -> Result, CalckeyDbError> { + let user_tbl = user::Entity.base_prefix(); + + let mut select = follow_request::Entity::find().join_as( + JoinType::InnerJoin, + follow_request::Relation::User1.with_to_alias(&user_tbl), + user_tbl.clone().into_iden(), + ); + + let query = QuerySelect::query(&mut select); + self.resolve(query, &user_tbl, options); + + let followers = select + .filter(follow_request::Column::FolloweeId.eq(followee)) + .get_paginated_model::( + &self.db.0, + ( + follow_request::Column::CreatedAt, + follow_request::Column::Id, + ), + pagination, + prev, + next, + limit, + ) + .await? + .into_iter() + .map(|u| u.user) + .collect(); + + Ok(followers) + } + + pub async fn get_followees( + &self, + options: &UserResolveOptions, + follower: &str, + pagination: &SpanFilter, + prev: &mut Option, + next: &mut Option, + limit: u64, + ) -> Result, CalckeyDbError> { + let user_tbl = user::Entity.base_prefix(); + + let mut select = following::Entity::find().join_as( + JoinType::InnerJoin, + following::Relation::User2.with_to_alias(&user_tbl), + user_tbl.clone().into_iden(), + ); + + let query = QuerySelect::query(&mut select); + self.resolve(query, &user_tbl, options); + + let followers = select + .filter(following::Column::FollowerId.eq(follower)) + .get_paginated_model::( + &self.db.0, + (following::Column::CreatedAt, following::Column::Id), + pagination, + prev, + next, + limit, + ) + .await? + .into_iter() + .map(|u| u.user) + .collect(); + + Ok(followers) + } + + pub async fn get_followers( + &self, + options: &UserResolveOptions, + followee: &str, + pagination: &SpanFilter, + prev: &mut Option, + next: &mut Option, + limit: u64, + ) -> Result, CalckeyDbError> { + let user_tbl = user::Entity.base_prefix(); + + let mut select = following::Entity::find().join_as( + JoinType::InnerJoin, + following::Relation::User1.with_to_alias(&user_tbl), + user_tbl.clone().into_iden(), + ); + + let query = QuerySelect::query(&mut select); + self.resolve(query, &user_tbl, options); + + let followers = select + .filter(following::Column::FolloweeId.eq(followee)) + .get_paginated_model::( + &self.db.0, + (following::Column::CreatedAt, following::Column::Id), + pagination, + prev, + next, + limit, + ) + .await? + .into_iter() + .map(|u| u.user) + .collect(); + + Ok(followers) } } diff --git a/magnetar_sdk/src/endpoints/mod.rs b/magnetar_sdk/src/endpoints/mod.rs index fc6cfc2..8b3cad3 100644 --- a/magnetar_sdk/src/endpoints/mod.rs +++ b/magnetar_sdk/src/endpoints/mod.rs @@ -24,7 +24,7 @@ pub enum ErrorKind { #[derive(Clone, Debug, Serialize, Deserialize, TS)] #[ts(export)] -pub struct Empty; +pub struct Empty {} impl From for () { fn from(_: Empty) -> Self {} diff --git a/magnetar_sdk/src/endpoints/user.rs b/magnetar_sdk/src/endpoints/user.rs index 34af5c4..3ccfda8 100644 --- a/magnetar_sdk/src/endpoints/user.rs +++ b/magnetar_sdk/src/endpoints/user.rs @@ -88,7 +88,7 @@ pub struct GetUserByAcct; endpoint = "/users/:id/followers", method = Method::GET, request = "Empty", - response = "Vec" + response = "Vec" )] pub struct GetFollowersById; @@ -97,7 +97,7 @@ pub struct GetFollowersById; endpoint = "/users/:id/following", method = Method::GET, request = "Empty", - response = "Vec" + response = "Vec" )] pub struct GetFollowingById; @@ -106,7 +106,7 @@ pub struct GetFollowingById; endpoint = "/users/@self/followers", method = Method::GET, request = "Empty", - response = "Vec" + response = "Vec" )] pub struct GetFollowersSelf; @@ -115,7 +115,7 @@ pub struct GetFollowersSelf; endpoint = "/users/@self/following", method = Method::GET, request = "Empty", - response = "Vec" + response = "Vec" )] pub struct GetFollowingSelf; @@ -124,6 +124,6 @@ pub struct GetFollowingSelf; endpoint = "/users/@self/follow-requests", method = Method::GET, request = "Empty", - response = "Vec" + response = "Vec" )] pub struct GetFollowRequestsSelf; diff --git a/magnetar_sdk/src/types/mod.rs b/magnetar_sdk/src/types/mod.rs index e787b45..4763232 100644 --- a/magnetar_sdk/src/types/mod.rs +++ b/magnetar_sdk/src/types/mod.rs @@ -5,8 +5,11 @@ pub mod note; pub mod timeline; pub mod user; +use crate::endpoints::ErrorKind; +use crate::util_types::U64Range; use chrono::{DateTime, Utc}; -use serde::{Deserialize, Serialize}; +use serde::de::Error; +use serde::{Deserialize, Deserializer, Serialize}; use ts_rs::TS; #[derive(Clone, Debug, Deserialize, Serialize, TS)] @@ -14,22 +17,22 @@ use ts_rs::TS; pub struct RangeFilter { pub time_start: DateTime, pub time_end: DateTime, - pub id_start: DateTime, - pub id_end: DateTime, + pub id_start: String, + pub id_end: String, } #[derive(Clone, Debug, Deserialize, Serialize, TS)] #[ts(export)] pub struct StartFilter { pub time_start: DateTime, - pub id_start: DateTime, + pub id_start: String, } #[derive(Clone, Debug, Deserialize, Serialize, TS)] #[ts(export)] pub struct EndFilter { pub time_end: DateTime, - pub id_end: DateTime, + pub id_end: String, } #[derive(Clone, Debug, Deserialize, Serialize, TS)] @@ -46,6 +49,119 @@ pub enum SpanFilter { None(NoFilter), } +impl SpanFilter { + pub fn is_desc(&self) -> bool { + !matches!(self, Self::Start(_)) + } + + pub fn start(&self) -> Option<(DateTime, String)> { + match self { + Self::Start(StartFilter { + time_start, + id_start, + }) + | Self::Range(RangeFilter { + time_start, + id_start, + .. + }) => Some((*time_start, id_start.clone())), + _ => None, + } + } + + pub fn end(&self) -> Option<(DateTime, String)> { + match self { + Self::End(EndFilter { time_end, id_end }) + | Self::Range(RangeFilter { + time_end, id_end, .. + }) => Some((*time_end, id_end.clone())), + _ => None, + } + } + + pub fn prev(&self, first_date: DateTime, first_id: &str) -> Option { + match self { + Self::Start(_) => Some(SpanFilter::End(EndFilter { + time_end: first_date, + id_end: first_id.to_string(), + })), + Self::End(_) => Some(SpanFilter::Start(StartFilter { + time_start: first_date, + id_start: first_id.to_string(), + })), + // TODO: This is jank + // It forgets the bounds of the cursor + Self::Range(_) => Some(SpanFilter::Start(StartFilter { + time_start: first_date, + id_start: first_id.to_string(), + })), + Self::None(_) => None, + } + } + + pub fn next(&self, last_date: DateTime, last_id: &str) -> Option { + match self { + Self::Start(_) => Some(SpanFilter::Start(StartFilter { + time_start: last_date, + id_start: last_id.to_string(), + })), + Self::End(_) | Self::None(_) => Some(SpanFilter::End(EndFilter { + time_end: last_date, + id_end: last_id.to_string(), + })), + Self::Range(RangeFilter { time_start, .. }) if *time_start > last_date => None, + Self::Range(RangeFilter { + time_start, + id_start, + .. + }) => Some(SpanFilter::Range(RangeFilter { + time_start: *time_start, + id_start: id_start.clone(), + time_end: last_date, + id_end: last_id.to_string(), + })), + } + } +} + +#[derive(Clone, Debug, Deserialize, Serialize, TS)] +#[ts(export)] +pub struct PaginationShape { + #[serde(default = "pagination_default")] + #[serde(deserialize_with = "deserialize_pagination")] + pub pagination: SpanFilter, + #[serde(default = "limit_defalt")] + #[serde(deserialize_with = "deserialize_limit")] + pub limit: U64Range<10, 100>, +} + +fn pagination_default() -> SpanFilter { + SpanFilter::None(NoFilter {}) +} + +fn limit_defalt() -> U64Range<10, 100> { + U64Range::try_from(20).unwrap() +} + +fn deserialize_limit<'de, D>(deserializer: D) -> Result, D::Error> +where + D: Deserializer<'de>, +{ + let str_val = String::deserialize(deserializer)?; + + U64Range::try_from(str_val.parse::().map_err(Error::custom)?) + .map_err(|_| Error::custom("number out of range")) +} + +fn deserialize_pagination<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + let str_val = String::deserialize(deserializer)?; + + serde_urlencoded::from_str::(&str_val).map_err(Error::custom) +} + #[derive(Clone, Debug, Deserialize, Serialize, TS)] #[ts(export)] #[repr(transparent)] diff --git a/src/api_v1/mod.rs b/src/api_v1/mod.rs index 6234b73..a26aa45 100644 --- a/src/api_v1/mod.rs +++ b/src/api_v1/mod.rs @@ -3,7 +3,9 @@ mod user; use crate::api_v1::note::handle_note; use crate::api_v1::user::{ - handle_user_by_id_many, handle_user_info, handle_user_info_by_acct, handle_user_info_self, + handle_follow_requests_self, handle_followers, handle_followers_self, handle_following, + handle_following_self, handle_user_by_id_many, handle_user_info, handle_user_info_by_acct, + handle_user_info_self, }; use crate::service::MagnetarService; use crate::web::auth; @@ -19,6 +21,14 @@ pub fn create_api_router(service: Arc) -> Router { .route("/users/by-acct/:id", get(handle_user_info_by_acct)) .route("/users/lookup-many", get(handle_user_by_id_many)) .route("/users/:id", get(handle_user_info)) + .route( + "/users/@self/follow-requests", + get(handle_follow_requests_self), + ) + .route("/users/@self/following", get(handle_following_self)) + .route("/users/:id/following", get(handle_following)) + .route("/users/@self/followers", get(handle_followers_self)) + .route("/users/:id/followers", get(handle_followers)) .route("/notes/:id", get(handle_note)) .layer(from_fn_with_state( AuthState::new(service.clone()), diff --git a/src/api_v1/user.rs b/src/api_v1/user.rs index 67dafb8..06729d6 100644 --- a/src/api_v1/user.rs +++ b/src/api_v1/user.rs @@ -1,13 +1,11 @@ -use crate::model::processing::user::{UserBorrowedData, UserModel}; -use crate::model::processing::PackError; +use crate::model::processing::user::{UserBorrowedData, UserModel, UserShapedData}; use crate::model::PackingContext; use crate::service::MagnetarService; use crate::web::auth::{AuthenticatedUser, MaybeUser}; +use crate::web::pagination::Pagination; use crate::web::{ApiError, ArgumentOutOfRange, ObjectNotFound}; use axum::extract::{Path, Query, State}; use axum::Json; -use futures::StreamExt; -use futures_util::TryStreamExt; use itertools::Itertools; use magnetar_common::util::lenient_parse_tag_decode; use magnetar_sdk::endpoints::user::{ @@ -29,6 +27,7 @@ pub async fn handle_user_info_self( &ctx, &UserBorrowedData { user: user.as_ref(), + profile: None, avatar: None, banner: None, }, @@ -56,6 +55,7 @@ pub async fn handle_user_info( &ctx, &UserBorrowedData { user: &user_model, + profile: None, avatar: None, banner: None, }, @@ -88,6 +88,7 @@ pub async fn handle_user_info_by_acct( &ctx, &UserBorrowedData { user: &user_model, + profile: None, avatar: None, banner: None, }, @@ -118,20 +119,15 @@ pub async fn handle_user_by_id_many( .iter() .map(|user| UserBorrowedData { user, + profile: None, avatar: None, banner: None, }) + .map(|u| Box::new(u) as Box) .collect::>(); - let futures = user_data - .iter() - .map(|user| user_model.base_from_existing(&ctx, user)) - .collect::>(); - - let users_proc = futures::stream::iter(futures) - .buffered(20) - .err_into::() - .try_collect::>() + let users_proc = user_model + .pack_many_base(&ctx, &user_data.iter().map(Box::as_ref).collect::>()) .await? .into_iter() .map(|u| (u.id.0.id.clone(), u)) @@ -144,3 +140,76 @@ pub async fn handle_user_by_id_many( Ok(Json(users_ordered)) } + +pub async fn handle_following_self( + Query(_): Query>, + State(service): State>, + AuthenticatedUser(user): AuthenticatedUser, + mut pagination: Pagination, +) -> Result<(Pagination, Json>), ApiError> { + let ctx = PackingContext::new(service, Some(user.clone())).await?; + let user_model = UserModel; + let users = user_model + .get_followees(&ctx, &user.id, &mut pagination) + .await?; + + Ok((pagination, Json(users))) +} + +pub async fn handle_followers_self( + Query(_): Query>, + State(service): State>, + AuthenticatedUser(user): AuthenticatedUser, + mut pagination: Pagination, +) -> Result<(Pagination, Json>), ApiError> { + let ctx = PackingContext::new(service, Some(user.clone())).await?; + let user_model = UserModel; + let users = user_model + .get_followers(&ctx, &user.id, &mut pagination) + .await?; + + Ok((pagination, Json(users))) +} + +pub async fn handle_following( + Query(_): Query>, + Path(id): Path, + State(service): State>, + MaybeUser(user): MaybeUser, + mut pagination: Pagination, +) -> Result<(Pagination, Json>), ApiError> { + let ctx = PackingContext::new(service, user.clone()).await?; + let user_model = UserModel; + let users = user_model.get_followees(&ctx, &id, &mut pagination).await?; + + Ok((pagination, Json(users))) +} + +pub async fn handle_followers( + Query(_): Query>, + Path(id): Path, + State(service): State>, + MaybeUser(user): MaybeUser, + mut pagination: Pagination, +) -> Result<(Pagination, Json>), ApiError> { + let ctx = PackingContext::new(service, user.clone()).await?; + let user_model = UserModel; + let users = user_model.get_followers(&ctx, &id, &mut pagination).await?; + + Ok((pagination, Json(users))) +} + +pub async fn handle_follow_requests_self( + Query(_): Query>, + State(service): State>, + AuthenticatedUser(user): AuthenticatedUser, + mut pagination: Pagination, +) -> Result<(Pagination, Json>), ApiError> { + let ctx = PackingContext::new(service, Some(user.clone())).await?; + let user_model = UserModel; + let users = user_model + .get_follow_requests(&ctx, &user.id, &mut pagination) + .await?; + + Ok((pagination, Json(users))) +} diff --git a/src/model/processing/note.rs b/src/model/processing/note.rs index e830c8b..1fe8051 100644 --- a/src/model/processing/note.rs +++ b/src/model/processing/note.rs @@ -18,7 +18,7 @@ use magnetar_calckey_model::note_model::data::{ }; use magnetar_calckey_model::note_model::{NoteResolveOptions, NoteVisibilityFilterFactory}; use magnetar_calckey_model::poll::PollResolver; -use magnetar_calckey_model::sea_orm::sea_query::{IntoIden, PgFunc, Query, SimpleExpr}; +use magnetar_calckey_model::sea_orm::sea_query::{PgFunc, Query, SimpleExpr}; use magnetar_calckey_model::sea_orm::{ActiveEnum, ColumnTrait, Iden, IntoSimpleExpr}; use magnetar_calckey_model::user_model::UserResolveOptions; use magnetar_calckey_model::{ck, CalckeyDbError}; @@ -581,6 +581,7 @@ impl NoteModel { limit: None, user_options: UserResolveOptions { with_avatar_and_banner: self.with_context, + with_profile: false, }, with_reply_target: self.with_context, with_renote_target: self.with_context, @@ -619,6 +620,7 @@ impl NoteModel { limit: None, user_options: UserResolveOptions { with_avatar_and_banner: self.with_context, + with_profile: false, }, with_reply_target: self.with_context, with_renote_target: self.with_context, diff --git a/src/model/processing/user.rs b/src/model/processing/user.rs index d73673b..3b236d6 100644 --- a/src/model/processing/user.rs +++ b/src/model/processing/user.rs @@ -4,10 +4,14 @@ use crate::model::processing::emoji::EmojiModel; use crate::model::processing::note::NoteModel; use crate::model::processing::{get_mm_token_emoji, PackError, PackResult}; use crate::model::{PackType, PackingContext}; +use crate::web::pagination::Pagination; +use crate::web::{AccessForbidden, ApiError}; use either::Either; use futures_util::future::OptionFuture; +use futures_util::{StreamExt, TryStreamExt}; use magnetar_calckey_model::ck; -use magnetar_calckey_model::user_model::UserData; +use magnetar_calckey_model::ck::sea_orm_active_enums::UserProfileFfvisibilityEnum; +use magnetar_calckey_model::user_model::{UserData, UserResolveOptions}; use magnetar_sdk::endpoints::user::{UserByIdReq, UserSelfReq}; use magnetar_sdk::mmm::Token; use magnetar_sdk::types::drive::PackDriveFileBase; @@ -27,12 +31,14 @@ use url::Url; pub trait UserShapedData<'a>: Send + Sync { fn user(&self) -> &'a ck::user::Model; + fn profile(&self) -> Option<&'a ck::user_profile::Model>; fn avatar(&self) -> Option<&'a ck::drive_file::Model>; fn banner(&self) -> Option<&'a ck::drive_file::Model>; } pub struct UserBorrowedData<'a> { pub user: &'a ck::user::Model, + pub profile: Option<&'a ck::user_profile::Model>, pub avatar: Option<&'a ck::drive_file::Model>, pub banner: Option<&'a ck::drive_file::Model>, } @@ -42,6 +48,10 @@ impl<'a> UserShapedData<'a> for &'a UserData { &self.user } + fn profile(&self) -> Option<&'a ck::user_profile::Model> { + self.profile.as_ref() + } + fn avatar(&self) -> Option<&'a ck::drive_file::Model> { self.avatar.as_ref() } @@ -56,6 +66,10 @@ impl<'a> UserShapedData<'a> for UserBorrowedData<'a> { self.user } + fn profile(&self) -> Option<&'a ck::user_profile::Model> { + self.profile + } + fn avatar(&self) -> Option<&'a ck::drive_file::Model> { self.avatar } @@ -179,6 +193,18 @@ impl UserModel { .ok_or_else(|| PackError::DataError("Missing user profile".to_string())) } + pub async fn get_profile_by_id( + &self, + ctx: &PackingContext, + id: &str, + ) -> PackResult { + ctx.service + .db + .get_user_profile_by_id(id) + .await? + .ok_or_else(|| PackError::DataError("Missing user profile".to_string())) + } + pub async fn profile_from_base<'a>( &self, ctx: &PackingContext, @@ -295,14 +321,15 @@ impl UserModel { ) -> PackResult { let user = user_data.user(); - let should_fetch_profile = - req.profile.unwrap_or_default() || req.secrets.unwrap_or_default(); + let should_fetch_profile = user_data.profile().is_none() + && (req.profile.unwrap_or_default() || req.secrets.unwrap_or_default()); let profile_raw_promise = OptionFuture::from(should_fetch_profile.then(|| self.get_profile(ctx, user))); let (base_res, profile_res) = join!(self.base_from_existing(ctx, user_data), profile_raw_promise); let mut base = base_res?; let profile_raw = profile_res.transpose()?; + let profile_ref = user_data.profile().or(profile_raw.as_ref()); let detail = req .detail @@ -313,7 +340,7 @@ impl UserModel { self.profile_from_base( ctx, user_data, - profile_raw.as_ref().unwrap(), + profile_ref.unwrap(), None, &mut base.user.0.emojis, ) @@ -332,7 +359,7 @@ impl UserModel { let secrets = OptionFuture::from( req.secrets .unwrap_or_default() - .then(|| self.secrets_from_base(ctx, user, profile_raw.as_ref().unwrap())), + .then(|| self.secrets_from_base(ctx, user, profile_ref.unwrap())), ); let (profile_res, pins_res, secrets_res) = join!(profile, pins, secrets); @@ -486,4 +513,205 @@ impl UserModel { auth: Optional(auth), }) } + + pub async fn pack_many_base( + &self, + ctx: &PackingContext, + users: &[&dyn UserShapedData<'_>], + ) -> PackResult> { + let futures = users + .iter() + .map(|&user| self.base_from_existing(ctx, user)) + .collect::>(); + + let users_proc = futures::stream::iter(futures) + .buffered(20) + .err_into::() + .try_collect::>() + .await?; + + Ok(users_proc) + } + + pub async fn pack_many_maybe_full( + &self, + ctx: &PackingContext, + users: &[&dyn UserShapedData<'_>], + req: &UserByIdReq, + ) -> PackResult> { + let futures = users + .iter() + .map(|&user| self.foreign_full_from_base(ctx, user, req)) + .collect::>(); + + let users_proc = futures::stream::iter(futures) + .buffered(20) + .err_into::() + .try_collect::>() + .await?; + + Ok(users_proc) + } + + pub async fn follower_visibility_check( + &self, + ctx: &PackingContext, + id: &str, + ) -> Result<(), ApiError> { + if !ctx.is_id_self(id) { + let profile = self.get_profile_by_id(ctx, id).await?; + + match (&ctx.self_user, &profile.ff_visibility) { + (_, UserProfileFfvisibilityEnum::Public) => {} + (Some(self_user), UserProfileFfvisibilityEnum::Followers) + if ctx + .is_relationship_between( + Either::Right(self_user), + Either::Left(id), + UserRelationship::Follow, + ) + .await? => {} + _ => { + Err(AccessForbidden( + "Follower information not visible".to_string(), + ))?; + } + } + } + + Ok(()) + } + + pub async fn get_followers( + &self, + ctx: &PackingContext, + id: &str, + pagination: &mut Pagination, + ) -> Result, ApiError> { + self.follower_visibility_check(ctx, id).await?; + + let users = ctx + .service + .db + .get_user_resolver() + .get_followers( + &UserResolveOptions { + with_avatar_and_banner: true, + with_profile: true, + }, + id, + &pagination.current, + &mut pagination.prev, + &mut pagination.next, + pagination.limit.into(), + ) + .await?; + + let users_ref = users + .iter() + .map(|u| Box::new(u) as Box>) + .collect::>(); + + Ok(self + .pack_many_maybe_full( + ctx, + &users_ref.iter().map(Box::as_ref).collect::>(), + &UserByIdReq { + profile: Some(true), + auth: None, + detail: None, + pins: None, + relation: None, + }, + ) + .await?) + } + + pub async fn get_followees( + &self, + ctx: &PackingContext, + id: &str, + pagination: &mut Pagination, + ) -> Result, ApiError> { + self.follower_visibility_check(ctx, id).await?; + + let users = ctx + .service + .db + .get_user_resolver() + .get_followees( + &UserResolveOptions { + with_avatar_and_banner: true, + with_profile: true, + }, + id, + &pagination.current, + &mut pagination.prev, + &mut pagination.next, + pagination.limit.into(), + ) + .await?; + + let users_ref = users + .iter() + .map(|u| Box::new(u) as Box>) + .collect::>(); + + Ok(self + .pack_many_maybe_full( + ctx, + &users_ref.iter().map(Box::as_ref).collect::>(), + &UserByIdReq { + profile: Some(true), + auth: None, + detail: None, + pins: None, + relation: None, + }, + ) + .await?) + } + + pub async fn get_follow_requests( + &self, + ctx: &PackingContext, + id: &str, + pagination: &mut Pagination, + ) -> Result, ApiError> { + let users = ctx + .service + .db + .get_user_resolver() + .get_follow_requests( + &UserResolveOptions { + with_avatar_and_banner: true, + with_profile: true, + }, + id, + &pagination.current, + &mut pagination.prev, + &mut pagination.next, + pagination.limit.into(), + ) + .await?; + + let users_ref = users + .iter() + .map(|u| Box::new(u) as Box>) + .collect::>(); + + Ok(self + .pack_many_maybe_full( + ctx, + &users_ref.iter().map(Box::as_ref).collect::>(), + &UserByIdReq { + profile: Some(true), + auth: None, + detail: None, + pins: None, + relation: None, + }, + ) + .await?) + } } diff --git a/src/util.rs b/src/util.rs index b965d51..0d39121 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,118 +1,48 @@ -use cached::{Cached, TimedCache}; -use std::future::Future; -use std::hash::Hash; -use std::sync::Arc; -use tokio::sync::Mutex; +use percent_encoding::{utf8_percent_encode, NON_ALPHANUMERIC}; +use serde_json::Value; +pub fn serialize_as_urlenc(value: &Value) -> String { + match value { + Value::Null => "null".to_string(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => utf8_percent_encode(&n.to_string(), NON_ALPHANUMERIC).to_string(), + Value::String(s) => utf8_percent_encode(s, NON_ALPHANUMERIC).to_string(), + Value::Array(arr) => { + let pairs = arr + .iter() + .map(|k| { + utf8_percent_encode( + &if matches!(k, Value::Array(_) | Value::Object(_)) { + utf8_percent_encode(&serialize_as_urlenc(k), NON_ALPHANUMERIC) + .to_string() + } else { + serialize_as_urlenc(k) + }, + NON_ALPHANUMERIC, + ) + .to_string() + }) + .collect::>(); -#[derive(Debug, Clone)] -pub struct SingleTimedAsyncCache { - inner: Arc>>, -} - -impl SingleTimedAsyncCache { - pub fn with_lifespan(lifespan: u64) -> Self { - Self { - inner: Arc::new(Mutex::new(TimedCache::with_lifespan(lifespan))), + pairs.join("&") } - } + Value::Object(entries) => { + let pairs = entries + .iter() + .map(|(k, v)| { + format!( + "{}={}", + utf8_percent_encode(&k, NON_ALPHANUMERIC).to_string(), + if matches!(v, Value::Array(_) | Value::Object(_)) { + utf8_percent_encode(&serialize_as_urlenc(v), NON_ALPHANUMERIC) + .to_string() + } else { + serialize_as_urlenc(v) + } + ) + }) + .collect::>(); - pub async fn put(&self, val: V) -> Option { - let mut cache = self.inner.lock().await; - cache.cache_set((), val) - } - - pub async fn get_opt(&self) -> Option { - let mut cache = self.inner.lock().await; - cache.cache_get(&()).cloned() - } - - pub async fn get(&self, f: F) -> Result - where - FT: Future>, - F: FnOnce() -> FT, - { - let mut cache = self.inner.lock().await; - - if let Some(val) = cache.cache_get(&()) { - return Ok(val.clone()); + pairs.join("&") } - - let val = f().await?; - cache.cache_set((), val.clone()); - Ok(val) - } - - pub async fn get_sync(&self, f: F) -> Result - where - F: FnOnce() -> Result, - { - let mut cache = self.inner.lock().await; - - if let Some(val) = cache.cache_get(&()) { - return Ok(val.clone()); - } - - let val = f()?; - cache.cache_set((), val.clone()); - Ok(val) - } -} - -#[derive(Debug, Clone)] -pub struct TimedAsyncCache { - inner: Arc>>, -} - -impl TimedAsyncCache { - pub fn with_lifespan(lifespan: u64) -> Self { - Self { - inner: Arc::new(Mutex::new(TimedCache::with_lifespan(lifespan))), - } - } - - pub async fn put(&self, key: K, val: V) -> Option { - let mut cache = self.inner.lock().await; - cache.cache_set(key, val) - } - - pub async fn remove(&self, key: &K) -> Option { - let mut cache = self.inner.lock().await; - cache.cache_remove(key) - } - - pub async fn get_opt(&self, key: &K) -> Option { - let mut cache = self.inner.lock().await; - cache.cache_get(key).cloned() - } - - pub async fn get(&self, key: K, f: F) -> Result - where - FT: Future>, - F: FnOnce() -> FT, - { - let mut cache = self.inner.lock().await; - - if let Some(val) = cache.cache_get(&key) { - return Ok(val.clone()); - } - - let val = f().await?; - cache.cache_set(key, val.clone()); - Ok(val) - } - - pub async fn get_sync(&self, key: K, f: F) -> Result - where - F: FnOnce() -> Result, - { - let mut cache = self.inner.lock().await; - - if let Some(val) = cache.cache_get(&key) { - return Ok(val.clone()); - } - - let val = f()?; - cache.cache_set(key, val.clone()); - Ok(val) } } diff --git a/src/web/mod.rs b/src/web/mod.rs index 37a1acf..6ec31dc 100644 --- a/src/web/mod.rs +++ b/src/web/mod.rs @@ -8,6 +8,7 @@ use serde::Serialize; use serde_json::json; pub mod auth; +pub mod pagination; #[derive(Debug, Clone, Serialize)] #[repr(transparent)] @@ -42,6 +43,15 @@ pub struct ApiError { pub message: String, } +#[derive(Debug)] +pub struct AccessForbidden(pub String); + +impl From<&AccessForbidden> for &str { + fn from(_: &AccessForbidden) -> &'static str { + "AccessForbidden" + } +} + impl IntoResponse for ApiError { fn into_response(self) -> Response { ( @@ -56,6 +66,20 @@ impl IntoResponse for ApiError { } } +impl From for ApiError { + fn from(err: AccessForbidden) -> Self { + Self { + status: StatusCode::FORBIDDEN, + code: err.error_code(), + message: if cfg!(debug_assertions) { + format!("Forbidden: {}", err.0) + } else { + "Forbidden".to_string() + }, + } + } +} + impl From for ApiError { fn from(err: FediverseTagParseError) -> Self { Self { diff --git a/src/web/pagination.rs b/src/web/pagination.rs new file mode 100644 index 0000000..cfd2a1b --- /dev/null +++ b/src/web/pagination.rs @@ -0,0 +1,164 @@ +use crate::service::MagnetarService; +use crate::util::serialize_as_urlenc; +use crate::web::{ApiError, IntoErrorCode}; +use axum::extract::rejection::QueryRejection; +use axum::extract::{FromRequestParts, OriginalUri, Query}; +use axum::http::header::InvalidHeaderValue; +use axum::http::request::Parts; +use axum::http::{HeaderValue, StatusCode, Uri}; +use axum::response::{IntoResponse, IntoResponseParts, Response, ResponseParts}; +use axum::RequestPartsExt; +use either::Either; +use itertools::Itertools; +use magnetar_calckey_model::sea_orm::prelude::async_trait::async_trait; +use magnetar_core::web_model::rel::{RelNext, RelPrev}; +use magnetar_sdk::types::{NoFilter, PaginationShape, SpanFilter}; +use magnetar_sdk::util_types::U64Range; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::sync::Arc; +use strum::IntoStaticStr; +use thiserror::Error; +use tracing::error; + +#[derive(Debug)] +pub struct Pagination { + base_uri: Uri, + pub current: SpanFilter, + pub prev: Option, + pub next: Option, + pub limit: U64Range<10, 100>, + query_rest: HashMap, +} + +#[derive(Debug, Serialize, Deserialize)] +struct PaginationQuery { + #[serde(flatten)] + pagination: PaginationShape, + #[serde(flatten)] + query_rest: HashMap, +} + +#[derive(Debug, Error, IntoStaticStr)] +pub enum PaginationBuilderError { + #[error("Query rejection: {0}")] + QueryRejection(#[from] QueryRejection), + #[error("HTTP error: {0}")] + HttpError(#[from] axum::http::Error), + #[error("Value of out of range error")] + OutOfRange, + #[error("Invalid header value")] + InvalidHeaderValue(#[from] InvalidHeaderValue), + #[error("Query string serialization error: {0}")] + SerializationErrorQuery(#[from] serde_urlencoded::ser::Error), + #[error("Query string serialization error: {0}")] + SerializationErrorJson(#[from] serde_json::Error), +} + +impl From for ApiError { + fn from(err: PaginationBuilderError) -> Self { + Self { + status: StatusCode::INTERNAL_SERVER_ERROR, + code: err.error_code(), + message: if cfg!(debug_assertions) { + format!("Pagination builder error: {}", err) + } else { + "Pagination builder error".to_string() + }, + } + } +} + +impl IntoResponse for PaginationBuilderError { + fn into_response(self) -> Response { + ApiError::from(self).into_response() + } +} + +#[async_trait] +impl FromRequestParts> for Pagination { + type Rejection = PaginationBuilderError; + + async fn from_request_parts( + parts: &mut Parts, + state: &Arc, + ) -> Result { + let OriginalUri(original_uri) = parts.extract::().await.unwrap(); + + let base_uri = Uri::builder() + .scheme(state.config.networking.protocol.as_ref()) + .authority(state.config.networking.host.as_str()) + .path_and_query(original_uri.path()) + .build()?; + + let Query(PaginationQuery { + pagination, + query_rest, + }) = parts.extract::>().await?; + + Ok(Pagination { + base_uri, + prev: None, + next: None, + current: pagination.pagination, + limit: pagination.limit, + query_rest, + }) + } +} + +impl IntoResponseParts for Pagination { + type Error = PaginationBuilderError; + + fn into_response_parts(self, mut res: ResponseParts) -> Result { + let wrap = |uri: Uri, query: String, rel: Either| { + format!( + "<{}?{}>; rel=\"{}\"", + uri.to_string(), + query, + rel.as_ref() + .map_either(RelPrev::as_ref, RelNext::as_ref) + .into_inner() + ) + }; + + let url_prev = if let Some(prev) = self.prev { + let query_prev = serialize_as_urlenc(&serde_json::to_value(PaginationQuery { + pagination: PaginationShape { + pagination: prev, + limit: self.limit, + }, + query_rest: self.query_rest.clone(), + })?); + + Some(wrap( + self.base_uri.clone(), + query_prev, + Either::Left(RelPrev), + )) + } else { + None + }; + + let url_next = if let Some(next) = self.next { + let query_next = serialize_as_urlenc(&serde_json::to_value(PaginationQuery { + pagination: PaginationShape { + pagination: next, + limit: self.limit, + }, + query_rest: self.query_rest, + })?); + + Some(wrap(self.base_uri, query_next, Either::Right(RelNext))) + } else { + None + }; + + let parts = [url_prev, url_next].iter().flatten().join(", "); + if !parts.is_empty() { + res.headers_mut() + .insert(axum::http::header::LINK, HeaderValue::from_str(&parts)?); + } + Ok(res) + } +}