1. Examples
  2. Rocket

Hello world!

Simple ‘Hello world’ app using Rocket.

Create a new directory (mkdir) and move into it (cd) — afterwards, execute the following command to initialize shuttle inside with the Rocket boilerplate.

cargo shuttle init --rocket

Make sure that your Cargo.toml file looks like the one below — having the right dependencies is key!

Cargo.toml
[package]
name = "hello-world"
version = "0.1.0"
edition = "2021"

[lib]

[dependencies]
rocket = "0.5.0-rc.2"
shuttle-service = { version = "0.9.0", features = ["web-rocket"] }

Your lib.rs should look like this:

lib.rs
#[macro_use]
extern crate rocket;

#[get("/")]
fn index() -> &'static str {
    "Hello, world!"
}

#[shuttle_service::main]
async fn rocket() -> shuttle_service::ShuttleRocket {
    let rocket = rocket::build().mount("/hello", routes![index]);

    Ok(rocket)
}

Finally, to deploy your app, all you need to do is:

cargo shuttle deploy

And your app is live! 🎉🎉🎉

JWT Authentication

This example shows how to use Rocket request guards for authentication with JSON Web Tokens (JWT for short). The idea is that all requests authenticate first at https://authentication-rocket-app.shuttleapp.rs/login to get a JWT. Then the JWT is sent with all requests requiring authentication using the HTTP header Authorization: Bearer <token>.

This example uses the jsonwebtoken which supports symmetric and asymmetric secret encoding, built-in validations, and most JWT algorithms. However, this example only makes use of symmetric encoding and validation on the expiration claim.

src/main.rs

Three Rocket routes are registered in this file:

  1. /public: a route that can be called without needing any authentication.
  2. /login: a route for posting a JSON object with a username and password to get a JWT.
  3. /private: a route that can only be accessed with a valid JWT.

src/claims.rs

The bulk of this example is in this file. Most of the code can be transferred to other frameworks except for the FromRequest implementation, which is Rocket specific. This file contains a Claims object which can be expanded with more claims. A Claims can be created from a Bearer <token> string using Claims::from_authorization(). And a Claims object can also be converted to a token using to_token().

Cargo.toml
[package]
name = "authentication"
version = "0.1.0"
edition = "2021"

[lib]

[dependencies]
chrono = "0.4"
jsonwebtoken = { version = "8", default-features = false }
lazy_static = "1.4"
rocket = { version = "0.5.0-rc.2", features = ["json"] }
serde = { version = "1.0", features = ["derive"] }
shuttle-service = { version = "0.9.0", features = ["web-rocket"] }

Your lib.rs should look like this:

lib.rs
// lib.rs
use rocket::http::Status;
use rocket::response::status::Custom;
use rocket::serde::json::Json;
use serde::{Deserialize, Serialize};

mod claims;

use claims::Claims;

#[macro_use]
extern crate rocket;

#[derive(Serialize)]
struct PublicResponse {
    message: String,
}

#[get("/public")]
fn public() -> Json<PublicResponse> {
    Json(PublicResponse {
        message: "This endpoint is open to anyone".to_string(),
    })
}

#[derive(Serialize)]
struct PrivateResponse {
    message: String,
    user: String,
}

// More details on Rocket request guards can be found here
// https://rocket.rs/v0.5-rc/guide/requests/#request-guards
#[get("/private")]
fn private(user: Claims) -> Json<PrivateResponse> {
    Json(PrivateResponse {
        message: "The `Claims` request guard ensures only valid JWTs can access this endpoint"
            .to_string(),
        user: user.name,
    })
}

#[derive(Deserialize)]
struct LoginRequest {
    username: String,
    password: String,
}

#[derive(Serialize)]
struct LoginResponse {
    token: String,
}

/// Tries to authenticate a user. Successful authentications get a JWT
#[post("/login", data = "<login>")]
fn login(login: Json<LoginRequest>) -> Result<Json<LoginResponse>, Custom<String>> {
    // This should be real user validation code, but is left simple for this example
    if login.username != "username" || login.password != "password" {
        return Err(Custom(
            Status::Unauthorized,
            "account was not found".to_string(),
        ));
    }

    let claim = Claims::from_name(&login.username);
    let response = LoginResponse {
        token: claim.into_token()?,
    };

    Ok(Json(response))
}

#[shuttle_service::main]
async fn rocket() -> shuttle_service::ShuttleRocket {
    let rocket = rocket::build().mount("/", routes![public, private, login]);

    Ok(rocket)
}

Your claims.rs should look like this:

claims.rs
// claims.rs
use chrono::{Duration, Utc};
use jsonwebtoken::{
    decode, encode, errors::ErrorKind, DecodingKey, EncodingKey, Header, Validation,
};
use lazy_static::lazy_static;
use rocket::{
    http::Status,
    request::{FromRequest, Outcome},
    response::status::Custom,
};
use serde::{Deserialize, Serialize};

// TODO: this has an extra trailing space to cause the test to fail
// This is to demonstate shuttle will not deploy when a test fails.
// FIX: remove the extra space character and try deploying again
const BEARER: &str = "Bearer  ";
const AUTHORIZATION: &str = "Authorization";

/// Key used for symmetric token encoding
const SECRET: &str = "secret";

lazy_static! {
    /// Time before token expires (aka exp claim)
    static ref TOKEN_EXPIRATION: Duration = Duration::minutes(5);
}

// Used when decoding a token to `Claims`
#[derive(Debug, PartialEq)]
pub(crate) enum AuthenticationError {
    Missing,
    Decoding(String),
    Expired,
}

// Basic claim object. Only the `exp` claim (field) is required. Consult the `jsonwebtoken` documentation for other claims that can be validated.
// The `name` is a custom claim for this API
#[derive(Serialize, Deserialize, Debug)]
pub(crate) struct Claims {
    pub(crate) name: String,
    exp: usize,
}

// Rocket specific request guard implementation
#[rocket::async_trait]
impl<'r> FromRequest<'r> for Claims {
    type Error = AuthenticationError;

    async fn from_request(request: &'r rocket::Request<'_>) -> Outcome<Self, Self::Error> {
        match request.headers().get_one(AUTHORIZATION) {
            None => Outcome::Failure((Status::Forbidden, AuthenticationError::Missing)),
            Some(value) => match Claims::from_authorization(value) {
                Err(e) => Outcome::Failure((Status::Forbidden, e)),
                Ok(claims) => Outcome::Success(claims),
            },
        }
    }
}

impl Claims {
    pub(crate) fn from_name(name: &str) -> Self {
        Self {
            name: name.to_string(),
            exp: 0,
        }
    }

    /// Create a `Claims` from a 'Bearer <token>' value
    fn from_authorization(value: &str) -> Result<Self, AuthenticationError> {
        let token = value.strip_prefix(BEARER).map(str::trim);

        if token.is_none() {
            return Err(AuthenticationError::Missing);
        }

        // Safe to unwrap as we just confirmed it is not none
        let token = token.unwrap();

        // Use `jsonwebtoken` to get the claims from a JWT
        // Consult the `jsonwebtoken` documentation for using other algorithms and validations (the default validation just checks the expiration claim)
        let token = decode::<Claims>(
            token,
            &DecodingKey::from_secret(SECRET.as_ref()),
            &Validation::default(),
        )
        .map_err(|e| match e.kind() {
            ErrorKind::ExpiredSignature => AuthenticationError::Expired,
            _ => AuthenticationError::Decoding(e.to_string()),
        })?;

        Ok(token.claims)
    }

    /// Converts this claims into a token string
    pub(crate) fn into_token(mut self) -> Result<String, Custom<String>> {
        let expiration = Utc::now()
            .checked_add_signed(*TOKEN_EXPIRATION)
            .expect("failed to create an expiration time")
            .timestamp();

        self.exp = expiration as usize;

        // Construct and return JWT using `jsonwebtoken`
        // Consult the `jsonwebtoken` documentation for using other algorithms and asymmetric keys
        let token = encode(
            &Header::default(),
            &self,
            &EncodingKey::from_secret(SECRET.as_ref()),
        )
        .map_err(|e| Custom(Status::BadRequest, e.to_string()))?;

        Ok(token)
    }
}

#[cfg(test)]
mod tests {
    use crate::claims::AuthenticationError;

    use super::Claims;

    #[test]
    fn missing_bearer() {
        let claim_err = Claims::from_authorization("no-Bearer-prefix").unwrap_err();

        assert_eq!(claim_err, AuthenticationError::Missing);
    }

    #[test]
    fn to_token_and_back() {
        let claim = Claims::from_name("test runner");
        let token = claim.into_token().unwrap();
        let token = format!("Bearer {token}");

        let claim = Claims::from_authorization(&token).unwrap();

        assert_eq!(claim.name, "test runner");
    }
}

Deploy

After logging into shuttle, use the following command to deploy this example:

Create a project, this will start an isolated deployer container for you under the hood:

cargo shuttle project new
cargo shuttle deploy

Notice how this deploy fails since one of the test fails. See the TODO at the top of src/claims.rs on how to fix the test. Once the code is fixed, try to deploy again. Now make a note of the Host for the deploy to use in the examples below. Or just use authentication-rocket-app.shuttleapp.rs as the host below.

Seeing it in action

First, we should be able to access the public endpoint without any authentication using:

$ curl https://<host>/public

But trying to access the private endpoint will fail with a 403 forbidden:

$ curl https://<host>/private

So let’s get a JWT from the login route first:

$ curl --request POST --data '{"username": "username", "password": "password"}' https://<host>/login

Accessing the private endpoint with the token will now succeed:

$ curl --header "Authorization: Bearer <token>" https://<host>/private

The token is set to expire in 5 minutus, so wait a while and try to access the private endpoint again. Once the token has expired, a user will need to get a new token from login. Since tokens usually have a longer than 5 minutes expiration time, we can create a /refresh endpoint that takes an active token and returns a new token with a refreshed expiration time.

URL Shortener

A URL shortener that you can use from your terminal - built with shuttle, rocket and postgres/sqlx.

Project structure

The project consists of the following files

  • Shuttle.toml contains the name of the app (if name is myapp domain will be myapp.shuttleapp.rs)
  • migrations folder is for DB migration files created by sqlx-cli
  • src/lib.rs is where all the magic happens - it creates a shuttle service with two endpoints: one for creating new short URLs and one for handling shortened URLs.
Cargo.toml
[package]
name = "url-shortener"
version = "0.1.0"
edition = "2021"

[lib]

[dependencies]
nanoid = "0.4"
rocket = { version = "0.5.0-rc.2", features = ["json"] }
serde = "1.0"
shuttle-service = { version = "0.9.0", features = ["web-rocket"] }
shuttle-shared-db = { version = "0.9.0", features = ["postgres"] }
sqlx = { version = "0.6", features = ["runtime-tokio-native-tls", "postgres"] }
url = "2.2"

Your lib.rs should look like this:

lib.rs
// lib.rs
#[macro_use]
extern crate rocket;

use rocket::{
    http::Status,
    response::{status, Redirect},
    routes, State,
};
use serde::Serialize;
use shuttle_service::{error::CustomError, ShuttleRocket};
use sqlx::migrate::Migrator;
use sqlx::{FromRow, PgPool};
use url::Url;

struct AppState {
    pool: PgPool,
}

#[derive(Serialize, FromRow)]
struct StoredURL {
    pub id: String,
    pub url: String,
}

#[get("/<id>")]
async fn redirect(id: String, state: &State<AppState>) -> Result<Redirect, status::Custom<String>> {
    let stored_url: StoredURL = sqlx::query_as("SELECT * FROM urls WHERE id = $1")
        .bind(id)
        .fetch_one(&state.pool)
        .await
        .map_err(|err| match err {
            sqlx::Error::RowNotFound => status::Custom(
                Status::NotFound,
                "the requested shortened URL does not exist".into(),
            ),
            _ => status::Custom(
                Status::InternalServerError,
                "something went wrong, sorry 🤷".into(),
            ),
        })?;

    Ok(Redirect::to(stored_url.url))
}

#[post("/", data = "<url>")]
async fn shorten(url: String, state: &State<AppState>) -> Result<String, status::Custom<String>> {
    let id = &nanoid::nanoid!(6);

    let parsed_url = Url::parse(&url).map_err(|err| {
        status::Custom(
            Status::UnprocessableEntity,
            format!("url validation failed: {err}"),
        )
    })?;

    sqlx::query("INSERT INTO urls(id, url) VALUES ($1, $2)")
        .bind(id)
        .bind(parsed_url.as_str())
        .execute(&state.pool)
        .await
        .map_err(|_| {
            status::Custom(
                Status::InternalServerError,
                "something went wrong, sorry 🤷".into(),
            )
        })?;

    Ok(format!("https://s.shuttleapp.rs/{id}"))
}

static MIGRATOR: Migrator = sqlx::migrate!();

#[shuttle_service::main]
async fn rocket(#[shuttle_shared_db::Postgres] pool: PgPool) -> ShuttleRocket {
    MIGRATOR.run(&pool).await.map_err(CustomError::new)?;

    let state = AppState { pool };
    let rocket = rocket::build()
        .mount("/", routes![redirect, shorten])
        .manage(state);

    Ok(rocket)
}

How to deploy

Create a project, this will start an isolated deployer container for you under the hood:

cargo shuttle project new

Once you’ve sorted out all of the items from above; you can deploy the app by simply running:

cargo shuttle deploy

And your app is live! 🎉🎉🎉

How to use it

You can use this URL shortener directly from your terminal. Just copy and paste this command to your terminal and replace <URL> with the URL that you want to shorten:

curl -X POST -d '<URL>' https://myapp.shuttleapp.rs
curl -X POST -d '<URL>' https://myapp.shuttleapp.rs

You will get the shortened URL back, something like this:

https://myapp.shuttle.app.rs/RvpVU_