Docs / ArchitectureKit / Rust / Guides / Handling Commands over HTTP

Handling Commands over HTTP

To accept a command over HTTP, define a request type that derives Deserialize for the body, and a handler of axum that builds the command from it, executes it, and answers. The handler takes the body with the Strict extractor, the user with the extractor of your application (see Setting Up an HTTP API), and what the body does not hold from other extractors, such as a value of the path with Path, as a handler of a query does (see Handling Queries over HTTP).

For example, for the BorrowBook that checks the revision of the caller (see Checking the Revision of the Caller), with the ID of the book in the path, and the date checked with NaiveDate of the chrono crate, which you add with cargo add chrono:

use architecturekit::httpapi::{CommandAnswer, HttpError, Strict};
use axum::extract::{Path, State};
use chrono::NaiveDate;
use serde::Deserialize;

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct BorrowBookRequest {
  borrowed_until: String,
  expected_event_id: String,
}

pub async fn handle_borrow_book(
  State(app): State<App>,
  Path(book_id): Path<String>,
  user: User,
  Strict(request): Strict<BorrowBookRequest>,
) -> Result<CommandAnswer, HttpError> {
  BOOKS.check(&[&book_id])?;
  if NaiveDate::parse_from_str(&request.borrowed_until, "%Y-%m-%d").is_err() {
    return Err(HttpError::malformed("borrowedUntil must be a date"));
  }
  let Ok(expected_event_id) = request.expected_event_id.parse() else {
    return Err(HttpError::malformed("expectedEventId must be an event ID"));
  };

  let command = BorrowBook {
    book: BOOKS.build(&[&book_id]),
    reader_id: user.id,
    borrowed_until: request.borrowed_until,
    expected_event_id,
  };
  let written = app.store.execute(&app.borrow_book, command).await?;

  Ok(CommandAnswer::new(&written))
}

The handler is the place to validate a request, since HttpError::malformed answers with 400 Bad Request and malformed request: followed by the reason. Check at least what would otherwise fail later: the ID of the book becomes part of a subject, and build panics on an empty ID or one with a character that a segment of a subject may not contain, such as a slash or a dot (see Composing Subjects), which is answered with 500 Internal Server Error at best (see below). check refuses such an ID with an error that ? answers with 400 Bad Request and its own text, such as value "a.b" for "book_id" may only contain A-Z, a-z, 0-9, underscores, and hyphens. A value of the path is no exception, since it may hold a slash, sent as %2F. And a value that does not match the schema of its event is refused by the database, which is a permanent failure answered with 500 Internal Server Error – although it is the caller's mistake. The expected event ID becomes a Revision, which on_event_id takes, so the handler reads it with parse. Handing on its error with ? would answer with 400 Bad Request as well, with a message such as not a revision: "abc". Refusing it here lets the message name the field.

Then route the handler with the router:

use axum::routing::post;

let router = Router::new().route("/api/books/{id}/borrow", post(handle_borrow_book));

A command changes something, so route it with a method that may do so, usually post. GET, HEAD, OPTIONS, and QUERY must not change anything. A browser sends GET and HEAD from another site without asking, for example for a link that the user follows, along with the cookies of the user, and a route that takes no body lets GET, HEAD, and OPTIONS pass from another origin (see Handling Commands Without a Body). QUERY may change nothing either, since HTTP defines it as safe and repeatable, so a client or a cache may send it again. Routing is up to axum, so nothing keeps a command from being routed with get or any: route it with post, rather than executing the command for any site that links to it, or whenever a query is sent again.

The handler decodes the request body, builds the command, and executes it:

curl -X POST http://localhost:8080/api/books/42/borrow \
  -H "Authorization: Bearer …" \
  -H "Content-Type: application/json" \
  -d '{"borrowedUntil":"2026-10-24","expectedEventId":"0"}'

If this succeeds, CommandAnswer answers with 200 OK and the revision it has written, which is the ID of the last written event:

{"revision":"1"}

The revision is empty if the command did not write anything. A caller hands it to a query to read its own writes (see Reading Your Own Writes over HTTP).

Otherwise, HttpError answers with the status code that matches the error (see Mapping Errors to Status Codes) and a message:

{"message":"/books/42 is already borrowed"}

The message is the error message if the error is written for the caller, such as a broken business rule or what is wrong with the request. Other errors may name internals, such as the key a token failed to verify with, or the subject a precondition guarded, so their message is fixed:

Status code Message
401 Unauthorized unauthorized
404 Not Found, for a query that found no item not found
409 Conflict conflict: the data has changed since it was read
500 Internal Server Error, for a write of the command whose outcome is unknown outcome unknown: the request may have succeeded
500 and above internal server error

The actual error is logged, so that it does not vanish (see Setting Up an HTTP API): at level info for 401, 404, and 409, since the server did not fail, and at level error for 500 and above. For 404, that holds only for a query that found no item (see Reporting Missing Items). Every answer to a command, whether a success or a failure, says Cache-Control: no-store, so that no cache keeps it.

If a handler panics, for example because build received an ID that was not checked, axum would drop the connection, and the caller would get no answer at all. A handler belongs to the application, so the kit catches no panic itself. Add the layer CatchPanicLayer of tower-http with answer_panic to the router instead (see Setting Up an HTTP API). It answers with 500 Internal Server Error and the message internal server error, like any other internal failure, and logs the panic at level error, with its message.

An error of the kit turns into an HttpError with ?, by what it is (see Mapping Errors to Status Codes). An error of your own turns into one with HttpError::of, which knows only its category, so it answers an error without a category with 500 Internal Server Error, also if the handler has found a mistake in the request itself. Answer such a mistake with HttpError::malformed, as above, to answer it with 400 Bad Request.

Note that the request type only describes the body, so it may come from another crate, for example one that the application shares with its clients.

Handling Commands Without a Body#

Some commands take everything they need from the path, such as ReturnBook, which only needs the ID of the book. Such a command takes no body, so its handler takes the NoBody extractor instead of Strict:

use architecturekit::httpapi::NoBody;

pub async fn handle_return_book(
  State(app): State<App>,
  Path(book_id): Path<String>,
  _: User,
  _: NoBody,
) -> Result<CommandAnswer, HttpError> {
  BOOKS.check(&[&book_id])?;

  let written =
    app.store.execute(&app.return_book, ReturnBook { book: BOOKS.build(&[&book_id]) }).await?;

  Ok(CommandAnswer::new(&written))
}
let router = Router::new().route("/api/books/{id}/return", post(handle_return_book));

The handler then expects no body, so the caller sends neither a Content-Type header nor a body:

curl -X POST http://localhost:8080/api/books/42/return \
  -H "Authorization: Bearer …"

A body of {} is accepted as well, whatever the Content-Type header says, so that a caller that sends one out of habit keeps working. Any other body is answered with 400 Bad Request, and the message says that the route takes no body.

Requiring application/json is what keeps a browser from sending a command from another site without asking the server first, since a form can not send JSON. A route without a body can not rely on that, so before it looks at the body, NoBody checks where the request comes from. A request that a browser sends from another origin, which the Sec-Fetch-Site header says, or, without it, an Origin header whose host differs from the Host header, is answered with 403 Forbidden, and the message forbidden: a request without a body is not accepted from another origin. A request from the same origin passes, and so does one without these headers, such as one of curl or of another server.

Note that this also refuses a browser frontend that runs on another origin than the API, even on another subdomain, so such a frontend can not call a route that takes NoBody.

The check lets a request with GET, HEAD, or OPTIONS pass from any origin, since these methods must not change anything. That is why a handler that executes a command is routed with a method that may change something, such as post (see above).

Adding to the Answer#

To answer with more than the revision, for example with the ID that the handler below makes up for a new book, call the with method on the answer. It takes the fields to add, usually as a struct that derives Serialize. Here, the ID comes from the uuid crate, which you add with cargo add uuid --features v4:

use serde::Serialize;
use uuid::Uuid;

#[derive(Deserialize)]
pub struct AcquireBookRequest {
  title: String,
  author: String,
  isbn: String,
}

#[derive(Serialize)]
pub struct AcquiredBook {
  id: String,
}

pub async fn handle_acquire_book(
  State(app): State<App>,
  _: User,
  Strict(request): Strict<AcquireBookRequest>,
) -> Result<CommandAnswer, HttpError> {
  let book_id = Uuid::new_v4().to_string();
  let command = AcquireBook {
    book: BOOKS.build(&[&book_id]),
    title: request.title,
    author: request.author,
    isbn: request.isbn,
  };
  let written = app.store.execute(&app.acquire_book, command).await?;

  Ok(CommandAnswer::new(&written).with(AcquiredBook { id: book_id }))
}
let router = Router::new().route("/api/acquire-book", post(handle_acquire_book));

The handler then answers with both:

{"id":"…","revision":"0"}

The fields are only added once the command has succeeded, since ? returns the failure before. A value that encodes to null, such as None, adds no fields, and calling with again replaces the fields of an earlier call.

If the fields come from something that may fail after the command has succeeded, such as a view that has to catch up with the events first, hand over the result with the with_result method instead (see Waiting for Written Events). If it is an error, the events are written all the same, so the handler still answers with 200 OK and the revision, which the caller needs to read its own writes, and must not take for a reason to send the command again. The answer then holds no further fields, and the error is logged at level error.

The kit adds the revision itself, so the fields must not contain one, and they must encode to a JSON object. Otherwise, or if they can not be encoded at all, the answer is 500 Internal Server Error, which is logged, although the events have been written, since that is a mistake in the code rather than something that happens at runtime.

Note that the written events are available to the handler as well, in what execute returns. Add them only deliberately: they are the inner model of the application, every caller that reads them depends on their shape, and they may contain data that is not meant for the caller.

Answering Commands in Your Own Format#

To answer in a format of your own, for example with another status code, return whatever axum can turn into a response, and keep HttpError for the failures:

use axum::{Json, http::StatusCode};

pub async fn handle_acquire_book(
  State(app): State<App>,
  _: User,
  Strict(request): Strict<AcquireBookRequest>,
) -> Result<(StatusCode, Json<AcquiredBook>), HttpError> {
  let book_id = Uuid::new_v4().to_string();
  let command = AcquireBook {
    book: BOOKS.build(&[&book_id]),
    title: request.title,
    author: request.author,
    isbn: request.isbn,
  };
  app.store.execute(&app.acquire_book, command).await?;

  Ok((StatusCode::CREATED, Json(AcquiredBook { id: book_id })))
}

Note that such an answer carries no revision, so its caller can not read its own writes. To add it, take it from the written events with revision_of, which returns None if the command wrote nothing.

Note that such an answer carries no Cache-Control header, unless the handler adds one, while every answer of the kit to a command says no-store.

Authorizing Commands#

To refuse a command, return HttpError::forbidden from the handler before it executes the command. The request is then answered with 403 Forbidden, and forbidden: followed by the reason:

pub async fn handle_acquire_book(
  State(app): State<App>,
  user: User,
  Strict(request): Strict<AcquireBookRequest>,
) -> Result<CommandAnswer, HttpError> {
  if !user.is_librarian {
    return Err(HttpError::forbidden("only librarians may acquire books"));
  }

  let book_id = Uuid::new_v4().to_string();
  let command = AcquireBook {
    book: BOOKS.build(&[&book_id]),
    title: request.title,
    author: request.author,
    isbn: request.isbn,
  };
  let written = app.store.execute(&app.acquire_book, command).await?;

  Ok(CommandAnswer::new(&written).with(AcquiredBook { id: book_id }))
}

The same applies to every other refusal a handler returns before it executes the command, such as HttpError::not_found, or an error of the category Category::Domain, which HttpError::of answers with 422 Unprocessable Entity and its own text. If the handler looks something up in another service, and that service is down, hand the error, which belongs to the category Category::Transient, to HttpError::of. The request is then answered with 503 Service Unavailable, and the failure is logged, rather than blaming the request.

Validating Requests#

Before the handler receives the body, Strict validates the request, as it does for a query (see Handling Queries over HTTP):

  • The Content-Type header must be application/json, otherwise the request is answered with 415 Unsupported Media Type, and the message starts with unsupported media type: . Parameters such as charset may follow, and case does not matter.
  • The body must not be larger than MAX_REQUEST_BODY, which is one mebibyte, otherwise the request is answered with 413 Payload Too Large, and the message starts with request body too large: . The same goes for a body that a layer cuts off at a lower limit, such as DefaultBodyLimit of axum or RequestBodyLimitLayer of tower-http.
  • The body must be a single valid JSON value that fits the request type, otherwise the request is answered with 400 Bad Request, and the message starts with malformed request: . A field that the request type does not have is refused at any depth, without #[serde(deny_unknown_fields)]. Nothing but whitespace may follow the value, and no name may occur twice in an object. A list where a struct belongs is refused as well, which serde would otherwise take for the values of the fields, in their order, so that a caller who put them in another order would set the wrong fields. A field that the body leaves out is refused as missing, unless the request type gives it a default, such as an Option, or with #[serde(default)].

Note that parsers disagree on what a name that occurs twice means, and on data after the value: one takes the first value, another the last, and one stops after the value, while another reads on. A filter or a proxy in front of the application might then check another value than the one the application uses, which is why both are refused.

Note that serde decodes an enum with #[serde(untagged)] or #[serde(tag = "…")], and a field with #[serde(flatten)], from a copy of its own that the checks do not reach, so within them, neither a field that the type does not have nor a list for a struct is refused.

If the body can not be decoded, the message says what is wrong in words of its own, rather than in those of serde, which name the types of Rust and positions in the text. After malformed request: , it says, for example, empty body, invalid JSON, data after the JSON value, unknown field "borowedUntil", duplicate field "expectedEventId", "borrowedUntil" must be a string, "borrowedUntil" is missing, the body must be an object, for a body of null or a list as well, or "pair" must have 2 elements. A nested value is named by its path, such as "items.0.bookId". For a failure that it has no words of its own for, it says that the value can not be decoded, as in "rating" can not be decoded. A body that can not even be read, for example because a layer fails to decompress it, gives the body could not be read. The error of a type that decodes itself, on the other hand, keeps its own text, as it is. For the full list, see the documentation of body_of.

If the handler takes NoBody, the request is validated differently (see Handling Commands Without a Body): a request that a browser sends from another origin is answered with 403 Forbidden first. The Content-Type header is not required, and the body must be empty or {}, otherwise the request is answered with 400 Bad Request. A body larger than MAX_REQUEST_BODY is still answered with 413 Payload Too Large.

Since Strict and NoBody read the body, they come last among the extractors of a handler, after the one for the user, so that an unknown caller is refused before the body is read. For a web framework other than axum, the feature http offers the same with the types of the http crate: body_of reads a body by the same rules as Strict, from the parts of the request and its bytes, and no_body checks a request as NoBody does.