tui - client app.
This commit is contained in:
231
crates/tui/src/client.rs
Normal file
231
crates/tui/src/client.rs
Normal file
@@ -0,0 +1,231 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
// ── DTOs (mirror backend dtos.rs exactly) ────────────────────────────────────
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LoginRequest {
|
||||
pub email: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct LoginResponse {
|
||||
pub token: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct LogReviewRequest {
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub external_metadata_id: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub manual_title: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub manual_release_year: Option<u16>,
|
||||
pub rating: u8,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DiaryResponse {
|
||||
pub items: Vec<DiaryEntryDto>,
|
||||
pub total_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct DiaryEntryDto {
|
||||
pub movie: MovieDto,
|
||||
pub review: ReviewDto,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct MovieDto {
|
||||
pub id: Uuid,
|
||||
pub title: String,
|
||||
pub release_year: u16,
|
||||
pub director: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ReviewDto {
|
||||
pub id: Uuid,
|
||||
pub rating: u8,
|
||||
pub comment: Option<String>,
|
||||
pub watched_at: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ReviewHistoryResponse {
|
||||
pub movie: MovieDto,
|
||||
pub viewings: Vec<ReviewDto>,
|
||||
pub trend: String,
|
||||
}
|
||||
|
||||
// ── Error ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum ApiError {
|
||||
#[error("network error: {0}")]
|
||||
Network(#[from] reqwest::Error),
|
||||
#[error("unauthorized")]
|
||||
Unauthorized,
|
||||
#[error("not found")]
|
||||
NotFound,
|
||||
#[error("forbidden")]
|
||||
Forbidden,
|
||||
#[error("validation error: {0}")]
|
||||
Validation(String),
|
||||
#[error("server error {status}: {body}")]
|
||||
Unknown { status: u16, body: String },
|
||||
}
|
||||
|
||||
async fn check_status(resp: reqwest::Response) -> Result<reqwest::Response, ApiError> {
|
||||
let status = resp.status();
|
||||
if status.is_success() {
|
||||
return Ok(resp);
|
||||
}
|
||||
let body = resp.text().await.map_err(ApiError::Network)?;
|
||||
Err(match status.as_u16() {
|
||||
401 => ApiError::Unauthorized,
|
||||
403 => ApiError::Forbidden,
|
||||
404 => ApiError::NotFound,
|
||||
400 => ApiError::Validation(body),
|
||||
code => ApiError::Unknown { status: code, body },
|
||||
})
|
||||
}
|
||||
|
||||
// ── Client ────────────────────────────────────────────────────────────────────
|
||||
|
||||
pub struct ApiClient {
|
||||
base_url: std::sync::RwLock<String>,
|
||||
http: reqwest::Client,
|
||||
}
|
||||
|
||||
impl ApiClient {
|
||||
pub fn new(url: &str) -> Self {
|
||||
Self {
|
||||
base_url: std::sync::RwLock::new(url.to_string()),
|
||||
http: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_url(&self, url: &str) {
|
||||
*self.base_url.write().unwrap() = url.to_string();
|
||||
}
|
||||
|
||||
fn url(&self) -> String {
|
||||
self.base_url.read().unwrap().clone()
|
||||
}
|
||||
|
||||
pub async fn login(&self, email: &str, password: &str) -> Result<LoginResponse, ApiError> {
|
||||
let resp = self
|
||||
.http
|
||||
.post(format!("{}/api/auth/login", self.url()))
|
||||
.json(&LoginRequest { email: email.into(), password: password.into() })
|
||||
.send()
|
||||
.await?;
|
||||
Ok(check_status(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
pub async fn get_diary(
|
||||
&self,
|
||||
token: &str,
|
||||
offset: u32,
|
||||
limit: u32,
|
||||
) -> Result<DiaryResponse, ApiError> {
|
||||
let resp = self
|
||||
.http
|
||||
.get(format!("{}/api/diary", self.url()))
|
||||
.query(&[("offset", offset), ("limit", limit)])
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(check_status(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
pub async fn get_movie_history(
|
||||
&self,
|
||||
token: &str,
|
||||
movie_id: Uuid,
|
||||
) -> Result<ReviewHistoryResponse, ApiError> {
|
||||
let resp = self
|
||||
.http
|
||||
.get(format!("{}/api/movies/{}/history", self.url(), movie_id))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await?;
|
||||
Ok(check_status(resp).await?.json().await?)
|
||||
}
|
||||
|
||||
pub async fn create_review(
|
||||
&self,
|
||||
token: &str,
|
||||
req: &LogReviewRequest,
|
||||
) -> Result<(), ApiError> {
|
||||
let resp = self
|
||||
.http
|
||||
.post(format!("{}/api/reviews", self.url()))
|
||||
.bearer_auth(token)
|
||||
.json(req)
|
||||
.send()
|
||||
.await?;
|
||||
check_status(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn delete_review(&self, token: &str, review_id: Uuid) -> Result<(), ApiError> {
|
||||
let resp = self
|
||||
.http
|
||||
.delete(format!("{}/api/reviews/{}", self.url(), review_id))
|
||||
.bearer_auth(token)
|
||||
.send()
|
||||
.await?;
|
||||
check_status(resp).await?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn apierror_unauthorized_display() {
|
||||
let err = ApiError::Unauthorized;
|
||||
assert!(matches!(err, ApiError::Unauthorized));
|
||||
assert_eq!(err.to_string(), "unauthorized");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apierror_validation_display() {
|
||||
let err = ApiError::Validation("rating must be 0-5".into());
|
||||
assert!(err.to_string().contains("validation error"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn log_review_request_skips_none_fields() {
|
||||
let req = LogReviewRequest {
|
||||
external_metadata_id: None,
|
||||
manual_title: Some("The Matrix".into()),
|
||||
manual_release_year: None,
|
||||
rating: 5,
|
||||
comment: None,
|
||||
watched_at: "2024-01-15T20:00:00".into(),
|
||||
};
|
||||
let json = serde_json::to_string(&req).unwrap();
|
||||
assert!(!json.contains("external_metadata_id"));
|
||||
assert!(!json.contains("manual_release_year"));
|
||||
assert!(json.contains("\"manual_title\":\"The Matrix\""));
|
||||
assert!(json.contains("\"rating\":5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn api_client_update_url() {
|
||||
let client = ApiClient::new("http://localhost:3000");
|
||||
assert!(client.url().contains("3000"));
|
||||
client.update_url("http://localhost:8080");
|
||||
assert!(client.url().contains("8080"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user