velvet-web 0.5.0

Wrapper stack for webapp apis
Documentation

Velvet

(original repo: https://github.com/raffaeleragni/velvet)

crates.io

A layer of republish and small functions to remove some boilerplate on web stacks.

For a reference/example of a project using it: https://github.com/raffaeleragni/veltes

Stack used

  • WEB: Axum
  • DB: sqlx(postgres,sqlite,mysql)
  • Templating: Askama (folder templates/)
  • Telemetry: sentry supported
  • Metrics: prometheus under /metrics/prometheus

The askama templates and the static RustEmbed will be compiled in and not required at runtime.

The sqlx migrations are not embedded, and will be needed at runtime.

Proc macros cannot be transferred transitively, so crates need to be added again at project root in order to access them. For example tokio or serde.

Base route setup

use velvet_web::prelude::*;

#[tokio::main]
async fn main() {
    App::new().route("/", get(index)).start().await;
}

async fn index() -> impl IntoResponse {
    "Hello World"
}

Logging

Default log level is error. To change the level use the env var RUST_LOG=info|debug|warn.

To get structured logging (json logs) pass env var STRUCTURED_LOGGING=true.

use velvet_web::prelude::*;

#[tokio::main]
async fn main() {
    App::new().route("/", get(index)).start().await;
}

async fn index() -> AppResult<impl IntoResponse> {
    info!("Logging some info");
    Ok("Hello World")
}

Add custom metrics

Metrics available at /metrics/prometheus. The custom metrics will be visible as soon as the first use happens, but only when used after App startup, not before. For example, all the routes will work when used like this.

use velvet_web::prelude::*;

#[tokio::main]
async fn main() {
    App::new().route("/", get(index)).start().await;
}

async fn index() -> AppResult<impl IntoResponse> {
    metric_counter("counter").increment(1);
    Ok("Hello World")
}

Add a database

Adding a .env file with DATABASE_URL=sqlite::memory:, and enabling the feature sqlite in crate velvet_web.

use velvet_web::prelude::*;

#[tokio::main]
async fn main() {
    let db = sqlite().await;
    App::new().route("/", get(index)).inject(db).start().await;
}

async fn index(Extension(db): Extension<Pool<Sqlite>>) -> AppResult<impl IntoResponse> {
    let res = sqlx::query!("pragma integrity_check").fetch_one(&db).await?;
    Ok(res.integrity_check.unwrap_or("Bad check".to_string()))
}

Use an HTTP Client

use velvet_web::prelude::*;

#[tokio::main]
async fn main() {
    App::new().route("/", get(index)).inject(client()).start().await;
}

async fn index(Extension(client): Extension<Client>) -> AppResult<impl IntoResponse> {
    Ok(client.get("https://en.wikipedia.org").send().await?.text().await?)
}

Check JWT token (from bearer or cookies)

Adding a .env file with JWT_SECRET=secret and enabling the feature auth in velvet_web.

JWK urls are also supported with a different enum initialization JWT::JWK.setup().await?.

use velvet_web::prelude::*;

#[derive(Deserialize)]
struct Claims {
    role: String,
}

#[tokio::main]
async fn main() -> AppResult<()> {
    JWT::Secret.setup().await?;
    let router = Router::new()
        .route("/", get(index))
        .authorized_bearer_claims(|claims: Claims| Ok(claims.role == "admin"));
    App::new().router(router).start().await;
    Ok(())
}

async fn index() -> AppResult<impl IntoResponse> {
    Ok("Hello World")
}

Support for static files

Need to include crate rust_embed as this uses proc macros.

use velvet_web::prelude::*;

#[tokio::main]
async fn main() {
    #[derive(RustEmbed)]
    #[folder = "statics"]
    struct S;

    App::new().statics::<S>().start().await;
}

A more complete example

Using also Askama templates, and JWT through cookie setting.

use std::time::{SystemTime, UNIX_EPOCH};

use velvet_web::prelude::*;

#[tokio::main]
async fn main() -> AppResult<()> {
    #[derive(RustEmbed)]
    #[folder = "statics"]
    struct S;
    JWT::Secret.setup().await?;
    let db = sqlite().await;
    sqlx::migrate!().run(&db).await?;

    let router = Router::new()
        .route("/", get(index))
        .authorized_cookie_claims(|claims: Claims| Ok(claims.role == "user"))
        .route("/login", get(login));
    App::new()
        .router(router)
        .inject(db)
        .inject(client())
        .statics::<S>()
        .start()
        .await;
    Ok(())
}

#[derive(Serialize, Deserialize)]
struct Claims {
    exp: u64,
    role: String,
}

async fn login(jar: CookieJar) -> AppResult<(CookieJar, Redirect)> {
    let jar = CookieToken::set_from_claims(
        jar,
        Claims {
            exp: SystemTime::now()
                .duration_since(UNIX_EPOCH)
                .unwrap()
                .as_secs()
                + 3600,
            role: "user".to_string(),
        },
    )
    .map_err(|_| StatusCode::UNPROCESSABLE_ENTITY)?;
    Ok((jar, Redirect::to("/")))
}

#[derive(Template)]
#[template(path = "index.html")]
struct Index;

async fn index(Extension(db): Extension<Pool<Sqlite>>) -> AppResult<impl IntoResponse> {
    let _ = query!("pragma integrity_check")
        .fetch_one(&db)
        .await?
        .integrity_check;
    Ok(Index {})
}

Testing routes

use velvet_web::prelude::*;

#[tokio::test]
async fn test() {
    let app = App::new().route("/", get(|| async { "result" }));
    let server = app.as_test_server().await;
    let response = server.get("/").await.text();
    assert_eq!(response, "result");
}

Default routes already implemented

  • Status (no-op): http GET /status/liveness
  • Metrics: http GET /metrics/prometheus

ENV vars

  • SERVER_BIND: [default] default (0.0.0.0) bind network for which to listen on
  • SERVER_PORT: [number] (default 8080) port for which to listen on
  • DATABASE_URL: postgres://user:pass@host:port/database (if database used) or sqlite::memory:...
  • DATABASE_MAX_CONNECTIONS: [number] (default 1)
  • STRUCTURED_LOGGING: true|false (default false)
  • SENTRY_URL: url inclusive of key for sending telemetry to sentry

To setup TLS use env vars:

  • TLS=true (or any string)
  • TLS_PEM_CERT=cert.pem
  • TLS_PEM_KEY=key.pem