use reqwest::Client;
use serde::de::DeserializeOwned;
use uuid::Uuid;
use super::{ApiError, ApiResult};
use fido_types::*;
#[derive(Debug, Clone, serde::Serialize)]
#[serde(rename_all = "lowercase")]
pub enum VoteDirection {
Up,
Down,
}
impl std::fmt::Display for VoteDirection {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let s = match self {
VoteDirection::Up => "up",
VoteDirection::Down => "down",
};
write!(f, "{}", s)
}
}
impl VoteDirection {
pub fn from_str(s: &str) -> Option<Self> {
match s {
"up" => Some(VoteDirection::Up),
"down" => Some(VoteDirection::Down),
_ => None,
}
}
}
#[derive(Clone)]
pub struct ApiClient {
client: Client,
base_url: String,
session_token: Option<String>,
}
impl ApiClient {
pub fn new(base_url: impl Into<String>) -> Self {
let client = Client::builder()
.timeout(std::time::Duration::from_secs(30))
.connect_timeout(std::time::Duration::from_secs(10))
.build()
.expect("Failed to create HTTP client");
Self {
client,
base_url: base_url.into(),
session_token: None,
}
}
fn build_url(&self, path: &str) -> String {
format!("{}{}", self.base_url, path)
}
fn build_url_with_params(&self, path: &str, params: &[(&str, &str)]) -> String {
let mut url = self.build_url(path);
if !params.is_empty() {
url.push('?');
let query_string = params
.iter()
.map(|(key, value)| format!("{}={}", key, urlencoding::encode(value)))
.collect::<Vec<_>>()
.join("&");
url.push_str(&query_string);
}
url
}
pub fn set_session_token(&mut self, token: Option<String>) {
self.session_token = token;
}
fn add_auth_header(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
if let Some(token) = &self.session_token {
req.header("X-Session-Token", token)
} else {
req
}
}
async fn handle_response<T: DeserializeOwned>(&self, response: reqwest::Response) -> ApiResult<T> {
let status = response.status();
if status.is_success() {
response.json().await.map_err(ApiError::from)
} else {
let error_text = response.text().await.unwrap_or_else(|_| "Unknown error".to_string());
let clean_error = if error_text.contains("<html>") || error_text.contains("<!DOCTYPE") {
format!("Server returned {} error. Please check the server URL.", status.as_u16())
} else {
error_text
};
let api_error = match status.as_u16() {
404 => ApiError::NotFound(clean_error),
401 => ApiError::Unauthorized(clean_error),
400 => ApiError::BadRequest(clean_error),
403 => ApiError::Unauthorized(clean_error), 500..=599 => ApiError::Api(format!("Server error ({}): {}", status.as_u16(), clean_error)),
_ => ApiError::Api(clean_error),
};
Err(api_error)
}
}
pub async fn get_test_users(&self) -> ApiResult<Vec<User>> {
let url = format!("{}/users/test", self.base_url);
let response = self.client.get(&url).send().await?;
self.handle_response(response).await
}
pub async fn login(&mut self, username: String) -> ApiResult<LoginResponse> {
let url = format!("{}/auth/login", self.base_url);
let request = LoginRequest { username };
let response = self.client.post(&url).json(&request).send().await?;
let login_response: LoginResponse = self.handle_response(response).await?;
self.session_token = Some(login_response.session_token.clone());
Ok(login_response)
}
pub async fn get_posts(&self, limit: Option<i32>, sort: Option<String>, hashtag: Option<String>, username: Option<String>) -> ApiResult<Vec<Post>> {
let mut params = Vec::new();
if let Some(l) = limit {
params.push(("limit", l.to_string()));
}
if let Some(s) = sort {
params.push(("sort", s));
}
if let Some(h) = hashtag {
params.push(("hashtag", h));
}
if let Some(u) = username {
params.push(("username", u));
}
let params_ref: Vec<(&str, &str)> = params.iter().map(|(k, v)| (*k, v.as_str())).collect();
let url = self.build_url_with_params("/posts", ¶ms_ref);
let req = self.add_auth_header(self.client.get(&url));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn create_post(&self, content: String) -> ApiResult<Post> {
let url = format!("{}/posts", self.base_url);
let request = CreatePostRequest { content };
let req = self.add_auth_header(self.client.post(&url).json(&request));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn vote_on_post(&self, post_id: Uuid, direction: VoteDirection) -> ApiResult<serde_json::Value> {
let url = self.build_url(&format!("/posts/{}/vote", post_id));
let request = VoteRequest {
direction: direction.to_string()
};
let req = self.add_auth_header(self.client.post(&url).json(&request));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn get_post_by_id(&self, post_id: Uuid) -> ApiResult<Post> {
let url = format!("{}/posts/{}", self.base_url, post_id);
let req = self.add_auth_header(self.client.get(&url));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn get_replies(&self, post_id: Uuid) -> ApiResult<Vec<Post>> {
let url = format!("{}/posts/{}/replies", self.base_url, post_id);
let req = self.add_auth_header(self.client.get(&url));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn create_reply(&self, post_id: Uuid, content: String) -> ApiResult<Post> {
let url = format!("{}/posts/{}/reply", self.base_url, post_id);
let request = CreateReplyRequest { content };
let req = self.add_auth_header(self.client.post(&url).json(&request));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn update_post(&self, post_id: Uuid, content: String) -> ApiResult<Post> {
let url = format!("{}/posts/{}", self.base_url, post_id);
let request = UpdatePostRequest { content };
let req = self.add_auth_header(self.client.put(&url).json(&request));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn delete_post(&self, post_id: Uuid) -> ApiResult<serde_json::Value> {
let url = format!("{}/posts/{}", self.base_url, post_id);
let req = self.add_auth_header(self.client.delete(&url));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn get_profile(&self, user_id: Uuid) -> ApiResult<UserProfile> {
let url = format!("{}/users/{}/profile", self.base_url, user_id);
let req = self.add_auth_header(self.client.get(&url));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn get_user_profile_view(&self, user_id: String) -> ApiResult<fido_types::UserProfileView> {
let url = format!("{}/users/{}/profile-view", self.base_url, user_id);
let req = self.add_auth_header(self.client.get(&url));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn update_bio(&self, user_id: Uuid, bio: String) -> ApiResult<serde_json::Value> {
let url = format!("{}/users/{}/profile", self.base_url, user_id);
let request = UpdateBioRequest { bio };
let req = self.add_auth_header(self.client.put(&url).json(&request));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn get_conversations(&self) -> ApiResult<Vec<serde_json::Value>> {
let url = format!("{}/dms/conversations", self.base_url);
let req = self.add_auth_header(self.client.get(&url));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn get_conversation(&self, user_id: Uuid) -> ApiResult<Vec<DirectMessage>> {
let url = format!("{}/dms/conversations/{}", self.base_url, user_id);
let req = self.add_auth_header(self.client.get(&url));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn send_message(&self, to_username: String, content: String) -> ApiResult<DirectMessage> {
let url = format!("{}/dms", self.base_url);
let request_body = SendMessageRequest { to_username, content };
let req = self.add_auth_header(self.client.post(&url).json(&request_body));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn mark_messages_read(&self, user_id: Uuid) -> ApiResult<serde_json::Value> {
let url = format!("{}/dms/mark-read/{}", self.base_url, user_id);
let req = self.add_auth_header(self.client.post(&url));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn get_config(&self) -> ApiResult<UserConfig> {
let url = format!("{}/config", self.base_url);
let req = self.add_auth_header(self.client.get(&url));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn update_config(&self, request: UpdateConfigRequest) -> ApiResult<UserConfig> {
let url = format!("{}/config", self.base_url);
let req = self.add_auth_header(self.client.put(&url).json(&request));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn get_followed_hashtags(&self) -> ApiResult<Vec<String>> {
let url = format!("{}/hashtags/followed", self.base_url);
let req = self.add_auth_header(self.client.get(&url));
let response = req.send().await?;
let hashtags: Vec<serde_json::Value> = self.handle_response(response).await?;
Ok(hashtags.into_iter().filter_map(|h| h.get("name").and_then(|n| n.as_str()).map(String::from)).collect())
}
pub async fn follow_hashtag(&self, name: String) -> ApiResult<()> {
let url = self.build_url("/hashtags/follow");
let request_body = serde_json::json!({ "name": name });
let req = self.add_auth_header(self.client.post(&url).json(&request_body));
let response = req.send().await?;
let _: serde_json::Value = self.handle_response(response).await?;
Ok(())
}
pub async fn unfollow_hashtag(&self, name: String) -> ApiResult<()> {
let url = self.build_url(&format!("/hashtags/follow/{}", name));
let req = self.add_auth_header(self.client.delete(&url));
let response = req.send().await?;
let _: serde_json::Value = self.handle_response(response).await?;
Ok(())
}
pub async fn search_hashtags(&self, query: String) -> ApiResult<Vec<String>> {
let url = format!("{}/hashtags/search?q={}", self.base_url, urlencoding::encode(&query));
let req = self.client.get(&url);
let response = req.send().await?;
let hashtags: Vec<serde_json::Value> = self.handle_response(response).await?;
Ok(hashtags.into_iter().filter_map(|h| h.get("name").and_then(|n| n.as_str()).map(String::from)).collect())
}
pub async fn follow_user(&self, user_id: String) -> ApiResult<()> {
let url = format!("{}/users/{}/follow", self.base_url, user_id);
let req = self.add_auth_header(self.client.post(&url));
let response = req.send().await?;
response.error_for_status()?;
Ok(())
}
pub async fn unfollow_user(&self, user_id: String) -> ApiResult<()> {
let url = format!("{}/users/{}/follow", self.base_url, user_id);
let req = self.add_auth_header(self.client.delete(&url));
let response = req.send().await?;
response.error_for_status()?;
Ok(())
}
pub async fn get_following_list(&self) -> ApiResult<Vec<SocialUserInfo>> {
let url = format!("{}/social/following", self.base_url);
let req = self.add_auth_header(self.client.get(&url));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn get_followers_list(&self) -> ApiResult<Vec<SocialUserInfo>> {
let url = format!("{}/social/followers", self.base_url);
let req = self.add_auth_header(self.client.get(&url));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn get_mutual_friends_list(&self) -> ApiResult<Vec<SocialUserInfo>> {
let url = format!("{}/social/mutual", self.base_url);
let req = self.add_auth_header(self.client.get(&url));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn search_users(&self, query: String) -> ApiResult<Vec<UserSearchResult>> {
let url = format!("{}/users/search?q={}", self.base_url, urlencoding::encode(&query));
let req = self.add_auth_header(self.client.get(&url));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn github_device_flow(&self) -> ApiResult<GitHubDeviceFlowResponse> {
let url = format!("{}/auth/github/device", self.base_url);
let response = self.client.post(&url).send().await?;
self.handle_response(response).await
}
pub async fn github_device_poll(&self, device_code: &str) -> ApiResult<LoginResponse> {
let url = format!("{}/auth/github/device/poll", self.base_url);
let payload = DevicePollRequest {
device_code: device_code.to_string(),
};
let response = self.client.post(&url).json(&payload).send().await?;
self.handle_response(response).await
}
pub async fn validate_session(&self) -> ApiResult<ValidateSessionResponse> {
let url = format!("{}/auth/validate", self.base_url);
let req = self.add_auth_header(self.client.get(&url));
let response = req.send().await?;
self.handle_response(response).await
}
pub async fn logout(&self, session_token: String) -> ApiResult<()> {
let url = format!("{}/auth/logout", self.base_url);
let response = self.client.post(&url).json(&session_token).send().await?;
response.error_for_status()?;
Ok(())
}
}
impl Default for ApiClient {
fn default() -> Self {
let base_url = if std::env::var("FIDO_WEB_MODE").is_ok() {
"http://127.0.0.1:3000".to_string()
} else {
std::env::var("FIDO_SERVER_URL")
.unwrap_or_else(|_| "https://fido-social.fly.dev/api".to_string())
};
Self::new(base_url)
}
}
#[derive(Debug, serde::Deserialize)]
pub struct SocialUserInfo {
pub id: String,
pub username: String,
pub follower_count: usize,
pub following_count: usize,
}
#[derive(Debug, serde::Deserialize)]
pub struct UserSearchResult {
pub id: String,
pub username: String,
}
#[derive(Debug, serde::Deserialize)]
pub struct GitHubDeviceFlowResponse {
pub device_code: String,
pub user_code: String,
pub verification_uri: String,
pub expires_in: i64,
pub interval: i64,
}
#[derive(Debug, serde::Serialize)]
pub struct DevicePollRequest {
pub device_code: String,
}
#[derive(Debug, serde::Deserialize)]
pub struct ValidateSessionResponse {
pub user: fido_types::User,
pub valid: bool,
}
#[derive(Debug, serde::Deserialize)]
pub struct SessionPollResponse {
pub session_token: Option<String>,
}