From e5e5b5a4b826bee8d5c23d4b488cc342c029b779 Mon Sep 17 00:00:00 2001 From: Luis Carlos Date: Sun, 17 Aug 2025 17:56:15 -0400 Subject: [PATCH] test: remove sample images --- .gitignore | 4 +- README.md | 5 +- public/index.html | 10 + src/config.rs | 25 ++- src/controllers/mod.rs | 4 +- src/router.rs | 12 +- src/server.rs | 141 ++++++------ src/test/integration_test.rs | 389 ++++++++++++++++------------------ src/test/mod.rs | 9 +- src/test/static_files_test.rs | 20 ++ 10 files changed, 318 insertions(+), 301 deletions(-) create mode 100644 public/index.html create mode 100644 src/test/static_files_test.rs diff --git a/.gitignore b/.gitignore index aa28e11..e58617b 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,6 @@ Cargo.lock /target #enviroment variables -.env \ No newline at end of file +.env +# swagger ui +swagger-ui.zip diff --git a/README.md b/README.md index 9208953..eea91d9 100644 --- a/README.md +++ b/README.md @@ -5,8 +5,9 @@ Rust-Base-Backend is a foundational backend project written in Rust, designed wi ## Features - Modular and scalable architecture -- RESTful API setup -- Docker support for containerization +- RESTful API setup +- Static file serving from the `public` directory +- Docker support for containerization - Middleware for parameter validation - Error handling conforming to RFC 7807 - Asynchronous programming with Tokio diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..4c684dc --- /dev/null +++ b/public/index.html @@ -0,0 +1,10 @@ + + + + + Rust Base Backend + + +

Static file served

+ + diff --git a/src/config.rs b/src/config.rs index 2eb4948..10e4af6 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,18 +1,21 @@ use std::env; pub struct Config { - pub port: u16, - pub api_base: String + pub port: u16, + pub api_base: String, + pub static_dir: String, } impl Config { - pub fn from_env() -> Self { - Self { - port: env::var("PORT") - .unwrap_or_else(|_| "3030".to_string()) - .parse() - .expect("PORT must be a number"), - api_base : env::var("API_BASE").unwrap_or_else(|_| "/api/v1".trim_matches('/').to_string()) + pub fn from_env() -> Self { + Self { + port: env::var("PORT") + .unwrap_or_else(|_| "3030".to_string()) + .parse() + .expect("PORT must be a number"), + api_base: env::var("API_BASE") + .unwrap_or_else(|_| "/api/v1".trim_matches('/').to_string()), + static_dir: env::var("STATIC_DIR").unwrap_or_else(|_| "public".to_string()), + } } - } -} \ No newline at end of file +} diff --git a/src/controllers/mod.rs b/src/controllers/mod.rs index f2d992b..237c3f3 100644 --- a/src/controllers/mod.rs +++ b/src/controllers/mod.rs @@ -1,6 +1,6 @@ pub mod base_controller; -use std::convert::Infallible; +use warp::Rejection; use std::sync::Arc; use crate::repositories::base_Repository::InMemoryBaseRepository; @@ -9,7 +9,7 @@ use crate::config::Config; use crate::router::Router; -pub fn base_routes(config: Arc) -> impl warp::Filter +Clone { +pub fn base_routes(config: Arc) -> impl warp::Filter +Clone { let repository = InMemoryBaseRepository::new(); let service = BaseServiceImpl::new(repository); let router = Router::new(service, Arc::clone(&config)); diff --git a/src/router.rs b/src/router.rs index 4e2fe08..6a8f241 100644 --- a/src/router.rs +++ b/src/router.rs @@ -2,7 +2,8 @@ use warp::Filter; use crate::services::base_service::BaseService; use crate::config::Config; use std::sync::Arc; -use std::convert::Infallible; +use std::convert::Infallible; +use warp::Rejection; use crate::controllers::base_controller::{handle_get_messages, handle_create_message, handle_search_messages}; pub struct Router { @@ -19,7 +20,7 @@ impl Router { } -pub fn routes(&self) -> impl Filter + Clone { +pub fn routes(&self) -> impl Filter + Clone { let service = self.service.clone(); let api_base = self.config.api_base.trim_matches('/').to_string(); let api_segments: Vec = api_base.split('/').map(|s| s.to_string()).collect(); @@ -52,10 +53,9 @@ pub fn routes(&self) -> impl Filter (oneshot::Sender<()>, String) { - dotenv().ok(); - - let config = Arc::new(config::Config::from_env()); - let config_swagger = Arc::new(Config::from(format!("/{}/api-doc.json", config.api_base))); - let routes = controllers::base_routes(Arc::clone(&config)); - - let port:u16 = config.port; - let api_base = config.api_base.trim_matches('/').to_string(); - let api_segments: Vec = api_base.split('/').map(|s| s.to_string()).collect(); - - let mut api_path = warp::path(api_segments[0].clone()).boxed(); - for segment in &api_segments[1..] { - api_path = api_path.and(warp::path(segment.clone())).boxed(); - } - - let api_doc = api_path.clone() - .and(warp::path("api-doc.json")) - .and(warp::get()) - .map(|| warp::reply::json(&swagger::ApiDoc::openapi())); - - let swagger_ui = api_path.clone() - .and(warp::path("swagger-ui")) - .and(warp::get()) - .and(warp::path::full()) - .and(warp::path::tail()) - .and(warp::any().map(move || config_swagger.clone())) - .and_then(serve_swagger); - - let (tx, rx) = oneshot::channel(); - let routes = api_doc.or(swagger_ui).or(routes); - - let (_, server) = warp::serve(routes) - .bind_with_graceful_shutdown(([127,0,0,1], port), async { - rx.await.ok(); - }); - - tokio::spawn(server); - - println!("Server starting on port {}", port); - println!("API base path: /{}", config.api_base); - - (tx, format!("http://127.0.0.1:{}", port)) -} +use crate::config; +use crate::controllers; +use crate::swagger; +use crate::swagger::serve_swagger; +use crate::errors; +use dotenv::dotenv; +use std::sync::Arc; +use tokio::sync::oneshot; + +use utoipa::{ + openapi::security::{ApiKey, ApiKeyValue, SecurityScheme}, + openapi::Server, + Modify, OpenApi, +}; +use utoipa_swagger_ui::Config; +use warp::{ + http::Uri, + hyper::{Response, StatusCode}, + path::{FullPath, Tail}, + Filter, Rejection, Reply, +}; + +pub async fn run_server() -> (oneshot::Sender<()>, String) { + dotenv().ok(); + + let config = Arc::new(config::Config::from_env()); + let config_swagger = Arc::new(Config::from(format!("/{}/api-doc.json", config.api_base))); + let routes = controllers::base_routes(Arc::clone(&config)); + let static_files = warp::fs::dir(config.static_dir.clone()); + + let port: u16 = config.port; + let api_base = config.api_base.trim_matches('/').to_string(); + let api_segments: Vec = api_base.split('/').map(|s| s.to_string()).collect(); + + let mut api_path = warp::path(api_segments[0].clone()).boxed(); + for segment in &api_segments[1..] { + api_path = api_path.and(warp::path(segment.clone())).boxed(); + } + + let api_doc = api_path + .clone() + .and(warp::path("api-doc.json")) + .and(warp::get()) + .and_then(|| async { + Ok::<_, Rejection>(warp::reply::json(&swagger::ApiDoc::openapi())) + }); + + let swagger_ui = api_path + .clone() + .and(warp::path("swagger-ui")) + .and(warp::get()) + .and(warp::path::full()) + .and(warp::path::tail()) + .and(warp::any().map(move || config_swagger.clone())) + .and_then(serve_swagger); + + let (tx, rx) = oneshot::channel(); + let routes = api_doc + .or(swagger_ui) + .or(routes) + .or(static_files) + .recover(errors::handle_rejection); + + let (_, server) = + warp::serve(routes).bind_with_graceful_shutdown(([127, 0, 0, 1], port), async { + rx.await.ok(); + }); + + tokio::spawn(server); + + println!("Server starting on port {}", port); + println!("API base path: /{}", config.api_base); + + (tx, format!("http://127.0.0.1:{}", port)) +} diff --git a/src/test/integration_test.rs b/src/test/integration_test.rs index 84e3ee0..0606219 100644 --- a/src/test/integration_test.rs +++ b/src/test/integration_test.rs @@ -1,209 +1,180 @@ -use crate::server::run_server; -use tokio::task; -use tokio::time::{sleep, Duration}; - -struct TestServer { - shutdown: Option>, - base_url: String, -} - -impl TestServer { - async fn new() -> Self { - dotenv::dotenv().ok(); - let (shutdown, base_url) = run_server().await; - - let _keep_alive = task::spawn(async move { - sleep(Duration::from_secs(360)).await; - }); - TestServer { - shutdown: Some(shutdown), - base_url, - } - } -} - -impl Drop for TestServer { - fn drop(&mut self) { - if let Some(shutdown) = self.shutdown.take() { - let _ = shutdown.send(()); - } - } -} - -#[cfg(test)] -mod test { - use super::TestServer; - use crate::config; - use crate::errors::error_codes::ErrorCodes; - use dotenv::dotenv; - use once_cell::sync::Lazy; - use reqwest; - use serde_json::Value; - use std::sync::Arc; - use tokio::sync::Mutex; - - static SERVER: Lazy>>> = Lazy::new(|| Mutex::new(None)); - - async fn initialize_server() -> Arc { - let mut server_lock = SERVER.lock().await; - - if server_lock.is_none() { - let server = TestServer::new().await; - *server_lock = Some(Arc::new(server)); - } - - Arc::clone(server_lock.as_ref().unwrap()) - } - - fn build_address(endpoint: &str) -> String { - dotenv().ok(); - let config = Arc::new(config::Config::from_env()); - format!("http://localhost:{}/{}/{}", config.port, config.api_base, endpoint) - } - - #[tokio::test] - async fn test_get_messages_valid() { - let server = initialize_server().await; - - let address = build_address("messages"); - let client = reqwest::Client::new(); - let response = client.get(address).send().await.unwrap(); - - assert_eq!(response.status(), 200); - - let body: Value = response.json().await.unwrap(); - - assert!(body.is_array()); - } - - #[tokio::test] - async fn test_create_message_valid() { - let server = initialize_server().await; - let text_expected = "Hello, world!"; - - dotenv().ok(); - let config = Arc::new(config::Config::from_env()); - let address = build_address("messages"); - let client = reqwest::Client::new(); - let response = client - .post(address) - .json(&serde_json::json!({ - "content": text_expected - })) - .send() - .await - .unwrap(); - - assert_eq!(response.status(), 201); - - let body: Value = response.json().await.unwrap(); - - assert_eq!(body["content"], text_expected); - } - - #[tokio::test] - async fn test_search_message_valid() { - let server = initialize_server().await; - - let text_expected = "Text to search"; - - dotenv().ok(); - let config = Arc::new(config::Config::from_env()); - let address = build_address("messages"); - let client = reqwest::Client::new(); - let response = client - .post(address.clone()) - .json(&serde_json::json!({ - "content": text_expected - })) - .send() - .await - .unwrap(); - - assert_eq!(response.status(), 201); - - let response = client - .get(format!("{}/{}", address.clone(), "Text")) - .send() - .await - .unwrap(); - - assert_eq!(response.status(), 200); - - let body: Value = response.json().await.unwrap(); - - assert_eq!(body[0]["content"], text_expected); - } - - #[tokio::test] - async fn test_create_message_invalid_null() { - let server = initialize_server().await; - - dotenv().ok(); - let config = Arc::new(config::Config::from_env()); - let address = build_address("messages"); - let client = reqwest::Client::new(); - - // null test - let response = client - .post(address.clone()) - .json(&serde_json::json!({ - "content": null - })) - .send() - .await - .unwrap(); - assert_eq!(response.status(), 400); - let body: Value = response.json().await.unwrap(); - assert_eq!(body["details"][0]["error_code"], ErrorCodes::NotNull as u16); - } - - #[tokio::test] - async fn test_create_message_invalid_empty() { - let server = initialize_server().await; - - dotenv().ok(); - let config = Arc::new(config::Config::from_env()); - let address = build_address("messages"); - let client = reqwest::Client::new(); - - // Empty test - let response = client - .post(address.clone()) - .json(&serde_json::json!({ - "content": "" - })) - .send() - .await - .unwrap(); - assert_eq!(response.status(), 400); - let body: Value = response.json().await.unwrap(); - assert_eq!( - body["details"][0]["error_code"], - ErrorCodes::NotEmpty as u16 - ); - } - - #[tokio::test] - async fn test_create_message_invalid_maxsize() { - let server = initialize_server().await; - - dotenv().ok(); - let config = Arc::new(config::Config::from_env()); - let address = build_address("messages"); - let client = reqwest::Client::new(); - - // More than 32 characters test - let response = client - .post(address.clone()) - .json(&serde_json::json!({ - "content": "This is a very long text that is more than 32 characters" - })) - .send() - .await - .unwrap(); - assert_eq!(response.status(), 400); - let body: Value = response.json().await.unwrap(); - assert_eq!(body["details"][0]["error_code"], ErrorCodes::MaxSize as u16); - } -} +use crate::server::run_server; +use tokio::time::{sleep, Duration}; +use tokio::sync::oneshot; +use crate::config; +use crate::errors::error_codes::ErrorCodes; +use dotenv::dotenv; +use serde_json::Value; +use std::sync::Arc; + +async fn spawn_server() -> oneshot::Sender<()> { + let (shutdown, _base) = run_server().await; + // give the server time to start + sleep(Duration::from_millis(100)).await; + shutdown +} + +fn build_address(endpoint: &str) -> String { + dotenv().ok(); + let config = Arc::new(config::Config::from_env()); + format!("http://localhost:{}/{}/{}", config.port, config.api_base, endpoint) +} + +fn build_static_address(path: &str) -> String { + dotenv().ok(); + let config = Arc::new(config::Config::from_env()); + format!("http://localhost:{}/{}", config.port, path) +} + +#[tokio::test] +async fn test_get_messages_valid() { + let shutdown = spawn_server().await; + + let address = build_address("messages"); + let client = reqwest::Client::new(); + let response = client.get(address).send().await.unwrap(); + + assert_eq!(response.status(), 200); + + let body: Value = response.json().await.unwrap(); + assert!(body.is_array()); + + let _ = shutdown.send(()); +} + +#[tokio::test] +async fn test_create_message_valid() { + let shutdown = spawn_server().await; + let text_expected = "Hello, world!"; + + let address = build_address("messages"); + let client = reqwest::Client::new(); + let response = client + .post(address.clone()) + .json(&serde_json::json!({ + "content": text_expected + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), 201); + + let body: Value = response.json().await.unwrap(); + assert_eq!(body["content"], text_expected); + + let _ = shutdown.send(()); +} + +#[tokio::test] +async fn test_search_message_valid() { + let shutdown = spawn_server().await; + let text_expected = "Text to search"; + + let address = build_address("messages"); + let client = reqwest::Client::new(); + let response = client + .post(address.clone()) + .json(&serde_json::json!({ + "content": text_expected + })) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), 201); + + let response = client + .get(format!("{}/{}", address, "Text")) + .send() + .await + .unwrap(); + + assert_eq!(response.status(), 200); + + let body: Value = response.json().await.unwrap(); + assert_eq!(body[0]["content"], text_expected); + + let _ = shutdown.send(()); +} + +#[tokio::test] +async fn test_create_message_invalid_null() { + let shutdown = spawn_server().await; + + let address = build_address("messages"); + let client = reqwest::Client::new(); + + let response = client + .post(address.clone()) + .json(&serde_json::json!({ + "content": null + })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400); + let body: Value = response.json().await.unwrap(); + assert_eq!(body["details"][0]["error_code"], ErrorCodes::NotNull as u16); + + let _ = shutdown.send(()); +} + +#[tokio::test] +async fn test_create_message_invalid_empty() { + let shutdown = spawn_server().await; + + let address = build_address("messages"); + let client = reqwest::Client::new(); + + let response = client + .post(address.clone()) + .json(&serde_json::json!({ + "content": "" + })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400); + let body: Value = response.json().await.unwrap(); + assert_eq!(body["details"][0]["error_code"], ErrorCodes::NotEmpty as u16); + + let _ = shutdown.send(()); +} + +#[tokio::test] +async fn test_create_message_invalid_maxsize() { + let shutdown = spawn_server().await; + + let address = build_address("messages"); + let client = reqwest::Client::new(); + + let response = client + .post(address.clone()) + .json(&serde_json::json!({ + "content": "This is a very long text that is more than 32 characters" + })) + .send() + .await + .unwrap(); + assert_eq!(response.status(), 400); + let body: Value = response.json().await.unwrap(); + assert_eq!(body["details"][0]["error_code"], ErrorCodes::MaxSize as u16); + + let _ = shutdown.send(()); +} + +#[tokio::test] +async fn test_static_file_serving() { + let shutdown = spawn_server().await; + + let client = reqwest::Client::new(); + + let address = build_static_address("index.html"); + let response = client.get(address).send().await.unwrap(); + assert_eq!(response.status(), 200); + let body = response.text().await.unwrap(); + assert!(body.contains("Static file served")); + + let _ = shutdown.send(()); +} diff --git a/src/test/mod.rs b/src/test/mod.rs index c82744b..d6db76e 100644 --- a/src/test/mod.rs +++ b/src/test/mod.rs @@ -1,4 +1,5 @@ -pub mod base_repository_tests; -pub mod base_service_test; -pub mod base_controller_test; -pub mod integration_test; \ No newline at end of file +pub mod base_repository_tests; +pub mod base_service_test; +pub mod base_controller_test; +pub mod integration_test; +pub mod static_files_test; diff --git a/src/test/static_files_test.rs b/src/test/static_files_test.rs new file mode 100644 index 0000000..74a18b0 --- /dev/null +++ b/src/test/static_files_test.rs @@ -0,0 +1,20 @@ +use std::sync::Arc; +use crate::config; + +#[tokio::test] +async fn test_index_html_is_served() { + dotenv::dotenv().ok(); + let config = Arc::new(config::Config::from_env()); + let static_files = warp::fs::dir(config.static_dir.clone()); + + let res = warp::test::request() + .method("GET") + .path("/index.html") + .reply(&static_files) + .await; + + assert_eq!(res.status(), 200); + assert_eq!(res.headers().get("content-type").unwrap(), "text/html"); + assert!(!res.body().is_empty()); + assert!(String::from_utf8_lossy(res.body()).contains("Static file served")); +}