A web framework built upon Actix Web 3.x using the Rust language.
To view the frontend companion, check out rust-actix-framework-front.
Actix Web is a fast, powerful web framework for building web applications in Rust. This project aims to create ergonomic abstractions comparable to frameworks in other languages while attempting to maintain the performance benefits of Rust and Actix.
- Actix 3.x HTTP Server
- Multi-Database Support (CockroachDB, Postgres, MySQL, Sqlite)
- JWT Support
- Async Caching Layer with a Simple API
- Public and Secure Static File Service
- Diesel Database Operations are Non-Blocking
- Filesystem Organized for Scale
- .env for Local Development
- Integrated Application State with a Simple API
- Lazy Static Config struct
- Built-in Healthcheck (includes cargo version info)
- Listeners configured for TDD
- Custom Errors and HTTP Payload/Json Validation
- Secure Argon2i Password Hashing
- CORS Support
- Paginated Results
- Unit and Integration Tests
- Test Coverage Reports
- Dockerfile for Running the Server in a Container
- TravisCI Integration
Argon2i: Argon2i Password Hasningactix-cors: CORS Supportactix-identity: User Authenticationactix-redisandredis-async: Async Caching Layeractix-web: Actix Web Serverderive_more: Error Formattingdiesel: ORM that Operates on Several Databasesdotenv: Configuration Loader (.env)envy: Deserializes Environment Variables into a Config Structjsonwebtoken: JWT encoding/decodingkcov: Coverage Analysislistenfd: Listens for Filesystem Changesrayon: Parallelizer2d2: Database Connection Poolingvalidator: Validates incoming Json
- Quick Installation
- Installation
- Running the Server
- Autoreloading
- Tests
- Docker
- Generating documentation
- The #[timestamps] proc macro
- The paginate! declaritive macro
- Public Static Files
- Secure Static Files
- Application State
- Application Cache
- Non-Blocking Diesel Database Operations
- Endpoints
- License
You can skip the first portion and jump ahead to the Diesel CLI section of this setup by copying the skeleton code in the /examples folder.
First, create a new project:
cargo new rest_server --binNext, cd into the rest_server folder and add the following to Cargo.toml:
[package]
name = "rest_server"
version = "0.1.0"
authors = ["YOUR NAME <yourname@yourdomain.com>"]
edition = "2018"
[dependencies]
actix_framework = "0.2.0"
actix-cors = "0.2.0"
actix-rt = "1"
actix-web = "3"
dotenv = "0.14"
env_logger = "0.6"
listenfd = "0.3"
[features]
cockroach = []
mysql = []
postgres = []
sqlite = []
default = ["mysql"]With that setup in place, you can add in the server code in /src/main.rs:
use actix_cors::Cors;
use actix_web::{middleware::Logger, App, HttpServer};
use listenfd::ListenFd;
use actix_framework::auth::get_identity_service;
use actix_framework::cache::add_cache;
use actix_framework::config::CONFIG;
use actix_framework::database::add_pool;
use actix_framework::routes::routes;
use actix_framework::state::new_state;
#[actix_rt::main]
async fn main() -> std::io::Result<()> {
dotenv::dotenv().ok();
env_logger::init();
// Create the application state
// String is used here, but it can be anything
// Invoke in hanlders using data: AppState<'_, String>
let data = new_state::<String>();
// Initialize the file system listener
let mut listenfd = ListenFd::from_env();
let mut server = HttpServer::new(move || {
App::new()
// Add the default logger
.wrap(Logger::default())
// Accept all CORS
// For more options, see https://docs.rs/actix-cors
.wrap(Cors::new().supports_credentials().finish())
// Adds Identity Service for use in the Actix Data Extractor
// In a handler, add "id: Identity" param for auto extraction
.wrap(get_identity_service())
// Adds Application State for use in the Actix Data Extractor
// In a handler, add "data: AppState<'_, String>" param for auto extraction
.app_data(data.clone())
// Adds the Redis Cache for use in the Actix Data Extractor
// In a handler, add "cache: Cache" param for auto extraction
.configure(add_cache)
// Adds a Database Pool for use in the Actix Data Extractor
// In a handler, add "pool: Data<PoolType>" param for auto extraction
.configure(add_pool)
// Pull in default framework defaults
// This can be removed if they're not needed
.configure(routes)
});
server = if let Some(l) = listenfd.take_tcp_listener(0)? {
server.listen(l)?
} else {
server.bind(&CONFIG.server)?
};
server.run().await
}Create an .env file at the root of your project:
touch .envNow add environment values for local development:
AUTH_SALT=CHANGEME
DATABASE=mysql
DATABASE_URL=mysql://root:root@0.0.0.0:13306/rust-actix-framework
JWT_EXPIRATION=24
JWT_KEY=4125442A472D4B614E645267556B58703273357638792F423F4528482B4D6251
REDIS_URL=127.0.0.1:6379
RUST_BACKTRACE=0
RUST_LOG="actix_framework=info,actix_web=info,actix_server=info,actix_redis=trace"
SERVER=127.0.0.1:3000
SESSION_KEY=4125442A472D4B614E645267556B58703273357638792F423F4528482B4D6251
SESSION_NAME=auth
SESSION_SECURE=false
SESSION_TIMEOUT=20IMPORTANT: Change .env values for your setup, paying special attention to the salt and various keys.
After you set the DATABASE value in .env, you'll need it to match the default value in the features section in Cargo.toml with the DATABASE value in .env:
[features]
cockroach = []
mysql = []
postgres = []
sqlite = []
default = ["mysql"]note: Only supply a SINGLE database in the default array.
Next, you'll need to install the Diesel CLI:
cargo install diesel_cliIf you run into errors, see http://diesel.rs/guides/getting-started/
After you've created a blank database, run the migrations via the Diesel CLI:
diesel migration runTo startup the server:
cargo runTo startup the server and autoreload on code changes:
systemfd --no-pid -s http::3000 -- cargo watch -x runIntegration tests are in the /src/tests folder. There are helper functions
to make testing the API straightforward. For example, if we want to test the
GET /api/v1/user route:
use crate::tests::helpers::tests::assert_get;
#[test]
async fn test_get_users() {
assert_get("/api/v1/user").await;
}Using the Actix test server, the request is sent and the response is asserted for a successful response:
assert!(response.status().is_success());
Similarly, to test a POST route:
use crate::handlers::user::CreateUserRequest;
use crate::tests::helpers::tests::assert_post;
#[test]
async fn test_create_user() {
let params = CreateUserRequest {
first_name: "Satoshi".into(),
last_name: "Nakamoto".into(),
email: "satoshi@nakamotoinstitute.org".into(),
};
assert_post("/api/v1/user", params).await;
}To run all of the tests:
cargo testI created a repo on DockerHub that I'll update with each Rust version (starting at 1.37), whose tags will match the Rust version.
In the root of the project:
docker run -it --rm --security-opt seccomp=unconfined --volume "${PWD}":/volume --workdir /volume ddimaria/rust-kcov:1.37 --exclude-pattern=/.cargo,/usr/lib,/src/main.rs,src/server.rsnote: coverage takes a long time to run (up to 30 mins).
You can view the HTML output of the report at target/cov/index.html
To build a Docker image of the application:
docker build -t actix_framework .Once the image is built, you can run the container in port 3000:
docker run -it --rm --env-file=.env.docker -p 3000:3000 --name actix_framework actix_frameworkTo run dependencies for this application, simply invoke docker-compose:
docker-compose upCurrently, only MySQL is in there, but more to come
cargo doc --no-deps --openThe #[timestamps] macro will automatically append the following fields to a model struct:
pub created_by: String,
pub created_at: NaiveDateTime,
pub updated_by: String,
pub updated_at: NaiveDateTime,Example:
use chrono::NaiveDateTime;
use proc_macro::timestamps;
#[timestamps]
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Queryable, Identifiable, Insertable)]
pub struct User {
pub id: String,
pub first_name: String,
pub last_name: String,
pub email: String,
pub password: String,
}This will expand to:
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Queryable, Identifiable, Insertable)]
pub struct User {
pub id: String,
pub first_name: String,
pub last_name: String,
pub email: String,
pub password: String,
pub created_by: String,
pub created_at: NaiveDateTime,
pub updated_by: String,
pub updated_at: NaiveDateTime,
}The paginate! macro removes boilerplate for paginating a model.
macro_rules! paginate {
($pool:expr, $model:ident, $model_type:ident, $params:ident, $response_type:ident, $base:ident) => {{
let conn = $pool.get()?;
let total = $model.select(count_star()).first(&conn)?;
let pagination = get_pagination($params.page, $params.per_page, total);
let paginated: $response_type = $model
.limit(pagination.per_page)
.offset(pagination.offset)
.load::<$model_type>(&conn)?
.into();
Ok(paginate::<$response_type>(pagination, paginated, $base)?)
}};
}Below is an example of using the macro in the user model:
pub fn get_all(
pool: &PoolType,
params: PaginationRequest,
base: String,
) -> Result<PaginationResponse<UsersResponse>, ApiError> {
use crate::schema::users::dsl::users;
crate::paginate!(pool, users, User, params, UsersResponse, base)
}Static files are served up from the /static folder.
Directory listing is turned off.
Index files are supported (index.html).
Example:
curl -X GET http://127.0.0.1:3000/test.htmlTo serve static files to authenticated users only, place them in the /static-secure folder.
These files are referenced using the root-level /secure path.
Example:
curl -X GET http://127.0.0.1:3000/secure/test.htmlA shared, mutable hashmap is automatically added to the server. To invoke this data in a handler, simply add data: AppState<'_, String> to the handler's signature.
Retrieves a copy of the entry in application state by key.
Example:
use crate::state::get;
pub async fn handle(data: AppState<'_, String>) -> impl Responder {
let key = "SOME_KEY";
let value = get(data, key);
assert_eq!(value, Some("123".to_string()));
}Inserts or updates an entry in application state.
Example:
use crate::state::set;
pub async fn handle(data: AppState<'_, String>) -> impl Responder {
let key = "SOME_KEY";
let value = set(data, key, "123".into());
assert_eq!(value, None)); // if this is an insert
assert_eq!(value, Some("123".to_string())); // if this is an update
}Deletes an entry in application state by key.
Example:
use crate::state::get;
pub async fn handle(data: AppState<'_, String>) -> impl Responder {
let key = "SOME_KEY";
let value = delete(data, key);
assert_eq!(value, None);
}Asynchronous access to redis is automatically added to the server if a value is provided for the REDIS_URL environment variable.
To invoke this data in a handler, simply add cache: Cache to the handler's signature.
Retrieves a copy of the entry in the application cache by key.
Example:
use crate::cache::{get, Cache};
pub async fn handle(cache: Cache) -> impl Responder {
let key = "SOME_KEY";
let value = get(cache, key).await?;
assert_eq!(value, "123");
}Inserts or updates an entry in the application cache.
Example:
use crate::cache::{set, Cache};
pub async fn handle(cache: Cache) -> impl Responder {
let key = "SOME_KEY";
set(cache, key, "123").await?;
}Deletes an entry in the application cache by key.
Example:
use crate::cache::{delete, Cache};
pub async fn handle(cache: Cache) -> impl Responder {
let key = "SOME_KEY";
delete(cache, key).await?;
}When accessing a database via Diesel, operations block the main server thread. This blocking can be mitigated by running the blocking code in a thread pool from within the handler.
Example:
pub async fn get_user(
user_id: Path<Uuid>,
pool: Data<PoolType>,
) -> Result<Json<UserResponse>, ApiError> {
let user = block(move || find(&pool, *user_id)).await?;
respond_json(user)
}Blocking errors are automatically converted into ApiErrors to keep the api simple:
impl From<BlockingError<ApiError>> for ApiError {
fn from(error: BlockingError<ApiError>) -> ApiError {
match error {
BlockingError::Error(api_error) => api_error,
BlockingError::Canceled => ApiError::BlockingError("Thread blocking error".into()),
}
}
}Determine if the system is healthy.
GET /health
{
"status": "ok",
"version": "0.1.0"
}Example:
curl -X GET http://127.0.0.1:3000/healthPOST /api/v1/auth/login
| Param | Type | Description | Required | Validations |
|---|---|---|---|---|
| String | The user's email address | yes | valid email address | |
| password | String | The user's password | yes | at least 6 characters |
{
"email": "torvalds@transmeta.com",
"password": "123456"
}Header
HTTP/1.1 200 OK
content-length: 118
content-type: application/json
set-cookie: auth=COOKIE_VALUE_HERE; HttpOnly; Path=/; Max-Age=1200
date: Tue, 15 Oct 2019 02:04:54 GMTJson Body
{
"id": "0c419802-d1ef-47d6-b8fa-c886a23d61a7",
"first_name": "Linus",
"last_name": "Torvalds",
"email": "torvalds@transmeta.com"
}When sending subsequent requests, create a header variable cookie with the value auth=COOKIE_VALUE_HERE
GET /api/v1/auth/logout
200 OK
Example:
curl -X GET http://127.0.0.1:3000/api/v1/auth/logoutRetrieve a paginated listing of all users in the system.
GET /api/v1/user
| Param | Type | Description |
|---|---|---|
| page | i64 | The page to start on. Defaults to 1. |
| per_page | i64 | The number of results per page. Defaults to 10. |
{
"links": {
"base": "http://127.0.0.1:3000/api/v1/user",
"first": "http://127.0.0.1:3000/api/v1/user?page=1&per_page=10",
"last": "http://127.0.0.1:3000/api/v1/user?page=13&per_page=10",
"prev": null,
"next": "http://127.0.0.1:3000/api/v1/user?page=2&per_page=10"
},
"pagination": {
"offset": 0,
"page": 1,
"per_page": 10,
"total": 129,
"total_pages": 13
},
"data": [
{
"id": "00000000-0000-0000-0000-000000000000",
"first_name": "admin",
"last_name": "user",
"email": "admin@admin.com"
},
{
"id": "035efb82-cfdf-42de-adef-c75d7ac6d3ff",
"first_name": "ModelUpdateaaa",
"last_name": "TestUpdatezzz",
"email": "model-update-test@nothing.org"
}
]
}Example:
curl -X GET http://127.0.0.1:3000/api/v1/userGET /api/v1/user/{id}
| Param | Type | Description |
|---|---|---|
| id | Uuid | The user's id |
{
"id": "a421a56e-8652-4da6-90ee-59dfebb9d1b4",
"first_name": "Satoshi",
"last_name": "Nakamoto",
"email": "satoshi@nakamotoinstitute.org"
}Example:
curl -X GET http://127.0.0.1:3000/api/v1/user/a421a56e-8652-4da6-90ee-59dfebb9d1b4404 Not Found
{
"errors": ["User c63d285b-7794-4419-bfb7-86d7bb3ff17a not found"]
}POST /api/v1/user
| Param | Type | Description | Required | Validations |
|---|---|---|---|---|
| first_name | String | The user's first name | yes | at least 3 characters |
| last_name | String | The user's last name | yes | at least 3 characters |
| String | The user's email address | yes | valid email address |
{
"first_name": "Linus",
"last_name": "Torvalds",
"email": "torvalds@transmeta.com"
}{
"id": "0c419802-d1ef-47d6-b8fa-c886a23d61a7",
"first_name": "Linus",
"last_name": "Torvalds",
"email": "torvalds@transmeta.com"
}Example:
curl -X POST \
http://127.0.0.1:3000/api/v1/user \
-H 'Content-Type: application/json' \
-d '{
"first_name": "Linus",
"last_name": "Torvalds",
"email": "torvalds@transmeta.com"
}'422 Unprocessable Entity
{
"errors": [
"first_name is required and must be at least 3 characters",
"last_name is required and must be at least 3 characters",
"email must be a valid email"
]
}PUT /api/v1/{id}
Path
| Param | Type | Description |
|---|---|---|
| id | Uuid | The user's id |
Body
| Param | Type | Description | Required | Validations |
|---|---|---|---|---|
| first_name | String | The user's first name | yes | at least 3 characters |
| last_name | String | The user's last name | yes | at least 3 characters |
| String | The user's email address | yes | valid email address |
{
"first_name": "Linus",
"last_name": "Torvalds",
"email": "torvalds@transmeta.com"
}{
"id": "0c419802-d1ef-47d6-b8fa-c886a23d61a7",
"first_name": "Linus",
"last_name": "Torvalds",
"email": "torvalds@transmeta.com"
}Example:
curl -X PUT \
http://127.0.0.1:3000/api/v1/user/0c419802-d1ef-47d6-b8fa-c886a23d61a7 \
-H 'Content-Type: application/json' \
-d '{
"first_name": "Linus",
"last_name": "Torvalds",
"email": "torvalds@transmeta.com"
}'422 Unprocessable Entity
{
"errors": [
"first_name is required and must be at least 3 characters",
"last_name is required and must be at least 3 characters",
"email must be a valid email"
]
}404 Not Found
{
"errors": ["User 0c419802-d1ef-47d6-b8fa-c886a23d61a7 not found"]
}DELETE /api/v1/user/{id}
| Param | Type | Description |
|---|---|---|
| id | Uuid | The user's id |
{
"id": "a421a56e-8652-4da6-90ee-59dfebb9d1b4",
"first_name": "Satoshi",
"last_name": "Nakamoto",
"email": "satoshi@nakamotoinstitute.org"
}200 OK
Example:
curl -X DELETE http://127.0.0.1:3000/api/v1/user/a421a56e-8652-4da6-90ee-59dfebb9d1b4404 Not Found
{
"errors": ["User c63d285b-7794-4419-bfb7-86d7bb3ff17a not found"]
}This project is licensed under:
- MIT license (LICENSE-MIT or http://opensource.org/licenses/MIT)