In this article we're going to talk about how you can get started with Loco - a new Rust web framework that builds on Axum and takes inspiration from Ruby on Rails. We will cover getting started using controllers, migrations, middleware and static files. Following on from our previous article, we're going to get more indepth and experiment with creating a CRUD controller as well as middleware.
Getting Started
To get started, you will need to make sure to have Loco's CLI installed by using the following:
cargo install loco-cli
Loco also uses sea-orm-cli
to carry out database migrations. You can install it using the following:
cargo install sea-orm-cli
Now we've installed all of the required packages, we can get initialise our project. We can get started by using loco new
and then selecting the SaaS application which will give us an app with full functionality. The given name we will be using for the app will be example_app
. Don't forget to cd
into the project folder!
When you're writing your API, you will probably want to spin up a local Docker database for database testing. In that case, you will wnat to use this docker command:
$ docker run -d -p 5432:5432 -e POSTGRES_USER=loco -e POSTGRES_DB=example_app_development -e POSTGRES_PASSWORD="loco" postgres:15.3-alpine
Routing in Loco
The first step that we need to take will be generating a "scaffold". This generates a controller, model and migration all at the same time. We can also add pre-generated field names and types beforehand, which you can find more about here. We can create our own scaffold below:
cargo loco generate scaffold item name:string! description:string quantity:int!
This will generate a controller, model and migration for a table named items
with:
- A non-nullable name field
- A nullable description field
- A non-nullable quantity field
The entities will also be generated so that you should not need to generate them yourself.
Once the scaffold is done, you may notice that your Loco controller and other relevant parts have been added in app.rs
, so there is no need to add it manually.
Now we can go to our new controller file which should be located under src/controllers/item.rs
. When opened, we should be greeted with something that looks like this:
#![allow(clippy::unused_async)]
use loco_rs::prelude::*;
pub async fn echo(req_body: String) -> String {
req_body
}
pub async fn hello(State(_ctx): State<AppContext>) -> Result<String> {
format::string("Hello world!")
}
pub fn routes() -> Routes {
Routes::new()
.prefix("item")
.add("/", get(hello))
.add("/echo", post(echo))
Now we can get to work on the routes for this!
If you check the source code for what AppContext
contains here, you should get this:
#[derive(Clone)]
#[allow(clippy::module_name_repetitions)]
pub struct AppContext {
/// The environment in which the application is running.
pub environment: Environment,
#[cfg(feature = "with-db")]
/// A database connection used by the application.
pub db: DatabaseConnection,
/// An optional connection pool for Redis, for worker tasks
pub redis: Option<Pool<RedisConnectionManager>>,
/// Configuration settings for the application
pub config: Config,
/// An optional email sender component that can be used to send email.
pub mailer: Option<EmailSender>,
}
This means we only need to use ctx.db
to access the database connection. Let's have a look at what a simple request for getting all of the item
records from the database would look like:
#![allow(clippy::unused_async)]
use loco_rs::prelude::*;
use crate::models::_entities::items::Entity as Item;
use crate::models::_entities::items::Model as ItemModel;
pub async fn hello(State(ctx): State<AppContext>) -> Result<Json<Vec<ItemModel>>> {
let items = Item::find().all(&ctx.db).await?;
format::json(items)
}
Note that we need to import our models and entities from the entities
folder.
We can extend this to create a full CRUD controller:
use crate::models::_entities::items::ActiveModel;
pub async fn view_item_by_id(Path<id>: Path<i32>, State(ctx): State<AppContext>) -> Result<Json<ItemModel>> {
let item: Option<item::Model> = Item::find_by_id(id).one(db).await?;
let item: item::Model = item.unwrap();
format::json(item)
}
pub async fn create_item(
State(ctx): State<AppContext>,
Json(item): Json<ItemModel>
) -> Result<String> {
let item: ActiveModel = item.into();
item.insert(&ctx.db).await?;
format::text("Created")
}
#[derive(Deserialize)]
struct ItemQty { qty: i32 };
pub async fn update_item_quantity(
State(ctx): State<AppContext>,
Path(id): Path<i32>,
Json(json): Json<ItemQty>
) -> Result<String> {
let item: Option<item::Model> = Item::find_by_id(id).one(&ctx.db).await?;
let mut item: item::ActiveModel = item.unwrap().into();
item.quantity = Set(json.qty);
let updateditem = item.update(db).await?;
format::text("Updated")
}
pub async fn delete_item(
Path<id>: Path<i32>,
State(ctx): State<AppContext>
) -> Result<String> {
let item: Option<item::Model> = Item::find_by_id(id).one(db).await?;
let item: item::Model = item.unwrap();
let res: DeleteResult = item.delete(db).await?;
format::text("Deleted")
}
Once you're done writing all of your routes, you need to make sure you attach them to your router in the routes()
function for the controller file:
pub fn routes() -> Routes {
Routes::new()
.prefix("items")
.add("/", get(get_all_items).post(create_item))
.add("/:id", get(get_item_by_id).put(update_item_qty).delete(delete_item))
}
Congrats! You just created your first full CRUD router.
To build onto this, let's add some validation for when you need to save an item. Loco itself re-exports the validator
crate which allows you to validate that a struct meets certain requirements. Loco ties this in with sea_orm
to be able to validate a struct before saving it to the database. A validator struct might look like this:
#[derive(Debug, Validate, Deserialize)]
pub struct ModelValidator {
#[validate(range(min = 0, message = "Item must have at least a quantity of 0."))]
pub quantity: i32,
}
Now that this is done, we just need to implement From<Model>
for the validator struct, and implement the ActiveModelBehavior
trait for the ActiveModel. Note that because we have to always convert a Model
to an ActiveModel
before saving it, the before_save
function will always kick in.
impl From<&ActiveModel> for ModelValidator {
fn from(value: &ActiveModel) -> Self {
Self {
quantity: *value.quantity.as_ref(),
}
}
}
#[async_trait::async_trait]
impl ActiveModelBehavior for super::_entities::items::ActiveModel {
async fn before_save<C>(self, db: &C, insert: bool) -> Result<Self, DbErr>
where
C: ConnectionTrait,
{
{
self.validate()?;
Ok(self)
}
}
}
You can also write an impl
for the Model
itself to extend its behavior! Note that you will need to pass in the database connection as a function parameter. Check out this function for finding users by emails (can be found in the pre-generated users::Model
model):
// src/models/users.rs
pub async fn find_by_email(db: &DatabaseConnection, email: &str) -> ModelResult<Self>
let user = users::Entity::find()
.filter(users::Column::Email.eq(email))
.one(db)
.await?;
user.ok_or_else(|| ModelError::EntityNotFound)
}
Middleware in Loco
Middleware in Loco can be implemented in a few ways:
-
Implementing
axum::FromRequestParts
(orFromRequest
) for a given struct or enum -
Implmenting the optional
after_routes()
method in the Hooks trait (inapp.rs
)
Implementing FromRequest
is probably the easiest way to go about being able to implement middleware for select routes, while after_routes()
is likely much better for globally implementing a middleware (for example, a timeout).
Using FromRequestParts
Although FromRequestParts
(and FromRequest
respectively) look tricky to implement, you can make it substantially easier on yourself by remembering that the state itself just needs to implement Send + Sync
- which means you can use AppContext
with it! Check out the code snippet below for an overall implementation of how you would write something that implements FromRequestParts
.
#[derive(Deserialize)]
pub struct MyMiddlewareState(String);
#[async_trait::async_trait]
impl<AppContext> FromRequestParts<AppContext> for MyMiddlewareState
{
type Rejection = ApiError;
async fn from_request_parts(parts: &mut Parts, _state: &AppContext) -> Result<Self, Self::Rejection> {
let string = "Hello world!".to_string();
if string != *"Hello world!" {
return Err(ApiError::Unknown);
}
Ok(MyMiddlewareState(string))
}
}
enum ApiError { Unknown }
impl IntoResponse for ApiError {
// ... implement IntoResponse for ApiError for it to return a HTTP response
}
Of course, in a real-world application this will be much more extensive than assigning "Hello world!" to a variable and then returning the struct.
Using Global Middleware
As mentioned before, you can also use the after_routes()
function in the Hooks
trait. The function itself looks like this:
async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
Ok(router)
}
Because the Axum router gets passed in as a parameter, you can attach any kind of Axum middleware or layer you want. This also means you can add things like a tower-http
service layer if you'd like! Let's have a look at adding a timeout layer, which will stop any slow loris attacks. To do this, we'll need to add tower-http
with the timeout
feature:
cargo add tower-http -F timeout
Then we just need to add the layer:
async fn after_routes(router: AxumRouter, _ctx: &AppContext) -> Result<AxumRouter> {
let router = router.layer(TimeoutLayer::new(Duration::from_secs(10)));
Ok(router)
}
Now any requests that last longer than 10 seconds will automatically be aborted with a 408 Request Timeout
response. Pretty nifty!
We can also implement our own Tower service, which you can find more about here.
Serving a Frontend in Loco
Serving a frontend with Loco is as easy as going into the frontend
folder, then running npm i
to install the dependencies. Then you run npm run build
to build your application. When you use cargo loco start
and go to localhost:8000 you should see the main screen for the Loco.rs homepage but blank.
Note that the main frontend approach uses React. You can switch this out for any other framework you like. The only thing you need to do is to make sure the frontend you are serving matches the config file under the static
section (the folder
key, with the default being /frontend/dist
). If you're a Svelte or Vue user, or want to use Leptos or Dioxus you can freely switch around! If you are not experienced in any of the aforementioned frameworks, you can also use raw HTML/CSS/JS. This may be particularly more favourable if you either don't have a lot of HTML/CSS you need to serve.
Deploying Loco
Loco have provided their own commands for generating a deployment. You can run it by using cargo loco generate deployment
. It will then generate a Dockerfile or Shuttle deployment depending on what you select. If you're deploying with Shuttle, don't forget you can add your frontend assets by going to your Shuttle.toml file and then adding frontend/dist
to your assets key:
name = "<name-of-your-project>"
assets = ["frontend/dist/*"]
Then you can run the following to deploy your application (don't forget to install cargo-shuttle
):
cargo shuttle project start
cargo shuttle deploy
We are planning to release a native database integration with Loco! Stay tuned for part 2 where we will go into further detail about how you can build out your dream web application.
Finishing Up
Thanks for reading! We hope this Rust Loco guide has helped you. If you're looking to get started with Rust web development, now is a better time than ever to do so.
Interested in more?