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 { pub success: bool, pub data: Option, pub message: Option, pub errors: Option>, } impl ApiResponse { pub fn success(data: T) -> Self { Self { success: true, data: Some(data), message: None, errors: None, } } } impl ApiResponse<()> { pub fn error(message: String) -> ApiResponse { ApiResponse { success: false, data: None, message: Some(message), errors: None, } } #[allow(dead_code)] pub fn validation_error(errors: Vec) -> ApiResponse { ApiResponse { success: false, data: None, message: Some("Validation failed".to_string()), errors: Some(errors), } } } #[derive(Debug, Deserialize)] pub struct ContentQueryParams { pub content_type: Option, pub state: Option, pub author_id: Option, pub category: Option, pub tags: Option, pub require_login: Option, pub search: Option, pub limit: Option, pub offset: Option, pub sort_by: Option, pub sort_order: Option, } impl From 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 = 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, pub excerpt: String, pub reading_time: Option, } #[derive(Debug, Serialize)] pub struct ContentListResponse { pub contents: Vec, 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, pub author_id: Option, pub content_type: String, pub content_format: Option, pub content: String, pub container: String, pub state: Option, pub require_login: Option, pub tags: Option>, pub category: Option, pub featured_image: Option, pub excerpt: Option, pub seo_title: Option, pub seo_description: Option, pub allow_comments: Option, pub sort_order: Option, pub metadata: Option>, } pub fn create_content_routes() -> Router> { 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>, Query(params): Query, ) -> 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>, Path(id): Path, ) -> 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>, Path(slug): Path, ) -> 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>, Path(slug): Path, ) -> 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>, Path(id): Path, ) -> 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("

Error rendering content

".to_string()) } } } Ok(None) => Html("

Content not found

".to_string()), Err(e) => { tracing::error!("Failed to get content by ID {}: {}", id, e); Html("

Error retrieving content

".to_string()) } } } pub async fn create_content( State(service): State>, Json(request): Json, ) -> 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>, Query(params): Query>, ) -> impl IntoResponse { let search_term = params.get("q").cloned().unwrap_or_default(); let limit = params .get("limit") .and_then(|l| l.parse::().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>, Query(params): Query>, ) -> impl IntoResponse { let limit = params .get("limit") .and_then(|l| l.parse::().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>) -> 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>) -> 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>) -> 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>, Path(content_type): Path, ) -> 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>, Path(category): Path, ) -> 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>, Path(author_id): Path, ) -> 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>, Query(params): Query>, ) -> impl IntoResponse { let limit = params .get("limit") .and_then(|l| l.parse::().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>, Query(params): Query>, ) -> impl IntoResponse { let limit = params .get("limit") .and_then(|l| l.parse::().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>, Path(id): Path, ) -> 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>, Path(id): Path, ) -> 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>) -> 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>, ) -> 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()]) ); } }