Skip to main content

Axum HTTP Applications

The nakago-axum crate defines AxumApplication, which wraps Application and provides a way to Run an HTTP service and use the Inject container via Axum's State mechanism.

Dependencies vs. Context

Axum provides the State extractor that allows you to inject dependencies that stay the same across many requests. For Nakago applications, however, your dependencies are provided through the Inject container. Nakago Axum apps use State to automatically carry the Inject container, but in a way that you don't have to think about while building typicaly applications.

Application Lifecycle

Init

The Init Hook for an Axum Application automatically adds the http::Config and auth::Config Loaders before the user-provided hook is invoked and the Config is generated.

Startup

The Startup Hook for an Axum Application uses the State with the provided Router, allowing the flow of the application to proceed through the Axum request handlers.

Routes

Routes are initialized on Init. There are multiple options for how to implement handlers and access their Dependencies. The approach with the smoothest Nakago integration also has the benefit of allowing you to define Controller structs with methods that can be used as handlers, allowing you to share common dependencies between related Axum request handlers.

Controllers

Start with a hypothetical Controller for a WebSocket connection, that will handle requests to upgrade an HTTP request to a WebSocket connection:

pub const CONTROLLER: Tag<Controller> = Tag::new("events::Controller");

#[derive(Clone)]
pub struct Controller {
users: Arc<Box<dyn users::Service>>,
handler: Arc<socket::Handler>,
}

It has a dependency on the Users Service and the Socket Handler, which are both used within the "upgrade()" method:

impl Controller {
/// Create a new Events handler
pub async fn upgrade(
self: Arc<Self>,
sub: Subject,
ws: WebSocketUpgrade,
) -> axum::response::Result<impl IntoResponse> {
// Retrieve the request User, if username is present
let user = if let Subject(Some(ref username)) = sub {
self.users
.get_by_username(username, &true)
.await
.unwrap_or(None)
} else {
None
};

Ok(ws.on_upgrade(|socket| async move { self.handler.handle(socket, user).await }))
}
}

As you can see, standard Axum extractors like Subject are usable within the Controller methods, and the Controller can use self to access Dependencies and complete the work it needs to do. Other methods can be added that share the same dependencies, organized around a common business domain or other focus.

Couple this with a Provider that can be used to inject the dependency:

#[derive(Default)]
pub struct Provide {}

#[Provider]
#[async_trait]
impl Provider<Controller> for Provide {
async fn provide(self: Arc<Self>, i: Inject) -> provider::Result<Arc<Controller>> {
let users = i.get(&users::SERVICE).await?;
let handler = i.get(&socket::HANDLER).await?;

Ok(Arc::new(Controller { users, handler }))
}
}

The route can then be initialized with an Init hook:

#[derive(Default)]
pub struct Init {}

#[async_trait]
impl Hook for Init {
async fn handle(&self, i: Inject) -> hooks::Result<()> {
let events_controller = i.get(&events::CONTROLLER).await?;

i.handle(routes::Init::new(
Method::GET,
"/events",
move |sub, ws| async move {
events::Controller::upgrade(events_controller, sub, ws).await
},
))
.await?;

Ok(())
}
}

The move |sub, ws| async move {} function is a necessary shim to wrap the method to use it as a handler.

Functional Handlers

Functional handlers eschew the typical Nakago approach of using a struct with Dependencies on the self instance, and instead use async functions with access to an Axum State extractor that pulls the Inject container out of the State.

Here's an example of a route handler implemented as an async function that uses the Inject container to retrieve a Users Service and a WebSocket connection handler::

use nakago_axum::{auth::Subject, Error, Inject};

pub async fn upgrade(
Inject(i): Inject,
sub: Subject,
ws: WebSocketUpgrade,
) -> axum::response::Result<impl IntoResponse> {
let users = i.get(&users::SERVICE).await.map_err(Error)?;
let handler = i.get(&socket::HANDLER).await.map_err(Error)?;

// Retrieve the request User, if username is present
let user = if let Subject(Some(ref username)) = sub {
users.get_by_username(username, &true).await.unwrap_or(None)
} else {
None
};

Ok(ws.on_upgrade(|socket| async move { handler.handle(socket, user).await }))
}

The Inject extractor from the nakago_axum package is used to retrieve the Inject container from the State. This container is then used to retrieve the users::SERVICE and socket::HANDLER services from the container, mapping the errors to the special nakago_axum:Error wrapper that works as an Axum response.

Then you can initialize the route in an Init hook:

#[derive(Default)]
pub struct Init {}

#[async_trait]
impl Hook for Init {
async fn handle(&self, _i: Inject) -> hooks::Result<()> {
i.handle(routes::Init::new(
Method::GET,
"/events",
events::upgrade,
))
.await?;

Ok(())
}
}

Starting the Application

To start your application, pass in your top-level Config type and create an instance. Attach Hooks in the order that they should be executed:

let mut app = AxumApplication::<Config>::default();
app.on(&EventType::Load, authz::Load::default());
app.on(&EventType::Load, graphql::Load::default());
app.on(&EventType::Init, graphql::Init::default());

Then, use run to start the application and return the connection details.

let (server, addr) = app.run(args.config_path).await?;

info!("Started on port: {port}", port = addr.port());

server.await?;

Integration Testing

Integration testing is handled by initializing your application server in a way similar to Production, using test utils to make requests to your server running in the background.

let app = init::app().await?;

let config_path = std::env::var("CONFIG_PATH").unwrap_or_else(|_| "test.toml".to_string());

let utils = nakago_axum::test::Utils::init(app, &config_path, "/").await?;

let username = Ulid::new().to_string();
let token = utils.create_jwt(&username).await?;

let resp = utils
.http
.request_json(Method::POST, "/username", Value::Null, Some(&token))
.send()
.await?;

See the Async-GraphQL Example's integration tests for examples of how to use this pattern. This will evolve as more pieces are moved into the framework itself over time.

CI Integration Testing

This strategy can be used for integration testing in the CI service of your choice based on a Docker Compose formation of shallow dependencies. This allows you to set up things like LocalStack or Postgers within your CI Docker environment and run integration tests against them without needing to use deployed resources. Branch-specific PR's are easy to run tests for in isolation.

Stay tuned for more details on how to set up this approach in your CI environment.