Docs / ArchitectureKit / Rust / Guides / Setting Up an HTTP API

Setting Up an HTTP API

An optional feature serves commands and queries over HTTP.

To serve commands and queries with axum, turn on the feature axum of the crate, and add axum, and tower-http for the answer to a handler that panics:

cargo add architecturekit --features axum
cargo add axum@0.8
cargo add tower-http --features catch-panic

The kit offers building blocks rather than routes: every route is an ordinary handler of axum, which takes the request apart with extractors, builds a command or a query, and answers with what the kit answers. The building blocks live in the module architecturekit::httpapi. What the handlers need, such as the store, the deciders, and the projection that holds the views, goes into the state of the router, a struct that derives Clone, so that axum can hand a copy to every handler. Since Store can not be cloned, it goes there behind an Arc, as does everything else that is shared:

use std::sync::Arc;

use architecturekit::{Decider, DomainError, httpapi::answer_panic};
use axum::Router;
use tower_http::catch_panic::CatchPanicLayer;

#[derive(Clone)]
pub struct App {
  pub store: Arc<Store>,
  pub acquire_book: Arc<Decider<AcquireBook, Book, DomainError>>,
  pub borrow_book: Arc<Decider<BorrowBook, Book, DomainError>>,
  pub return_book: Arc<Decider<ReturnBook, Book, DomainError>>,
  pub catalog_projection: Arc<CatalogProjection>,
}

Then create the state, and the router with the routes of the commands and queries (see Handling Commands over HTTP and Handling Queries over HTTP). The layer CatchPanicLayer of tower-http, together with answer_panic of the kit, answers a handler that panics with 500 Internal Server Error:

let app = App {
  store: Arc::new(store),
  acquire_book: Arc::new(acquire_book()),
  borrow_book: Arc::new(borrow_book()),
  return_book: Arc::new(return_book()),
  catalog_projection: Arc::clone(&catalog_projection),
};

let router: Router = Router::new()
  // ...
  .layer(CatchPanicLayer::custom(answer_panic))
  .with_state(app);

A handler answers a failure with an HttpError, which nearly every error of the kit turns into with ?, by its category (see Mapping Errors to Status Codes). Every failure that an HttpError does not tell the caller in full is logged, once, when it is answered: a failure of the server, answered with 500 or 503, at level error, and a refusal whose details the caller is not told, such as one with 401, 409, or a 404 for a query that found no item, at level info (see Handling Commands over HTTP). A panic that answer_panic answers is logged at level error, with its message. The kit logs with the log crate, under targets that start with architecturekit, so the records go to the logger the application installs for it, such as env_logger. Without a logger, they are dropped.

Determining the User#

The kit does not determine who is making a request, since that is up to the application. Define a type that describes the user, and implement the FromRequestParts trait of axum for it, with HttpError as its rejection. Every handler that takes the user as an argument then determines it first, and a request whose user can not be determined is refused before the handler runs:

use architecturekit::{Categorized, Category, httpapi::HttpError};
use axum::{
  extract::FromRequestParts,
  http::{header, request::Parts},
};

pub struct User {
  pub id: String,
  pub is_librarian: bool,
}

impl<S: Send + Sync> FromRequestParts<S> for User {
  type Rejection = HttpError;

  async fn from_request_parts(parts: &mut Parts, _: &S) -> Result<Self, HttpError> {
    let Some(token) = parts.headers.get(header::AUTHORIZATION) else {
      return Err(HttpError::unauthorized("the request carries no token"));
    };

    match verify(token).await {
      Ok(user) => Ok(user),
      Err(error) if error.category() == Some(Category::Transient) => Err(HttpError::of(error)),
      Err(error) => Err(HttpError::unauthorized(error)),
    }
  }
}

Here, verify stands for whatever your application uses to verify a token, such as a session store, and its error for the one it fails with, which implements Categorized. HttpError::unauthorized answers with 401 Unauthorized and the fixed message unauthorized, since the cause may name internals, such as the key a token failed to verify with, and logs the cause at level info. Not every failure means that the caller is unknown, though. If the session store is down, for example, the error belongs to the category Category::Transient, and HttpError::of answers it with 503 Service Unavailable and logs it at level error, rather than sending the caller off to sign in again.

A handler takes the user like any other extractor, before the body, since an extractor that reads the body comes last (see Validating Requests). So a caller who is not known is refused before the body is read:

pub async fn handle_me(user: User) -> String {
  user.id
}

For an application without authentication, leave the extractor out. Its handlers then take no user.