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"));
+}