diff --git a/.gitignore b/.gitignore index aa28e11..72c28d9 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,9 @@ Cargo.lock /target #enviroment variables -.env \ No newline at end of file +.env + +# MacOs Finder files +.DS_Store +._* + diff --git a/README.md b/README.md index 9208953..8b0f6a5 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 @@ -168,7 +169,9 @@ async fn main() { The project integrates Swagger for API documentation using OpenAPI. This allows for automatically generated and interactive API documentation, making it easier for developers to understand and interact with the API endpoints. #### Enabling Swagger -To enable Swagger documentation in the project, ensure that the necessary dependencies are included and configured to generate the OpenAPI specification, which can then be served and viewed using tools like Swagger UI. +To enable Swagger documentation in the project, ensure that the necessary dependencies are included and configured to generate the OpenAPI specification, which can then be served and viewed using tools like Swagger UI. + +The `utoipa-swagger-ui` crate downloads the Swagger UI assets at build time. Ensure network access is available, or provide an alternate archive URL via the `SWAGGER_UI_DOWNLOAD_URL` environment variable before running `cargo build` or `cargo test`. ## Getting Started diff --git a/public/image.jpg b/public/image.jpg new file mode 100644 index 0000000..3e1c40e Binary files /dev/null and b/public/image.jpg differ diff --git a/public/image.png b/public/image.png new file mode 100644 index 0000000..c5abf76 Binary files /dev/null and b/public/image.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..c5b5dfd --- /dev/null +++ b/public/index.html @@ -0,0 +1,20 @@ + + + + + Rust Base Backend + + +

Static file served

+ Logo +

Welcome to the Rust Base Backend static file server!

+

This page is served from the public/index.html file.

+

Check the .gitignore file for ignored files in this project.

+

Enjoy your stay!

+ + + 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::errors; +use crate::swagger; +use crate::swagger::serve_swagger; +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 (addr, 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 {}", addr.port()); + println!("API base path: /{}", config.api_base); + + (tx, format!("http://127.0.0.1:{}", addr.port())) +} diff --git a/src/swagger-ui.zip b/src/swagger-ui.zip new file mode 100644 index 0000000..07ebe29 Binary files /dev/null and b/src/swagger-ui.zip differ diff --git a/src/test/integration_test.rs b/src/test/integration_test.rs index 84e3ee0..6ebc9c5 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::config; +use crate::errors::error_codes::ErrorCodes; +use crate::server::run_server; +use dotenv::dotenv; +use serde_json::Value; +use std::sync::Arc; +use tokio::sync::oneshot; +use tokio::time::{sleep, Duration}; + +async fn spawn_server() -> (oneshot::Sender<()>, String) { + std::env::set_var("PORT", "0"); + let (shutdown, base) = run_server().await; + // give the server time to start + sleep(Duration::from_millis(100)).await; + (shutdown, base) +} + +fn build_address(base: &str, endpoint: &str) -> String { + dotenv().ok(); + let config = Arc::new(config::Config::from_env()); + format!("{}/{}/{}", base, config.api_base, endpoint) +} + +fn build_static_address(base: &str, path: &str) -> String { + format!("{}/{}", base, path) +} + +#[tokio::test] +async fn test_get_messages_valid() { + let (shutdown, base) = spawn_server().await; + + let address = build_address(&base, "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, base) = spawn_server().await; + let text_expected = "Hello, world!"; + let address = build_address(&base, "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, base) = spawn_server().await; + let text_expected = "Text to search"; + let address = build_address(&base, "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, base) = spawn_server().await; + + let address = build_address(&base, "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, base) = spawn_server().await; + + let address = build_address(&base, "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, base) = spawn_server().await; + + let address = build_address(&base, "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, base) = spawn_server().await; + + let client = reqwest::Client::new(); + + let address = build_static_address(&base, "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")); +}