You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
254 lines
6.6 KiB
Rust
254 lines
6.6 KiB
Rust
//! Source model and request/response types.
|
|
//!
|
|
//! Sources represent user-curated URLs (blogs, news sites, etc.)
|
|
//! that the AI should prioritize during synthesis generation.
|
|
|
|
use chrono::{DateTime, Utc};
|
|
use serde::{Deserialize, Serialize};
|
|
use uuid::Uuid;
|
|
|
|
/// A source record from the database.
|
|
#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
|
|
pub struct Source {
|
|
pub id: Uuid,
|
|
pub user_id: Uuid,
|
|
pub title: String,
|
|
pub url: String,
|
|
pub theme_id: Option<Uuid>,
|
|
pub is_preferred: bool,
|
|
pub rss_url: Option<String>,
|
|
pub rss_discovered_at: Option<DateTime<Utc>>,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
/// Response shape for source endpoints.
|
|
#[derive(Debug, Serialize)]
|
|
pub struct SourceResponse {
|
|
pub id: Uuid,
|
|
pub title: String,
|
|
pub url: String,
|
|
pub theme_id: Option<Uuid>,
|
|
pub is_preferred: bool,
|
|
pub created_at: DateTime<Utc>,
|
|
}
|
|
|
|
impl From<Source> for SourceResponse {
|
|
fn from(s: Source) -> Self {
|
|
Self {
|
|
id: s.id,
|
|
title: s.title,
|
|
url: s.url,
|
|
theme_id: s.theme_id,
|
|
is_preferred: s.is_preferred,
|
|
created_at: s.created_at,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Request body for `POST /api/v1/sources`.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct CreateSourceRequest {
|
|
pub title: String,
|
|
pub url: String,
|
|
#[serde(default)]
|
|
pub theme_id: Option<Uuid>,
|
|
}
|
|
|
|
impl CreateSourceRequest {
|
|
/// Validate the source creation request.
|
|
///
|
|
/// Returns `Ok(())` if both fields are within acceptable bounds,
|
|
/// or `Err(message)` describing the first validation failure.
|
|
pub fn validate(&self) -> Result<(), String> {
|
|
validate_title(&self.title)?;
|
|
validate_url(&self.url)?;
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
/// Request body for `PUT /api/v1/sources/preferred`.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct UpdatePreferredRequest {
|
|
pub source_ids: Vec<Uuid>,
|
|
pub theme_id: Uuid,
|
|
}
|
|
|
|
/// Request body for `POST /api/v1/sources/bulk`.
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct BulkImportRequest {
|
|
pub sources: Vec<CreateSourceRequest>,
|
|
pub theme_id: Option<Uuid>,
|
|
}
|
|
|
|
/// Response for bulk import operations (JSON and CSV).
|
|
#[derive(Debug, Serialize)]
|
|
pub struct BulkImportResponse {
|
|
pub imported: usize,
|
|
pub skipped: usize,
|
|
pub errors: Vec<String>,
|
|
}
|
|
|
|
/// Validate a source title.
|
|
///
|
|
/// Must be non-empty (after trimming) and at most 200 characters.
|
|
pub fn validate_title(title: &str) -> Result<(), String> {
|
|
if title.trim().is_empty() {
|
|
return Err("Title cannot be empty".into());
|
|
}
|
|
if title.len() > 200 {
|
|
return Err("Title must be at most 200 characters".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// Validate a source URL.
|
|
///
|
|
/// Must start with `http://` or `https://` and be at most 1000 characters.
|
|
pub fn validate_url(url: &str) -> Result<(), String> {
|
|
if url.trim().is_empty() {
|
|
return Err("URL cannot be empty".into());
|
|
}
|
|
if url.len() > 1000 {
|
|
return Err("URL must be at most 1000 characters".into());
|
|
}
|
|
if !url.starts_with("http://") && !url.starts_with("https://") {
|
|
return Err("URL must start with http:// or https://".into());
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn test_valid_source_request() {
|
|
let req = CreateSourceRequest {
|
|
title: "My Blog".into(),
|
|
url: "https://example.com".into(),
|
|
theme_id: None,
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_title() {
|
|
let req = CreateSourceRequest {
|
|
title: " ".into(),
|
|
url: "https://example.com".into(),
|
|
theme_id: None,
|
|
};
|
|
let err = req.validate().unwrap_err();
|
|
assert!(err.contains("Title"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_title_too_long() {
|
|
let req = CreateSourceRequest {
|
|
title: "a".repeat(201),
|
|
url: "https://example.com".into(),
|
|
theme_id: None,
|
|
};
|
|
let err = req.validate().unwrap_err();
|
|
assert!(err.contains("200"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_empty_url() {
|
|
let req = CreateSourceRequest {
|
|
title: "Blog".into(),
|
|
url: "".into(),
|
|
theme_id: None,
|
|
};
|
|
let err = req.validate().unwrap_err();
|
|
assert!(err.contains("URL"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_url_too_long() {
|
|
let long_url = format!("https://example.com/{}", "a".repeat(990));
|
|
let req = CreateSourceRequest {
|
|
title: "Blog".into(),
|
|
url: long_url,
|
|
theme_id: None,
|
|
};
|
|
let err = req.validate().unwrap_err();
|
|
assert!(err.contains("1000"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_url_invalid_scheme_ftp() {
|
|
let req = CreateSourceRequest {
|
|
title: "Blog".into(),
|
|
url: "ftp://example.com".into(),
|
|
theme_id: None,
|
|
};
|
|
let err = req.validate().unwrap_err();
|
|
assert!(err.contains("http"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_url_invalid_scheme_javascript() {
|
|
let req = CreateSourceRequest {
|
|
title: "Blog".into(),
|
|
url: "javascript:alert(1)".into(),
|
|
theme_id: None,
|
|
};
|
|
let err = req.validate().unwrap_err();
|
|
assert!(err.contains("http"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_url_no_scheme() {
|
|
let req = CreateSourceRequest {
|
|
title: "Blog".into(),
|
|
url: "example.com".into(),
|
|
theme_id: None,
|
|
};
|
|
let err = req.validate().unwrap_err();
|
|
assert!(err.contains("http"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_valid_http_url() {
|
|
let req = CreateSourceRequest {
|
|
title: "Blog".into(),
|
|
url: "http://example.com".into(),
|
|
theme_id: None,
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_valid_https_url() {
|
|
let req = CreateSourceRequest {
|
|
title: "Blog".into(),
|
|
url: "https://example.com/path?query=1".into(),
|
|
theme_id: None,
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_title_exactly_200_chars() {
|
|
let req = CreateSourceRequest {
|
|
title: "a".repeat(200),
|
|
url: "https://example.com".into(),
|
|
theme_id: None,
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
|
|
#[test]
|
|
fn test_url_exactly_1000_chars() {
|
|
let url = format!("https://example.com/{}", "a".repeat(980));
|
|
assert!(url.len() == 1000);
|
|
let req = CreateSourceRequest {
|
|
title: "Blog".into(),
|
|
url,
|
|
theme_id: None,
|
|
};
|
|
assert!(req.validate().is_ok());
|
|
}
|
|
}
|