2025-07-07 23:05:19 +01:00

693 lines
23 KiB
Rust

use axum::{
Router,
extract::{Path, Query, State},
response::{Html, IntoResponse, Json},
routing::{get, post},
};
use serde::{Deserialize, Serialize};
use shared::content::{ContentQuery, ContentState, ContentType, PageContent};
use std::collections::HashMap;
use std::sync::Arc;
use uuid::Uuid;
use super::{ContentRenderer, ContentService, TocEntry};
#[derive(Debug, Serialize)]
pub struct ApiResponse<T> {
pub success: bool,
pub data: Option<T>,
pub message: Option<String>,
pub errors: Option<Vec<String>>,
}
impl<T> ApiResponse<T> {
pub fn success(data: T) -> Self {
Self {
success: true,
data: Some(data),
message: None,
errors: None,
}
}
}
impl ApiResponse<()> {
pub fn error<U>(message: String) -> ApiResponse<U> {
ApiResponse {
success: false,
data: None,
message: Some(message),
errors: None,
}
}
#[allow(dead_code)]
pub fn validation_error<U>(errors: Vec<String>) -> ApiResponse<U> {
ApiResponse {
success: false,
data: None,
message: Some("Validation failed".to_string()),
errors: Some(errors),
}
}
}
#[derive(Debug, Deserialize)]
pub struct ContentQueryParams {
pub content_type: Option<String>,
pub state: Option<String>,
pub author_id: Option<String>,
pub category: Option<String>,
pub tags: Option<String>,
pub require_login: Option<bool>,
pub search: Option<String>,
pub limit: Option<i64>,
pub offset: Option<i64>,
pub sort_by: Option<String>,
pub sort_order: Option<String>,
}
impl From<ContentQueryParams> for ContentQuery {
fn from(params: ContentQueryParams) -> Self {
let mut query = ContentQuery::new();
if let Some(content_type) = params.content_type {
query.content_type = Some(ContentType::from(content_type));
}
if let Some(state) = params.state {
query.state = Some(ContentState::from(state));
}
if let Some(author_id) = params.author_id {
if let Ok(uuid) = Uuid::parse_str(&author_id) {
query.author_id = Some(uuid);
}
}
if let Some(category) = params.category {
query.category = Some(category);
}
if let Some(tags) = params.tags {
let tag_list: Vec<String> = tags.split(',').map(|s| s.trim().to_string()).collect();
query.tags = Some(tag_list);
}
query.require_login = params.require_login;
query.search = params.search;
query.limit = params.limit;
query.offset = params.offset;
query.sort_by = params.sort_by;
query.sort_order = params.sort_order;
query
}
}
#[derive(Debug, Serialize)]
pub struct ContentResponse {
pub content: PageContent,
pub rendered_html: String,
pub table_of_contents: Vec<TocEntry>,
pub excerpt: String,
pub reading_time: Option<i32>,
}
#[derive(Debug, Serialize)]
pub struct ContentListResponse {
pub contents: Vec<PageContent>,
pub total_count: i64,
pub has_more: bool,
}
#[derive(Debug, Deserialize)]
pub struct CreateContentRequest {
pub slug: String,
pub title: String,
pub name: String,
pub author: Option<String>,
pub author_id: Option<Uuid>,
pub content_type: String,
pub content_format: Option<String>,
pub content: String,
pub container: String,
pub state: Option<String>,
pub require_login: Option<bool>,
pub tags: Option<Vec<String>>,
pub category: Option<String>,
pub featured_image: Option<String>,
pub excerpt: Option<String>,
pub seo_title: Option<String>,
pub seo_description: Option<String>,
pub allow_comments: Option<bool>,
pub sort_order: Option<i32>,
pub metadata: Option<HashMap<String, String>>,
}
pub fn create_content_routes() -> Router<Arc<ContentService>> {
Router::new()
.route("/contents", get(list_contents).post(create_content))
.route("/contents/:id", get(get_content_by_id))
.route("/contents/slug/:slug", get(get_content_by_slug))
.route("/contents/slug/:slug/render", get(render_content_by_slug))
.route("/contents/search", get(search_contents))
.route("/contents/published", get(get_published_contents))
.route("/contents/stats", get(get_content_stats))
.route("/contents/tags", get(get_all_tags))
.route("/contents/categories", get(get_all_categories))
.route("/contents/type/:content_type", get(get_contents_by_type))
.route(
"/contents/category/:category",
get(get_contents_by_category),
)
.route("/contents/author/:author_id", get(get_contents_by_author))
.route("/contents/recent", get(get_recent_contents))
.route("/contents/popular", get(get_popular_contents))
.route("/contents/:id/increment-view", post(increment_view_count))
.route("/contents/:id/render", get(render_content_by_id))
.route("/contents/:id/toc", get(get_table_of_contents))
.route("/contents/reload", post(reload_content))
.route(
"/contents/publish-scheduled",
post(publish_scheduled_content),
)
}
pub async fn list_contents(
State(service): State<Arc<ContentService>>,
Query(params): Query<ContentQueryParams>,
) -> impl IntoResponse {
let query = ContentQuery::from(params);
match service.query_contents(&query).await {
Ok(contents) => {
let total_count = contents.len() as i64;
let has_more = query.limit.map_or(false, |limit| total_count >= limit);
Json(ApiResponse::success(ContentListResponse {
contents,
total_count,
has_more,
}))
}
Err(e) => {
tracing::error!("Failed to list contents: {}", e);
Json(ApiResponse::error(
"Failed to retrieve contents".to_string(),
))
}
}
}
pub async fn get_content_by_id(
State(service): State<Arc<ContentService>>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
match service.get_content_by_id(id).await {
Ok(Some(content)) => Json(ApiResponse::success(content)),
Ok(None) => Json(ApiResponse::error("Content not found".to_string())),
Err(e) => {
tracing::error!("Failed to get content by ID {}: {}", id, e);
Json(ApiResponse::error("Failed to retrieve content".to_string()))
}
}
}
pub async fn get_content_by_slug(
State(service): State<Arc<ContentService>>,
Path(slug): Path<String>,
) -> impl IntoResponse {
match service.get_content_by_slug(&slug).await {
Ok(Some(content)) => Json(ApiResponse::success(content)),
Ok(None) => Json(ApiResponse::error("Content not found".to_string())),
Err(e) => {
tracing::error!("Failed to get content by slug {}: {}", slug, e);
Json(ApiResponse::error("Failed to retrieve content".to_string()))
}
}
}
pub async fn render_content_by_slug(
State(service): State<Arc<ContentService>>,
Path(slug): Path<String>,
) -> impl IntoResponse {
match service.get_content_by_slug(&slug).await {
Ok(Some(content)) => {
let renderer = ContentRenderer::new();
match renderer.render_content(&content) {
Ok(rendered_html) => {
let table_of_contents = renderer
.generate_table_of_contents(&content)
.unwrap_or_default();
let excerpt = renderer.extract_excerpt(&content, 200);
// Calculate reading time (rough estimate: 200 words per minute)
let word_count = content.content.split_whitespace().count();
let reading_time = Some(((word_count as f32 / 200.0).ceil() as i32).max(1));
Json(ApiResponse::success(ContentResponse {
content,
rendered_html,
table_of_contents,
excerpt,
reading_time,
}))
}
Err(e) => {
tracing::error!("Failed to render content: {}", e);
Json(ApiResponse::error("Failed to render content".to_string()))
}
}
}
Ok(None) => Json(ApiResponse::error("Content not found".to_string())),
Err(e) => {
tracing::error!("Failed to get content by slug {}: {}", slug, e);
Json(ApiResponse::error("Failed to retrieve content".to_string()))
}
}
}
pub async fn render_content_by_id(
State(service): State<Arc<ContentService>>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
match service.get_content_by_id(id).await {
Ok(Some(content)) => {
let renderer = ContentRenderer::new();
match renderer.render_content(&content) {
Ok(rendered_html) => Html(rendered_html),
Err(e) => {
tracing::error!("Failed to render content: {}", e);
Html("<p>Error rendering content</p>".to_string())
}
}
}
Ok(None) => Html("<p>Content not found</p>".to_string()),
Err(e) => {
tracing::error!("Failed to get content by ID {}: {}", id, e);
Html("<p>Error retrieving content</p>".to_string())
}
}
}
pub async fn create_content(
State(service): State<Arc<ContentService>>,
Json(request): Json<CreateContentRequest>,
) -> impl IntoResponse {
let content_type = ContentType::from(request.content_type);
let content_format = request
.content_format
.map(shared::content::ContentFormat::from)
.unwrap_or(shared::content::ContentFormat::Markdown);
let state = request
.state
.map(ContentState::from)
.unwrap_or(ContentState::Draft);
let mut content = PageContent::new(
request.slug,
request.title,
request.name,
content_type,
request.content,
request.container,
request.author_id,
);
content.author = request.author;
content.content_format = content_format;
content.state = state;
content.require_login = request.require_login.unwrap_or(false);
content.tags = request.tags.unwrap_or_default();
content.category = request.category;
content.featured_image = request.featured_image;
content.excerpt = request.excerpt;
content.seo_title = request.seo_title;
content.seo_description = request.seo_description;
content.allow_comments = request.allow_comments.unwrap_or(true);
content.sort_order = request.sort_order.unwrap_or(0);
content.metadata = request.metadata.unwrap_or_default();
match service.create_content(&content).await {
Ok(()) => Json(ApiResponse::success(content)),
Err(e) => {
tracing::error!("Failed to create content: {}", e);
Json(ApiResponse::error("Failed to create content".to_string()))
}
}
}
// Note: Update and delete endpoints removed for now to fix compilation
// They can be re-added later with proper implementation
pub async fn search_contents(
State(service): State<Arc<ContentService>>,
Query(params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
let search_term = params.get("q").cloned().unwrap_or_default();
let limit = params
.get("limit")
.and_then(|l| l.parse::<i64>().ok())
.unwrap_or(20);
if search_term.is_empty() {
return Json(ApiResponse::error("Search term is required".to_string()));
}
match service.search_contents(&search_term, Some(limit)).await {
Ok(contents) => {
let total_count = contents.len() as i64;
Json(ApiResponse::success(ContentListResponse {
contents,
total_count,
has_more: total_count >= limit,
}))
}
Err(e) => {
tracing::error!("Failed to search contents: {}", e);
Json(ApiResponse::error("Failed to search contents".to_string()))
}
}
}
pub async fn get_published_contents(
State(service): State<Arc<ContentService>>,
Query(params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
let limit = params
.get("limit")
.and_then(|l| l.parse::<i64>().ok())
.unwrap_or(50);
match service.get_published_contents(Some(limit)).await {
Ok(contents) => {
let total_count = contents.len() as i64;
Json(ApiResponse::success(ContentListResponse {
contents,
total_count,
has_more: total_count >= limit,
}))
}
Err(e) => {
tracing::error!("Failed to get published contents: {}", e);
Json(ApiResponse::error(
"Failed to retrieve published contents".to_string(),
))
}
}
}
pub async fn get_content_stats(State(service): State<Arc<ContentService>>) -> impl IntoResponse {
match service.get_content_stats().await {
Ok(stats) => Json(ApiResponse::success(stats)),
Err(e) => {
tracing::error!("Failed to get content stats: {}", e);
Json(ApiResponse::error(
"Failed to retrieve content statistics".to_string(),
))
}
}
}
pub async fn get_all_tags(State(service): State<Arc<ContentService>>) -> impl IntoResponse {
match service.get_all_tags().await {
Ok(tags) => Json(ApiResponse::success(tags)),
Err(e) => {
tracing::error!("Failed to get tags: {}", e);
Json(ApiResponse::error("Failed to retrieve tags".to_string()))
}
}
}
pub async fn get_all_categories(State(service): State<Arc<ContentService>>) -> impl IntoResponse {
match service.get_all_categories().await {
Ok(categories) => Json(ApiResponse::success(categories)),
Err(e) => {
tracing::error!("Failed to get categories: {}", e);
Json(ApiResponse::error(
"Failed to retrieve categories".to_string(),
))
}
}
}
pub async fn get_contents_by_type(
State(service): State<Arc<ContentService>>,
Path(content_type): Path<String>,
) -> impl IntoResponse {
let content_type = ContentType::from(content_type);
match service.get_contents_by_type(content_type).await {
Ok(contents) => {
let total_count = contents.len() as i64;
Json(ApiResponse::success(ContentListResponse {
contents,
total_count,
has_more: false,
}))
}
Err(e) => {
tracing::error!("Failed to get contents by type: {}", e);
Json(ApiResponse::error(
"Failed to retrieve contents by type".to_string(),
))
}
}
}
pub async fn get_contents_by_category(
State(service): State<Arc<ContentService>>,
Path(category): Path<String>,
) -> impl IntoResponse {
match service.get_contents_by_category(&category).await {
Ok(contents) => {
let total_count = contents.len() as i64;
Json(ApiResponse::success(ContentListResponse {
contents,
total_count,
has_more: false,
}))
}
Err(e) => {
tracing::error!("Failed to get contents by category: {}", e);
Json(ApiResponse::error(
"Failed to retrieve contents by category".to_string(),
))
}
}
}
pub async fn get_contents_by_author(
State(service): State<Arc<ContentService>>,
Path(author_id): Path<Uuid>,
) -> impl IntoResponse {
match service.get_contents_by_author(author_id).await {
Ok(contents) => {
let total_count = contents.len() as i64;
Json(ApiResponse::success(ContentListResponse {
contents,
total_count,
has_more: false,
}))
}
Err(e) => {
tracing::error!("Failed to get contents by author: {}", e);
Json(ApiResponse::error(
"Failed to retrieve contents by author".to_string(),
))
}
}
}
pub async fn get_recent_contents(
State(service): State<Arc<ContentService>>,
Query(params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
let limit = params
.get("limit")
.and_then(|l| l.parse::<i64>().ok())
.unwrap_or(10);
match service.get_recent_contents(limit).await {
Ok(contents) => {
let total_count = contents.len() as i64;
Json(ApiResponse::success(ContentListResponse {
contents,
total_count,
has_more: total_count >= limit,
}))
}
Err(e) => {
tracing::error!("Failed to get recent contents: {}", e);
Json(ApiResponse::error(
"Failed to retrieve recent contents".to_string(),
))
}
}
}
pub async fn get_popular_contents(
State(service): State<Arc<ContentService>>,
Query(params): Query<HashMap<String, String>>,
) -> impl IntoResponse {
let limit = params
.get("limit")
.and_then(|l| l.parse::<i64>().ok())
.unwrap_or(10);
match service.get_popular_contents(limit).await {
Ok(contents) => {
let total_count = contents.len() as i64;
Json(ApiResponse::success(ContentListResponse {
contents,
total_count,
has_more: total_count >= limit,
}))
}
Err(e) => {
tracing::error!("Failed to get popular contents: {}", e);
Json(ApiResponse::error(
"Failed to retrieve popular contents".to_string(),
))
}
}
}
pub async fn increment_view_count(
State(service): State<Arc<ContentService>>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
match service.increment_view_count(id).await {
Ok(()) => Json(ApiResponse::success("View count incremented")),
Err(e) => {
tracing::error!("Failed to increment view count: {}", e);
Json(ApiResponse::error(
"Failed to increment view count".to_string(),
))
}
}
}
pub async fn get_table_of_contents(
State(service): State<Arc<ContentService>>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
match service.get_content_by_id(id).await {
Ok(Some(content)) => {
let renderer = ContentRenderer::new();
match renderer.generate_table_of_contents(&content) {
Ok(toc) => Json(ApiResponse::success(toc)),
Err(e) => {
tracing::error!("Failed to generate table of contents: {}", e);
Json(ApiResponse::error(
"Failed to generate table of contents".to_string(),
))
}
}
}
Ok(None) => Json(ApiResponse::error("Content not found".to_string())),
Err(e) => {
tracing::error!("Failed to get content for TOC: {}", e);
Json(ApiResponse::error("Failed to retrieve content".to_string()))
}
}
}
pub async fn reload_content(State(service): State<Arc<ContentService>>) -> impl IntoResponse {
match service.reload_file_content().await {
Ok(()) => Json(ApiResponse::success("Content reloaded successfully")),
Err(e) => {
tracing::error!("Failed to reload content: {}", e);
Json(ApiResponse::error("Failed to reload content".to_string()))
}
}
}
pub async fn publish_scheduled_content(
State(service): State<Arc<ContentService>>,
) -> impl IntoResponse {
match service.publish_scheduled_content().await {
Ok(published_contents) => {
let count = published_contents.len();
Json(ApiResponse::success(format!(
"Published {} scheduled contents",
count
)))
}
Err(e) => {
tracing::error!("Failed to publish scheduled content: {}", e);
Json(ApiResponse::error(
"Failed to publish scheduled content".to_string(),
))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_content_query_params_conversion() {
let params = ContentQueryParams {
content_type: Some("blog".to_string()),
state: Some("published".to_string()),
author_id: None,
category: Some("tech".to_string()),
tags: Some("rust,web".to_string()),
require_login: Some(false),
search: Some("test".to_string()),
limit: Some(10),
offset: Some(0),
sort_by: Some("created_at".to_string()),
sort_order: Some("DESC".to_string()),
};
let query = ContentQuery::from(params);
assert_eq!(query.content_type, Some(ContentType::Blog));
assert_eq!(query.state, Some(ContentState::Published));
assert_eq!(query.category, Some("tech".to_string()));
assert_eq!(
query.tags,
Some(vec!["rust".to_string(), "web".to_string()])
);
assert_eq!(query.require_login, Some(false));
assert_eq!(query.search, Some("test".to_string()));
assert_eq!(query.limit, Some(10));
assert_eq!(query.offset, Some(0));
assert_eq!(query.sort_by, Some("created_at".to_string()));
assert_eq!(query.sort_order, Some("DESC".to_string()));
}
#[test]
fn test_api_response_creation() {
let success_response = ApiResponse::success("test data");
assert!(success_response.success);
assert_eq!(success_response.data, Some("test data"));
assert!(success_response.message.is_none());
assert!(success_response.errors.is_none());
let error_response: ApiResponse<()> = ApiResponse::error("test error".to_string());
assert!(!error_response.success);
assert!(error_response.data.is_none());
assert_eq!(error_response.message, Some("test error".to_string()));
assert!(error_response.errors.is_none());
let validation_response: ApiResponse<()> =
ApiResponse::validation_error(vec!["error1".to_string(), "error2".to_string()]);
assert!(!validation_response.success);
assert!(validation_response.data.is_none());
assert_eq!(
validation_response.message,
Some("Validation failed".to_string())
);
assert_eq!(
validation_response.errors,
Some(vec!["error1".to_string(), "error2".to_string()])
);
}
}