/rust-axum-web-lab

A web application in Rust built with Tokio Axum and the Hyper server

Primary LanguageRustMIT LicenseMIT

Rust Axum Web Application

A web application in Rust built with Tokio Axum and the Hyper server.

Structure

  • src/main.rs: The main entry point of the application.
  • assets: static assets such as images, stylesheets, etc.
  • templates: templates for HTML pages. This is the default location for templates when using the askama crate.

Features

Axum Routing

Routing in Axum similar to routing in low-level HTTP libraries like Sinatra, Flask and Express. See src/server.rs for the routing.

It is worth to mention that the concept of route and nested routes. A route is a single path, like /foo or /bar. A nested route is a the root route and everything "under" it. For example, /root/x and /root/y/z are nested routes under /root.

When declaring the routing, keep this in mind:

We serve a single file on a single (non-nested) route like this:

    let router = axum::routing::Router::new()
        .route_service("/assets/foo.html", ServeFile::new("assets/foo.html"));

We can serve a whole directory like this. Note that we use .nest_service since it nests all the routes, .route_service would route the root path to the service only.

    let router = axum::routing::Router::new()
        .nest_service("/assets", ServeDir::new("assets"));

Path Parameters

You can use path parameters to turn path segments into parameters to the handler function:

    let router = axum::routing::Router::new()
        .route("/languages/years/:year", get(languages_from_year));

The handler function would look like this, using the Path extractor. It contains a single value or a tuple if you match multiple path segments:

    // Path is an Axum Extract to get the matched value from the path (see below in the route configuration)
    async fn languages_from_year(Path(year): Path<u32>) -> LanguagesTemplate { /* ... */ }

Query Parameters

The query parameters are extracted using the Query extractor. It can be used to extract the query parameters into an stringly typed HashMap of key-value pairs or a typed struct:

    // Stringly typed
    async fn languages_from_year_query(Query(params): Query<HashMap<String, String>>) 
                -> LanguagesTemplate { /* ... */ }
    // Typed struct

    /// Axum can use `serde` to deserialize the query parameters into a struct
    #[derive(Deserialize)]
    pub(crate) struct LanguagesFilter {
      year_from_inclusive: Option<u32>,
      year_to_exclusive: Option<u32>,
    }

    async fn languages_by_struct_query(filter: Query<LanguagesFilter>) 
                -> LanguagesTemplate { /* ... */ }
Empty Strings and Missing Query Parameters

You can get some errors for missing fields and empty fields in the query string when it is deserialized with serde.

See the serde documentation for #[serde(deserialize_with = ...)] to see how to define the behaviour for these scenarios.

#[derive(Deserialize)]
pub(crate) struct LanguagesFilterThatAcceptsEmptyQueryParameterValuesAsNone {
  #[serde(deserialize_with = "empty_string_as_none")]
  year_from_inclusive: Option<u32>,
  #[serde(deserialize_with = "empty_string_as_none")]
  year_to_exclusive: Option<u32>,
}

From https://github.com/tokio-rs/axum/blob/main/examples/query-params-with-empty-strings/src/main.rs

/// Serde deserialization decorator to map empty Strings to None,
fn empty_string_as_none<'de, D, T>(de: D) -> Result<Option<T>, D::Error>
  where
          D: Deserializer<'de>,
          T: FromStr,
          T::Err: fmt::Display,
{
  let opt = Option::<String>::deserialize(de)?;
  match opt.as_deref() {
    None | Some("") => Ok(None),
    Some(s) => FromStr::from_str(s).map_err(de::Error::custom).map(Some),
  }
}

Testing Query Parsing

You can use the try_from_uri method of the Query extractor to test the query parsing like this. This is very useful when using bespoke deserialize_with functions.

    #[test]
    fn languages_filter_can_deserialize_when_all_query_string_params_are_present_and_valid() {
        let uri = Uri::builder().path_and_query("/?year_from_inclusive=1950&year_to_exclusive=1970").build().unwrap();
        let q = Query::<LanguagesFilter>::try_from_uri(&uri).unwrap();
        assert_eq!(Some(1950), q.year_from_inclusive);
        assert_eq!(Some(1970), q.year_to_exclusive);
    }

Debugging Axum Handlers

The error messages are terrible when the handler signatures are not correct.

Unfortunately Rust gives poor error messages if you try to use a function that doesn’t quite match what’s required by Handler.

https://docs.rs/axum/latest/axum/handler/index.html#debugging-handler-type-errors

Use axum-macros crate and its debug_handler macro to get better error messages. Just apply it to the handler function:

#[debug_handler]
async fn foo( /* ... */ ) -> impl IntoResponse {
    // ...
}

Tracing

Tracing is enabled, see the use of the tracing macros like info!. See src/main.rs for the configuration of the tracing library.

Note that it is not enough to configure the tracing library, in many cases the libraries that are used also need to be configured to use tracing by enabling the tracing feature. See cargo.toml.

For example, for tower-http the following is needed:

tower-http = { version = "0.4.3", features = ["fs", "trace"] }

Templating

The application uses the askama template library to render HTML pages.

It appears that askama and tera are popular choices for templating in Rust. However, askama comes with axum bindings, so we will use that for now.

Template Inheritance

We can use template inheritance to improve consistency and reduce duplication in our templates. See https://djc.github.io/askama/template_syntax.html

For example, see the /languages route in src/server.rs and the corresponding templates in templates/languages, e.g. templates/languages/base.html and templates/languages/index.html.

License

Published under the MIT License, see LICENSE.