I found Shuttle weeks ago while I was scrolling through Reddit, I found it really cool, and I want to try it immediately, and I thought it would be a great opportunity to use Axum, that which is a web framework I started to use recently. I don't have much experience with Rust, and it is my first time using Shuttle. Be patient with me, please.
If you don't know what Shuttle is, this is what its Github page says:
Shuttle is a serverless platform for Rust which makes it really easy to deploy your web-apps.
Shuttle is built for productivity, reliability, and performance:
- Zero-Configuration support for Rust using annotations
- Automatic resource provisioning (databases, caches, subdomains, etc.) via Infrastructure-From-Code
- First-class support for popular Rust frameworks (Rocket, Axum, Tide, and Tower)
- Scalable hosting (with optional self-hosting)
First, we need to install the cargo-shuttle
subcommand:
cargo install cargo-shuttle
After the subcommand is installed, we are ready to create our project. We will build a URL Shortener, like the example on Shuttle's web page, but their example is built with Rocket, you can see them here. Our URL Shortener will be built with Axum.
cargo shuttle init url-shrtnr
This is our project's directory structure:
url-shortener/
--src/
-- lib.rs
-- Cargo.toml
-- .gitignore
Let's add the dependencies to our project:
Cargo.toml
[package]
name = "url-shrtnr"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"]
[dependencies]
shuttle-service = { version = "0.4.2", features = ["web-axum", "sqlx-postgres"] }
axum = "0.5.15"
serde = "1.0.137"
sqlx = { version = "0.5.13", features = ["runtime-tokio-native-tls", "postgres"] }
sync_wrapper = "0.1.1"
Now, let's write a Hello World example.
Hello World example
lib.rs
use axum::{
routing::{get},
Router,
};
use shuttle_service::ShuttleAxum;
use sync_wrapper:: SyncWrapper;
#[shuttle_service::main]
async fn main() -> ShuttleAxum {
let router = Router::new()
.route("/", get(root));
let sync_wrapper = SyncWrapper::new(router);
Ok(sync_wrapper)
}
async fn root() -> &'static str {
"Hello, World!"
}
Now we run this command in our terminal:
cargo shuttle run
We should see this message in our terminal:
Starting url-shrtnr on http://127.0.0.1:8000
If we use our browser or curl and paste the URL, we will see the message "Hello, World!".
Now, to deploy the app we need to log in first, let's go to this page and get our API key.
Then in our terminal, we write this command:
cargo shuttle login --api-key <your api-key>
cargo shuttle deploy
If everything is ok, we will see this message:
Project: url-shrtnr
Deployment Id: 9ebd7ed5-0fa2-4d43-a1ef-3168c1ad22e6
Deployment Status: DEPLOYED
Host: https://url-shrtnr.shuttleapp.rs
Created At: 2022-06-29 15:01:37.557012238 UTC
We will see the message "Hello, World!" in our browser if we go to the URL "https://url-shrtnr.shuttleapp.rs".
If we have an error message saying that there's another app with the same name, we have to create Shuttle.toml in the same location Cargo.toml is. Inside Shuttle.toml we write the name we want for our app.
Shuttle.toml
name = "<your app's name>"
According to the documentation:
If the name key is not specified, the services name will be the same as the crates name.
Alternatively, you can override the project name on the command-line, by passing the name argument:
cargo shuttle deploy --name=$PROJECT_NAME
Now, let's write our URL shortener. We will use Sqlx for our database and sqlx-cli to generate our migrations folder. If you haven't installed sqlx-cli, you can install it with the next command:
cargo install sqlx-cli
sqlx database create --database-url postgres://<your user>:<your password>@localhost:<port>/<your database name>
sqlx migrate add <database>
We should see this message in our terminal:
Creating migrations\20220629154910_url.sql
Congratulations on creating your first migration!
Did you know you can embed your migrations in your application binary?
On startup, after creating your database connection or pool, add:
sqlx::migrate!().run(<&your_pool OR &mut your_connection>).await?;
Note that the compiler won't pick up new migrations if no Rust source files have changed.
You can create a Cargo build script to work around this with `sqlx migrate build-script`.
See: https://docs.rs/sqlx/0.5/sqlx/macro.migrate.html
After that we go to migrations/<timestap>-url.sql
and there we add our table.
CREATE TABLE url (
id VARCHAR(6) PRIMARY KEY,
url VARCHAR NOT NULL
);
Then we need to run migrations.
sqlx migrate run --database-url postgres://<your user>:<your password>@localhost:<port>/<database>
Let's add other dependencies to our Cargo.toml:
Cargo.toml
[dependencies]
...
nanoid = "0.4"
url ="2.2"
lib.rs
use axum::{
routing::{get, post},
Router,
response::Redirect,
http::StatusCode,
extract::Extension,
};
use shuttle_service::{error::CustomError, ShuttleAxum};
use sync_wrapper:: SyncWrapper;
use serde::Serialize;
use sqlx::migrate::Migrator;
use sqlx::{FromRow, PgPool};
use url::Url;
#[derive(Serialize, FromRow)]
struct StoredURL {
pub id: String,
pub url: String,
}
async fn shorten(url:String, Extension(pool): Extension<PgPool>) -> Result<String, StatusCode> {
let id = &nanoid::nanoid!(6);
let parserd_url = Url::parse(&url).map_err(|_err| {
StatusCode::UNPROCESSABLE_ENTITY
})?;
sqlx::query("INSERT INTO url(id, url) VALUES ($1, $2)")
.bind(id)
.bind(parserd_url.as_str())
.execute(&pool)
.await
.map_err(|_| {
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(format!("https://url-shrtnr.shuttleapp.rs/{id}"))
}
We pass a URL to the shorten
function, then, we define a parsed_url
to check if the string passed is a URL and return an error message if is not.
Then, we pass the SQL query to the query
function and bind the values id
generated by nanoid
and the url
. If an error is not raised, it will return and url as a string and the id
as the shortened url.
async fn redirect(id: String, Extension(pool): Extension<PgPool>) -> Result<Redirect, StatusCode> {
let stored_url: StoredURL = sqlx::query_as("SELECT * FROM url WHERE id = $1")
.bind(id)
.fetch_one(&pool)
.await
.map_err(|err| match err {
sqlx::Error::RowNotFound => StatusCode::NOT_FOUND,
_=> StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Redirect::to(&stored_url.url))
}
The redirect function retrieves a URL from the database when its id
is passed. If the id
doesn't exist in the database, it will return a Not Found
error.
static MIGRATOR: Migrator = sqlx::migrate!();
#[shuttle_service::main]
async fn axum(pgpool: PgPool) -> ShuttleAxum {
MIGRATOR.run(&pgpool).await.map_err(CustomError::new)?;
let router = Router::new()
.route("/:id", get(redirect))
.route("/:url", post(shorten))
.layer(Extension(pgpool));
let sync_wrapper = SyncWrapper::new(router);
Ok(sync_wrapper)
}
If everything is ok, our URL-Shortener will be deployed and we will see the next message:
Compiling url-shrtnr v0.1.0 (/opt/shuttle/crates/url-shrtnr)
Finished dev [unoptimized + debuginfo] target(s) in 49.18s
Project: url-shrtnr
Deployment Id: 771d387b-fb1c-48ce-97a5-ff3452036265
Deployment Status: DEPLOYED
Host: https://url-shrtnr.shuttleapp.rs
Created At: 2022-06-29 16:37:02.890391021 UTC
Database URI: postgres:// ***:*** @pg.shuttle.rs:5432/db-url-shrtnr
Conclusion
It was really fun for me to build and deploy this app, it is the first time I deployed anything, and it was easy with Shuttle, they wrote very good documentation with examples for Rocket, Axum, Tide, and Postgres, and the examples were very helpful.
If there is anything I didn't explain or I should improve or know about Shuttle, Axum, Sqlx, or Rust, please leave a comment.
Here is the complete source code.
Thank you for taking the time to read this article.
If you have any recommendations about other packages, architectures, how to improve my code, my English, or anything; please leave a comment or contact me through Twitter, LinkedIn.
Top comments (0)