diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..8597674 --- /dev/null +++ b/LICENSE @@ -0,0 +1,25 @@ +Copyright 2018 Sébastien Santoro aka Dereckson + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + 1. Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + + 2. Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS +IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED +TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A +PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md index a49374e..ed4f5a6 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,23 @@ The **limiting-factor** crate offers facilities to implement a REST API. +## Goal + The goal of this library is to provide: - glue code for Rocket and Diesel - standard API replies - - boilerplate to parse environment and run a server + - boilerplate to parse environment to extract configuration and run a server + +That allows an API or a back-end web server to focus on requests and data model, +and to maintain helper methods as a separate library. + +## Dependencies + +* Diesel, as PostgreSQL ORM, with r2d2 support to pool connections +* Rocket, as web framework +* Chrono, for date and time types + +## Credits -That allows an API or a back-end web server to focus on requests and data model, and to maintain helper methods as a separate library. +The glue code to use Rocket with Diesel is adapted from the Rocket guide. +See https://rocket.rs/guide/state/#databases. Guide author: Sergio Benitez. diff --git a/src/api/mod.rs b/src/api/mod.rs index 0099b66..b350bcf 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -1,9 +1,9 @@ -//! # API module +//! # Utilities for API. //! //! This module provides useful code to create easily APIs. /* ------------------------------------------------------------- Public submodules offered by this module - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ pub mod replies; diff --git a/src/api/replies.rs b/src/api/replies.rs index 5266016..9f15880 100644 --- a/src/api/replies.rs +++ b/src/api/replies.rs @@ -1,128 +1,128 @@ -//! # API module +//! # 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; use diesel::result::Error as ResultError; use diesel::result::QueryResult; use rocket::http::Status; use rocket::response::Failure; use rocket_contrib::Json; /* ------------------------------------------------------------- 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; } 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; } 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) } 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 9213717..1c4fb58 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,96 +1,98 @@ -//! This module allows to configure the API. +//! # 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; 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; } /* ------------------------------------------------------------- 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 pub struct DefaultConfig { database_url: String, entry_point: String, database_pool_size: u32, } impl Config for DefaultConfig { fn get_database_url(&self) -> &str { &self.database_url } fn get_entry_point(&self) -> &str { &self.entry_point } fn get_database_pool_size(&self) -> u32 { self.database_pool_size } } impl DefaultConfig { pub const DEFAULT_DATABASE_POOL_SIZE: u32 = 4; pub fn parse_environment() -> ErrorResult { if let Err(error) = dotenv() { warn!(target: "config", "Can't parse .env: {}", error.description()); }; 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)); } }; 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, }) } } diff --git a/src/kernel.rs b/src/kernel.rs index a4c11ff..46382fa 100644 --- a/src/kernel.rs +++ b/src/kernel.rs @@ -1,134 +1,135 @@ -//! # API module +//! # Service execution utilities. //! //! Provides methods to start the server and handle the application use config::Config; use config::DefaultConfig; use database::initialize_database_pool; use database::test_database_connection; use ErrorResult; use rocket::Route; use rocket::ignite; use std::process; /* ------------------------------------------------------------- Application Allow to define config and routes. Launch a server. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ pub trait Application { fn get_config(&self) -> &dyn Config; fn get_routes(&self) -> &[Route]; fn launch_server(&mut self) -> ErrorResult<()> { let config = self.get_config(); let routes = self.get_routes(); ignite() .manage( initialize_database_pool(config.get_database_url(), config.get_database_pool_size())? ) .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(); test_database_connection(config.get_database_url())?; info!(target: "runner", "Connection to database established."); } self.launch_server()?; Ok(()) } } /* ------------------------------------------------------------- Default application :: Application :: sui generis implementation - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ /// 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() } } 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: Exits gracefully (but currently we don't have a signal to ask the server to shutdown) - /// 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) + /// + /// - 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."); let config = DefaultConfig::parse_environment().unwrap_or_else(|_error| { process::exit(2); }); let mut app = Self::new(config, routes); if let Err(error) = app.run() { error!(target: "runner", "{}", error.description()); process::exit(1); } process::exit(0); } } diff --git a/src/lib.rs b/src/lib.rs index b74216a..12c774c 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,21 +1,52 @@ +//! 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" +//! } +//! ``` + extern crate diesel; extern crate dotenv; #[macro_use] extern crate log; 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; /* ------------------------------------------------------------- Custom types - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - */ pub type ErrorResult = Result>;