diff --git a/Cargo.toml b/Cargo.toml index a1e9b53..777f044 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,27 +1,33 @@ [package] name = "limiting-factor" version = "0.1.0" authors = [ "Sébastien Santoro ", ] description = "Library to create a REST API with Diesel and Rocket" readme = "README.md" keywords = [ "Diesel", "API", "Rocket", "REST", ] categories = [ "web-programming", ] license = "BSD-2-Clause" repository = "https://devcentral.nasqueron.org/source/limiting-factor/" [dependencies] -diesel = { version = "^1.0.0", features = ["postgres", "r2d2", "chrono"] } +diesel = { version = "^1.0.0", features = ["postgres", "r2d2", "chrono"], optional = true } dotenv = "0.9.0" log = "^0.4.4" -r2d2 = "^0.8.2" +r2d2 = { version = "^0.8.2", optional = true } rocket = "^0.3.16" rocket_contrib = { version = "^0.3.16", features = [ "json" ] } + +[features] +default = ["pgsql"] +minimal = [] + +pgsql = ["diesel", "r2d2"] diff --git a/src/api/replies.rs b/src/api/replies.rs index 9f15880..189d0e3 100644 --- a/src/api/replies.rs +++ b/src/api/replies.rs @@ -1,128 +1,133 @@ //! # API standard and JSON responses. //! //! This module provides useful traits and methods to craft API replies from an existing type. -use std::error::Error; - -use diesel::result::DatabaseErrorInformation; -use diesel::result::DatabaseErrorKind; +#[cfg(feature = "pgsql")] +use diesel::result::{DatabaseErrorInformation, DatabaseErrorKind, QueryResult}; +#[cfg(feature = "pgsql")] use diesel::result::Error as ResultError; -use diesel::result::QueryResult; + use rocket::http::Status; use rocket::response::Failure; use rocket_contrib::Json; +#[cfg(feature = "pgsql")] +use std::error::Error; + /* ------------------------------------------------------------- Custom types - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ pub type ApiJsonResponse = Result, Failure>; /* ------------------------------------------------------------- API Response :: Implementation for QueryResult (Diesel ORM) - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ /// This trait allows to consume an object into an HTTP response. pub trait ApiResponse { /// Consumes the value and creates a JSON or a Failure result response. fn into_json_response(self) -> ApiJsonResponse; } +#[cfg(feature = "pgsql")] impl ApiResponse for QueryResult { /// Prepares an API response from a query result. /// /// The result is the data structure prepared by the Diesel ORM after a SELECT query /// with one result, for example using `first` method. You can also you use it to /// parse the returning result (... RETURNING *), which is a default for Diesel after /// an INSERT query. /// /// So result can be: /// - Ok(T) /// - Err(any database error) /// /// # Examples /// /// To offer a /player/foo route to serve player information from the player table: /// /// ``` /// use limiting_factor::api::ApiResponse; /// use limiting_factor::api::ApiJsonResponse; /// /// #[get("/player/")] /// pub fn get_player(connection: DatabaseConnection, name: String) -> ApiJsonResponse { /// players /// .filter(username.eq(&name)) /// .first::(&*connection) /// .into_json_response() /// } /// ``` /// /// This will produce a JSON representation when the result is found, /// a 404 error when no result is found, a 500 error if there is a database issue. fn into_json_response(self) -> ApiJsonResponse { match self { // CASE I - The query returns one value, we return a JSON representation fo the item Ok(item) => Ok(Json(item)), Err(error) => match error { // Case II - The query returns no result, we return a 404 Not found response ResultError::NotFound => Err(Failure::from(Status::NotFound)), // Case III - We need to handle a database error, which could be a 400/409/500 ResultError::DatabaseError(kind, details) => Err(build_database_error_response(kind, details)), // Case IV - The error is probably server responsbility, log it and throw a 500 _ => Err(error.into_failure_response()), } } } } /* ------------------------------------------------------------- Failure response :: Implementation for diesel::result::Error - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ /// This trait allows to consume an object into an HTTP failure response. pub trait FailureResponse { /// Consumes the variable and creates a Failure response . fn into_failure_response(self) -> Failure; } +#[cfg(feature = "pgsql")] impl FailureResponse for ResultError { /// Consumes the error and creates a Failure 500 Internal server error response. fn into_failure_response(self) -> Failure { build_internal_server_error_response(self.description()) } } /* ------------------------------------------------------------- Helper methods to prepare API responses - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ pub fn build_internal_server_error_response(message: &str) -> Failure { warn!(target:"api", "{}", message); Failure::from(Status::InternalServerError) } +#[cfg(feature = "pgsql")] fn build_database_error_response(error_kind: DatabaseErrorKind, info: Box) -> Failure { match error_kind { // Case IIIa - The query tries to do an INSERT violating an unique constraint // e.g. two INSERT with the same unique value // We return a 409 Conflict DatabaseErrorKind::UniqueViolation => Failure::from(Status::Conflict), // Case IIIb - The query violated a foreign key constraint // e.g. an INSERT referring to a non existing user 1004 // when there is no id 1004 in users table // We return a 400 Bad request DatabaseErrorKind::ForeignKeyViolation => Failure::from(Status::BadRequest), // Case IIIc - For other databases errors, the client responsibility isn't involved. _ => build_internal_server_error_response(info.message()), } } diff --git a/src/config.rs b/src/config.rs index 1c4fb58..44a084c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,98 +1,187 @@ //! # Service configuration. //! //! This module allows to configure the service. //! //! It provides a Config trait to build custom configuration implementation. //! //! It also provides a `DefaultConfig` implementation of this `Config` trait to //! extract variables from an .env file or environment. use dotenv::dotenv; +#[cfg(feature = "pgsql")] +use kernel::DefaultService; +use kernel::{MinimalService, Service}; +use rocket::Route; use std::env; use std::error::Error; use ErrorResult; /* ------------------------------------------------------------- Config trait - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ /// This trait allows to provide a configuration for the resources needed by the API. pub trait Config { fn get_database_url(&self) -> &str; fn get_entry_point(&self) -> &str; fn get_database_pool_size(&self) -> u32; + fn with_database(&self) -> bool; + fn into_service(self, routes: Vec) -> Box; +} + +/* ------------------------------------------------------------- + EnvironmentConfigurable + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ + +/// This trait allows to configure the object from the environment +pub trait EnvironmentConfigurable { + fn parse_environment() -> ErrorResult where Self: Sized; } /* ------------------------------------------------------------- DefaultConfig :: Config :: sui generis implementation - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ /// This is a default implementation of the `Config` trait, which extracts the following variables /// from an .env file or environment: /// /// - `API_ENTRY_POINT` (facultative, by default `/`): the mouting point of the API methods /// - `DATABASE_URL` (mandatory): the URL to connect to your database /// - `DATABASE_POOL_SIZE` (facultative, by default 4): the number of connections to open +#[cfg(feature = "pgsql")] pub struct DefaultConfig { database_url: String, entry_point: String, database_pool_size: u32, + with_database: bool, +} + +#[cfg(feature = "pgsql")] +impl DefaultConfig { + const DEFAULT_DATABASE_POOL_SIZE: u32 = 4; } +#[cfg(feature = "pgsql")] impl Config for DefaultConfig { - fn get_database_url(&self) -> &str { - &self.database_url - } + fn get_database_url(&self) -> &str { &self.database_url } - fn get_entry_point(&self) -> &str { - &self.entry_point - } + fn get_entry_point(&self) -> &str { &self.entry_point } - fn get_database_pool_size(&self) -> u32 { - self.database_pool_size + fn get_database_pool_size(&self) -> u32 { self.database_pool_size } + + fn with_database(&self) -> bool { self.with_database } + + fn into_service(self, routes: Vec) -> Box { + let service = DefaultService { + config: self, + routes: Box::new(routes), + }; + + Box::new(service) } } -impl DefaultConfig { - pub const DEFAULT_DATABASE_POOL_SIZE: u32 = 4; - - pub fn parse_environment() -> ErrorResult { +#[cfg(feature = "pgsql")] +impl EnvironmentConfigurable for DefaultConfig { + fn parse_environment() -> ErrorResult { if let Err(error) = dotenv() { warn!(target: "config", "Can't parse .env: {}", error.description()); }; + let with_database = env::var("LF_DISABLE_DATABASE").is_err(); + let database_url = match env::var("DATABASE_URL") { Ok(url) => url, Err(e) => { - error!(target: "config", "You need to specify a DATABASE_URL variable in the environment (or .env file)."); - return Err(Box::new(e)); + if with_database { + error!(target: "config", "You need to specify a DATABASE_URL variable in the environment (or .env file)."); + return Err(Box::new(e)); + } + + String::new() } }; let entry_point = env::var("API_ENTRY_POINT").unwrap_or(String::from("/")); let database_pool_size = match env::var("DATABASE_POOL_SIZE") { Ok(variable) => { match variable.parse::() { Ok(size) => size, Err(_) => { warn!(target: "config", "The DATABASE_POOL_SIZE variable must be an unsigned integer."); DefaultConfig::DEFAULT_DATABASE_POOL_SIZE }, } }, Err(_) => DefaultConfig::DEFAULT_DATABASE_POOL_SIZE, }; Ok(DefaultConfig { database_url, entry_point, database_pool_size, + with_database, }) } } +/* ------------------------------------------------------------- + MinimalConfig + + :: Config + :: sui generis implementation + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ + +/// This is a minimal implementation of the `Config` trait, which extracts the following variables +/// from an .env file or environment: +/// +/// - `API_ENTRY_POINT` (facultative, by default `/`): the mouting point of the API methods +/// +/// It sets the server not to use a database. +pub struct MinimalConfig { + entry_point: String, +} + +impl Config for MinimalConfig { + fn get_database_url(&self) -> &str { + "" + } + + fn get_entry_point(&self) -> &str { + &self.entry_point + } + + fn get_database_pool_size(&self) -> u32 { + 0 + } + + fn with_database(&self) -> bool { false } + + fn into_service(self, routes: Vec) -> Box { + let service = MinimalService { + config: self, + routes: Box::new(routes), + }; + + Box::new(service) + } +} + +impl EnvironmentConfigurable for MinimalConfig { + fn parse_environment() -> ErrorResult { + if let Err(error) = dotenv() { + warn!(target: "config", "Can't parse .env: {}", error.description()); + }; + + let entry_point = env::var("API_ENTRY_POINT").unwrap_or(String::from("/")); + + Ok(MinimalConfig { + entry_point, + }) + } +} diff --git a/src/kernel.rs b/src/kernel.rs index 46382fa..2f49cef 100644 --- a/src/kernel.rs +++ b/src/kernel.rs @@ -1,135 +1,240 @@ //! # Service execution utilities. //! //! Provides methods to start the server and handle the application -use config::Config; +use config::{Config, MinimalConfig}; +#[cfg(feature = "pgsql")] use config::DefaultConfig; -use database::initialize_database_pool; -use database::test_database_connection; +#[cfg(feature = "pgsql")] +use database::{initialize_database_pool, test_database_connection}; use ErrorResult; use rocket::Route; use rocket::ignite; use std::process; +use std::marker::PhantomData; +use config::EnvironmentConfigurable; /* ------------------------------------------------------------- - Application + Service Allow to define config and routes. Launch a server. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ -pub trait Application { +pub trait Service { fn get_config(&self) -> &dyn Config; fn get_routes(&self) -> &[Route]; + fn launch_server(&mut self) -> ErrorResult<()>; + + fn check_service_configuration(&self) -> ErrorResult<()>; + + fn run (&mut self) -> ErrorResult<()> { + info!(target: "runner", "Server started."); + + { + self.check_service_configuration()? + } + + self.launch_server()?; + + Ok(()) + } +} + +/* ------------------------------------------------------------- + Default service + + Allow to define config and routes. Launch a server. + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ + +/// The default service offers a pgsql database connection with Diesel and r2d2. +#[cfg(feature = "pgsql")] +pub struct DefaultService { + pub config: DefaultConfig, + pub routes: Box>, +} + +#[cfg(feature = "pgsql")] +impl Service for DefaultService { + fn get_config(&self) -> &dyn Config { &self.config } + + fn get_routes(&self) -> &[Route] { self.routes.as_slice() } + fn launch_server(&mut self) -> ErrorResult<()> { let config = self.get_config(); let routes = self.get_routes(); - ignite() - .manage( + let mut server = ignite(); + + if config.with_database() { + server = server.manage( initialize_database_pool(config.get_database_url(), config.get_database_pool_size())? - ) + ); + } + + server .mount(config.get_entry_point(), routes.to_vec()) .launch(); Ok(()) } - fn run (&mut self) -> ErrorResult<()> { - info!(target: "runner", "Server started."); - - // Initial connection to test if the database configuration works - { - let config = self.get_config(); + fn check_service_configuration(&self) -> ErrorResult<()> { + let config = self.get_config(); + if config.with_database() { test_database_connection(config.get_database_url())?; info!(target: "runner", "Connection to database established."); } - self.launch_server()?; + Ok(()) + } +} + +/* ------------------------------------------------------------- + Minimal service + + Allow to define config and routes. Launch a server. + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ + +/// The minimal service allows to spawn a server without any extra feature. +pub struct MinimalService { + pub config: MinimalConfig, + pub routes: Box>, +} + +impl Service for MinimalService { + fn get_config(&self) -> &dyn Config { &self.config } + + fn get_routes(&self) -> &[Route] { self.routes.as_slice() } + + fn launch_server(&mut self) -> ErrorResult<()> { + let config = self.get_config(); + let routes = self.get_routes(); + + ignite() + .mount(config.get_entry_point(), routes.to_vec()) + .launch(); Ok(()) } + + fn check_service_configuration(&self) -> ErrorResult<()> { Ok(()) } } /* ------------------------------------------------------------- - Default application + Base application as concrete implementation :: Application :: sui generis implementation - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ +/// The application structure allows to encapsulate the service into a CLI application. +/// +/// The application takes care to run the service and quits with a correct exit code. +/// +/// It also takes care of initialisation logic like parse the environment to extract +/// the configuration. +pub struct Application + where U: Config +{ + service: Box, + config_type: PhantomData, +} + +impl Application + where U: Config + EnvironmentConfigurable +{ + pub fn new (config: U, routes: Vec) -> Self { + Application { + service: config.into_service(routes), + config_type: PhantomData, + } + } + + /// Starts the application + /// + /// # Exit codes + /// + /// The software will exit with the following error codes: + /// + /// - 0: Graceful exit (currently not in use, as the application never stops) + /// - 1: Error during the application run (e.g. routes conflict or Rocket fairings issues) + /// - 2: Error parsing the configuration (e.g. no database URL has been defined) + pub fn start (&mut self) { + info!(target: "runner", "Server initialized."); + + if let Err(error) = self.service.run() { + error!(target: "runner", "{}", error.description()); + process::exit(1); + } + + process::exit(0); + } + + pub fn start_application (routes: Vec) { + let config = ::parse_environment().unwrap_or_else(|_error| { + process::exit(2); + }); + + let mut app = Application::new(config, routes); + app.start(); + } +} + +/* ------------------------------------------------------------- + Default application + + :: Application + :: sui generis implementation, wrapper for Application + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ + /// The default application implements CLI program behavior to prepare a configuration from the /// `DefaultConfig` implementation, test if it's possible to connect to the database, and if so, /// launch a Rocket server. /// /// # Examples /// /// To run an application with some routes in a `requests` module: /// /// ``` /// use limiting_factor::kernel::DefaultApplication; /// use requests::*; /// /// pub fn main () { /// let routes = routes![ /// status, /// favicon, /// users::register, /// users::get_player, /// ]; /// /// DefaultApplication::start_application(routes); /// } /// ``` /// /// The default configuration will be used and the server started. -pub struct DefaultApplication { - config: DefaultConfig, - routes: Box>, -} - -impl Application for DefaultApplication { - fn get_config(&self) -> &dyn Config { - &self.config - } - - fn get_routes(&self) -> &[Route] { - self.routes.as_slice() - } -} +#[cfg(feature = "pgsql")] +pub struct DefaultApplication {} +#[cfg(feature = "pgsql")] impl DefaultApplication { - pub fn new (config: DefaultConfig, routes: Vec) -> Self { - DefaultApplication { - config, - routes: Box::new(routes), - } - } - - /// Starts the application, prepares default configuration - /// - /// # Exit codes - /// - /// The software will exit with the following error codes: - /// - /// - 0: Graceful exit (currently not in use, as the application never stops) - /// - 1: Error during the application run (e.g. routes conflict or Rocket fairings issues) - /// - 2: Error parsing the configuration (e.g. no database URL has been defined) pub fn start_application (routes: Vec) { - info!(target: "runner", "Server initialized."); + Application::::start_application(routes); + } +} - let config = DefaultConfig::parse_environment().unwrap_or_else(|_error| { - process::exit(2); - }); +/* ------------------------------------------------------------- + Minimal application - let mut app = Self::new(config, routes); + :: Application + :: sui generis implementation, wrapper for Application + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ - if let Err(error) = app.run() { - error!(target: "runner", "{}", error.description()); - process::exit(1); - } +pub struct MinimalApplication {} - process::exit(0); +impl MinimalApplication { + pub fn start_application (routes: Vec) { + Application::::start_application(routes); } } diff --git a/src/lib.rs b/src/lib.rs index 12c774c..9a5d291 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,52 +1,64 @@ //! A library with components to implement a REST API. //! //! The goal of this crate is to provide: //! //! - boilerplate to parse environment and run a Rocket server //! - glue code for Rocket and Diesel to use a database in the web service //! - standard API replies //! //! That allows an API or a back-end web server to focus on requests and data model. //! //! # Examples //! //! A simple server serving a 200 ALIVE response on /status : //! //! ```no_run //! use limiting_factor::kernel::DefaultApplication; //! //! pub fn run () { //! let routes = routes![ //! status, //! ]; //! //! DefaultApplication::start_application(routes); //! } //! //! #[get("/status")] //! pub fn status() -> &'static str { //! "ALIVE" //! } //! ``` +//! +//! Replacing `DefaultApplication` by `MinimalApplication` allows to use a lighter version +//! of the library without Diesel dependencies or database use. +#[cfg(feature = "pgsql")] extern crate diesel; extern crate dotenv; -#[macro_use] extern crate log; +#[macro_use] +extern crate log; +#[cfg(feature = "pgsql")] extern crate r2d2; extern crate rocket; extern crate rocket_contrib; /* ------------------------------------------------------------- Public modules offered by this crate - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ pub mod api; pub mod config; -pub mod database; pub mod kernel; +/* ------------------------------------------------------------- + Optional public features modules offered by this crate + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ + +#[cfg(feature = "pgsql")] +pub mod database; + /* ------------------------------------------------------------- Custom types - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ pub type ErrorResult = Result>;