# Serving Commands and Queries over HTTP

In this step, you turn the library into an **HTTP API** that accepts commands and answers queries, built with **axum**. The API keeps its view up to date while it runs, and lets a caller **read its own writes**.

## Mapping Requests to Commands

The commands from **[Making Decisions](/learn/tutorials/first-steps-with-architecturekit/rust/making-decisions)** know nothing about HTTP. To accept them over HTTP, define a **request type** for each command, which serde reads from the JSON body, and a **conversion** that turns a request into a command.

First, add the feature `axum` of ArchitectureKit, which brings extractors and answers for axum 0.8 and turns on the feature `http` as well, then axum itself and chrono, which checks the dates below, and the feature `net` of Tokio, which brings the `TcpListener` that axum serves on:

```shell
cargo add architecturekit --features axum
cargo add axum@0.8 chrono
cargo add tokio --features net
```

Create a file `src/requests.rs`:

```rust
use architecturekit::httpapi::HttpError;
use chrono::NaiveDate;
use serde::Deserialize;

use crate::commands::{AcquireBook, BOOK_SUBJECT, BorrowBook, ReturnBook};
use crate::queries::ListBooks;

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AcquireBookRequest {
  pub book_id: String,
  pub title: String,
  pub author: String,
  pub isbn: String,
}

impl TryFrom<AcquireBookRequest> for AcquireBook {
  type Error = HttpError;

  fn try_from(request: AcquireBookRequest) -> Result<Self, HttpError> {
    BOOK_SUBJECT.check(&[&request.book_id])?;

    Ok(AcquireBook {
      book_id: request.book_id,
      title: request.title,
      author: request.author,
      isbn: request.isbn,
    })
  }
}

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

impl TryFrom<BorrowBookRequest> for BorrowBook {
  type Error = HttpError;

  fn try_from(request: BorrowBookRequest) -> Result<Self, HttpError> {
    BOOK_SUBJECT.check(&[&request.book_id])?;
    if request.borrowed_until.parse::<NaiveDate>().is_err() {
      return Err(HttpError::malformed("borrowedUntil must be a date such as 2026-10-24"));
    }

    Ok(BorrowBook {
      book_id: request.book_id,
      reader_id: request.reader_id,
      borrowed_until: request.borrowed_until,
    })
  }
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ReturnBookRequest {
  pub book_id: String,
}

impl TryFrom<ReturnBookRequest> for ReturnBook {
  type Error = HttpError;

  fn try_from(request: ReturnBookRequest) -> Result<Self, HttpError> {
    BOOK_SUBJECT.check(&[&request.book_id])?;

    Ok(ReturnBook { book_id: request.book_id })
  }
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ListBooksRequest {
  #[serde(default)]
  pub only_available: bool,
}

impl From<ListBooksRequest> for ListBooks {
  fn from(request: ListBooksRequest) -> Self {
    ListBooks { only_available: request.only_available }
  }
}
```

Each request type derives `Deserialize`, with its fields named in camelCase, as in `bookId`, and each conversion implements **`TryFrom`**, which returns the command, or refuses the request with an **`HttpError`**. An `HttpError` says how the API answers, with a status code and a message. The library does not authenticate its users, so the reader who borrows a book is named in the body. For an API with authentication, see **[Setting Up an HTTP API](/docs/architecturekit/rust/setting-up-an-http-api)**.

The conversions are also the place to **validate** a request, since a refusal is answered before the command is executed, and **`HttpError::malformed`** answers with `400 Bad Request` (for refusing with other status codes, see **[Authorizing Commands](/docs/architecturekit/rust/handling-commands-over-http#authorizing-commands)**). They check two things:

- The ID of a book becomes part of a subject, and composing a subject from an empty ID, or from one with a character that a segment of a subject may not contain, such as a slash, a dot, or a space, is a programming error that makes `build` panic. So the conversions first call **`check`** on the scheme of the subject, which returns an error for such an ID instead, and `?` turns it into an `HttpError` with `400 Bad Request` (see **[Composing Subjects](/docs/architecturekit/rust/composing-subjects)**).
- The schema of `BookBorrowed`, which ArchitectureKit derives from the struct, only requires the return date to be a string, so EventSourcingDB would store any text as a return date. Checking the date turns a mistake of the caller into a `400 Bad Request`, before anything is stored.

`ListBooksRequest` does the same for the `ListBooks` query, which can not be refused, so its conversion implements `From`. A query is asked with the HTTP method **`QUERY`**, which changes nothing, like `GET`, but carries a body, like `POST`, so a query takes its input from the body, as a command does. `#[serde(default)]` lets the body leave out whether to list only the available books, which then is `false`. `GetBook` needs no request type, since it reads the ID of the book from the path, as its handler below shows. That ID only looks up an item and never becomes part of a subject, so it does not need to be checked.

## Mapping Items to Responses

The items of the catalog do not derive `Serialize`, since what a caller sees is decided by a query and its answer, not by the view. So the answer over HTTP maps the items to a **response type** that derives `Serialize`, the counterpart of the request types above.

Create a file `src/responses.rs`:

```rust
use architecturekit::InMemoryView;
use architecturekit::httpapi::{HttpError, QueryAnswer};
use serde::Serialize;

use crate::catalog::BookItem;
use crate::queries::{GetBook, ListBooks, get_book, list_books};

#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct BookBody {
  pub id: String,
  pub title: String,
  pub author: String,
  pub is_borrowed: bool,
  pub borrowed_until: String,
}

impl From<BookItem> for BookBody {
  fn from(book: BookItem) -> Self {
    BookBody {
      id: book.id,
      title: book.title,
      author: book.author,
      is_borrowed: book.is_borrowed,
      borrowed_until: book.borrowed_until,
    }
  }
}

pub async fn answer_list_books(
  catalog: &InMemoryView<String, BookItem>,
  query: &ListBooks,
) -> Result<QueryAnswer<Vec<BookBody>>, HttpError> {
  let books = list_books(catalog, query).await?;
  let bodies = books.into_iter().map(BookBody::from).collect();

  Ok(QueryAnswer::new(bodies))
}

pub async fn answer_get_book(
  catalog: &InMemoryView<String, BookItem>,
  query: &GetBook,
) -> Result<QueryAnswer<BookBody>, HttpError> {
  let book = get_book(catalog, query).await?;

  Ok(QueryAnswer::found(book.map(BookBody::from)))
}
```

`answer_list_books` answers the `ListBooks` query with `list_books` from **[Defining a View and a Query](/learn/tutorials/first-steps-with-architecturekit/rust/defining-a-view-and-a-query)**, and turns every item into a `BookBody`. `answer_get_book` does the same for a single book with `get_book`. Both return a **`QueryAnswer`**, which the API answers with its result as JSON: **`QueryAnswer::new`** with the given result, and **`QueryAnswer::found`** with the book, or with `404 Not Found` if there is none. The queries return the error of the view, which can not occur for a view in memory, and which `?` turns into an `HttpError`, as it would for a view that can fail. Since the answer decides what a caller sees, the catalog can change its items without changing the API. For details, see **[Handling Queries over HTTP](/docs/architecturekit/rust/handling-queries-over-http)**.

## Wiring the API

Add the new modules to `src/lib.rs`, which now reads:

```rust
pub mod book;
pub mod catalog;
pub mod commands;
pub mod deciders;
pub mod events;
pub mod queries;
pub mod requests;
pub mod responses;
pub mod store;
```

Replace the content of `src/main.rs` with the following program, which serves the three commands and the two queries on port `8080`:

```rust
use std::error::Error;
use std::sync::Arc;

use architecturekit::httpapi::{
  self, CommandAnswer, DEFAULT_WAIT, HttpError, NoBody, QueryAnswer, Reading, Strict,
};
use architecturekit::{
  CancellationToken, Decider, DomainError, InMemoryView, ProjectionOptions, Store, Subjects,
  wait_caught_up,
};
use axum::Router;
use axum::extract::{Path, State};
use axum::routing::post;
use library::book::Book;
use library::catalog::{BookItem, CatalogProjection, new_catalog};
use library::commands::{AcquireBook, BOOK_SUBJECT, BorrowBook, ReturnBook};
use library::deciders::{acquire_book, borrow_book, return_book};
use library::queries::{GetBook, ListBooks};
use library::requests::{
  AcquireBookRequest, BorrowBookRequest, ListBooksRequest, ReturnBookRequest,
};
use library::responses::{BookBody, answer_get_book, answer_list_books};
use library::store::new_store;
use tokio::net::TcpListener;

struct App {
  store: Store,
  acquire_book: Decider<AcquireBook, Book, DomainError>,
  borrow_book: Decider<BorrowBook, Book, DomainError>,
  return_book: Decider<ReturnBook, Book, DomainError>,
  catalog: Arc<InMemoryView<String, BookItem>>,
}

#[tokio::main]
async fn main() -> Result<(), Box<dyn Error>> {
  let store = new_store().await?;

  let catalog = new_catalog();
  let run = store.start_projection(
    Subjects::tree(BOOK_SUBJECT.root()),
    Arc::new(CatalogProjection::new(Arc::clone(&catalog))),
    CancellationToken::new(),
    ProjectionOptions::named("catalog"),
  );

  wait_caught_up(&[&run]).await?;

  let app = App {
    store,
    acquire_book: acquire_book(),
    borrow_book: borrow_book(),
    return_book: return_book(),
    catalog,
  };

  let router = Router::new()
    .route("/api/acquire-book", post(handle_acquire_book))
    .route("/api/borrow-book", post(handle_borrow_book))
    .route("/api/return-book", post(handle_return_book))
    .route("/api/books", httpapi::query(handle_list_books))
    .route("/api/books/{id}", httpapi::query(handle_get_book))
    .with_state(Arc::new(app));

  let listener = TcpListener::bind("localhost:8080").await?;

  println!("Listening on http://localhost:8080.");
  axum::serve(listener, router).await?;

  Ok(())
}

async fn handle_acquire_book(
  State(app): State<Arc<App>>,
  Strict(request): Strict<AcquireBookRequest>,
) -> Result<CommandAnswer, HttpError> {
  let command = AcquireBook::try_from(request)?;
  let written = app.store.execute(&app.acquire_book, command).await?;

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

async fn handle_borrow_book(
  State(app): State<Arc<App>>,
  Strict(request): Strict<BorrowBookRequest>,
) -> Result<CommandAnswer, HttpError> {
  let command = BorrowBook::try_from(request)?;
  let written = app.store.execute(&app.borrow_book, command).await?;

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

async fn handle_return_book(
  State(app): State<Arc<App>>,
  Strict(request): Strict<ReturnBookRequest>,
) -> Result<CommandAnswer, HttpError> {
  let command = ReturnBook::try_from(request)?;
  let written = app.store.execute(&app.return_book, command).await?;

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

async fn handle_list_books(
  State(app): State<Arc<App>>,
  reading: Reading,
  Strict(request): Strict<ListBooksRequest>,
) -> Result<QueryAnswer<Vec<BookBody>>, HttpError> {
  let query = ListBooks::from(request);

  reading
    .revisioned(&app.catalog, &query, DEFAULT_WAIT, || answer_list_books(&app.catalog, &query))
    .await
}

async fn handle_get_book(
  State(app): State<Arc<App>>,
  reading: Reading,
  Path(id): Path<String>,
  _: NoBody,
) -> Result<QueryAnswer<BookBody>, HttpError> {
  let query = GetBook { book_id: id };

  reading
    .revisioned(&app.catalog, &query, DEFAULT_WAIT, || answer_get_book(&app.catalog, &query))
    .await
}
```

Unlike `catch_up_projection`, **`start_projection`** does not stop once it has applied the stored events. It runs the projection on a task of its own and keeps **observing** new events, so the catalog follows every command the API executes, until the **`CancellationToken`** is canceled, which this program never does. **`ProjectionOptions::named`** gives the projection a name, by which it reports its failures. Since a half-built view would answer wrongly, the program calls **`wait_caught_up`** with the run, which returns once the projection has applied all events that were stored when it started, and only then serves requests. If the projection ends before, `wait_caught_up` returns why, and the program stops. An application with several projections hands over all of their runs at once.

The projection tells the catalog about every event in `seen`, so the catalog knows its **revision**, which the queries need to let callers read their own writes, as described below.

The handlers share an **`App`** with the store, the deciders, and the catalog, which axum hands to every handler as its **state**. Each command is an ordinary **handler** of axum:

- Its extractor **`Strict`** reads the body as JSON into the request type. It answers a request without the header `content-type: application/json` with `415 Unsupported Media Type`, and a body that does not fit the type, also one with a field the type does not know, with `400 Bad Request`.
- The handler turns the request into the command with `try_from`, and executes the command with its decider.
- **`CommandAnswer::new`** answers with the revision the command has written, which is the ID of the last written event.

`?` turns every error on the way into an `HttpError`, which answers it by its category, such as a rejection of the decider with `422 Unprocessable Entity`.

Each query is a handler as well, routed with **`httpapi::query`**, which accepts only the method `QUERY`, and answers any other one with `405 Method Not Allowed`. `handle_list_books` reads its query from the body, as a command does. `handle_get_book` reads the ID of the book from the path, where the route names it `{id}`, and takes **`NoBody`**, which accepts only an empty body or `{}`. Both answer through **`revisioned`** of the extractor **`Reading`**, which reads the revision of the catalog, and calls the function that answers, `answer_list_books` or `answer_get_book`, once the catalog has caught up with the caller, as described below.

Start the API:

```shell
cargo run
```

It prints the following line:

```text
Listening on http://localhost:8080.
```

Open another terminal and ask for all books. A query is sent with the method `QUERY` and its input as JSON in the body, which for all books is an empty object. The catalog has been built from the events of the previous steps:

```shell
curl \
  -X QUERY \
  -H "content-type: application/json" \
  -d '{}' \
  http://localhost:8080/api/books
```

```json
[{"author":"Arthur C. Clarke","borrowedUntil":"2026-10-24","id":"42","isBorrowed":true,"title":"2001 – A Space Odyssey"}]
```

## Sending Commands

The library acquires a second copy of the book, with the ID `43`:

```shell
curl \
  -i \
  -X POST \
  -H "content-type: application/json" \
  -d '{"bookId":"43","title":"2001 – A Space Odyssey","author":"Arthur C. Clarke","isbn":"978-0756906788"}' \
  http://localhost:8080/api/acquire-book
```

The API answers with `200 OK` and the revision it has written, which is the ID of the last written event. Apart from the `date` header, which shows the current time in this and all following responses, it looks like this:

```http
HTTP/1.1 200 OK
content-type: application/json
cache-control: no-store
content-length: 17
date: Fri, 13 Nov 2026 07:05:02 GMT

{"revision":"2"}
```

Now try to lend book `42` to a second reader:

```shell
curl \
  -i \
  -X POST \
  -H "content-type: application/json" \
  -d '{"bookId":"42","readerId":"17","borrowedUntil":"2026-10-31"}' \
  http://localhost:8080/api/borrow-book
```

The decider rejects the command, just as in **[Making Decisions](/learn/tutorials/first-steps-with-architecturekit/rust/making-decisions)**, and the API answers with `422 Unprocessable Entity` and the message of the rejection:

```http
HTTP/1.1 422 Unprocessable Entity
content-type: application/json
cache-control: no-store
content-length: 42
date: Fri, 13 Nov 2026 07:05:02 GMT

{"message":"book 42 is already borrowed"}
```

A request with a return date that is not a date does not even reach the decider:

```shell
curl \
  -i \
  -X POST \
  -H "content-type: application/json" \
  -d '{"bookId":"43","readerId":"17","borrowedUntil":"tomorrow"}' \
  http://localhost:8080/api/borrow-book
```

Its conversion refuses it, and the API answers with `400 Bad Request`:

```http
HTTP/1.1 400 Bad Request
content-type: application/json
cache-control: no-store
content-length: 81
date: Fri, 13 Nov 2026 07:05:02 GMT

{"message":"malformed request: borrowedUntil must be a date such as 2026-10-24"}
```

Every error maps to the status code that matches it. For the complete list, see **[Mapping Errors to Status Codes](/docs/architecturekit/rust/mapping-errors-to-status-codes)**.

## Reading Your Own Writes

The projection applies new events in the background, so the catalog lags a little behind the events that have been written. A query sent right after a command may therefore not see the command's events yet – the view is **[eventually consistent](/concepts/eventual-consistency)**.

To see its own write, a caller sends the revision from the answer in the **`Wait-For-Revision`** header. The query then waits until the catalog has seen that event, for at most five seconds, as `DEFAULT_WAIT` says. Ask for the available books after acquiring book `43`, whose event has the ID `2`:

```shell
curl \
  -i \
  -X QUERY \
  -H "content-type: application/json" \
  -H "Wait-For-Revision: 2" \
  -d '{"onlyAvailable":true}' \
  http://localhost:8080/api/books
```

The response contains book `43`, which is available, but not book `42`, which is borrowed:

```http
HTTP/1.1 200 OK
content-type: application/json
cache-control: private, no-cache
revision: 2
etag: "2-d9777700d4f2af6"
content-length: 115
date: Fri, 13 Nov 2026 07:05:09 GMT

[{"author":"Arthur C. Clarke","borrowedUntil":"","id":"43","isBorrowed":false,"title":"2001 – A Space Odyssey"}]
```

The **`revision`** header tells which revision the answer shows. For how the `etag` header saves a caller from downloading an unchanged answer again, see **[Reading Your Own Writes over HTTP](/docs/architecturekit/rust/reading-your-own-writes-over-http)**.

## Getting a Single Book

To get a single book, put its ID into the path. The query has no other input, so the request has no body. Ask for book `43`:

```shell
curl \
  -i \
  -X QUERY \
  http://localhost:8080/api/books/43
```

The response contains the book as a single object, rather than as a list:

```http
HTTP/1.1 200 OK
content-type: application/json
cache-control: private, no-cache
revision: 2
etag: "2-af5ed19b920bef6c"
content-length: 113
date: Fri, 13 Nov 2026 07:05:09 GMT

{"author":"Arthur C. Clarke","borrowedUntil":"","id":"43","isBorrowed":false,"title":"2001 – A Space Odyssey"}
```

Now ask for a book that does not exist:

```shell
curl \
  -i \
  -X QUERY \
  http://localhost:8080/api/books/99
```

`get_book` returns `None`, which `QueryAnswer::found` answers with `404 Not Found` and the message `not found` (see **[Mapping Errors to Status Codes](/docs/architecturekit/rust/mapping-errors-to-status-codes)**):

```http
HTTP/1.1 404 Not Found
content-type: application/json
cache-control: no-store
content-length: 24
date: Fri, 13 Nov 2026 07:05:09 GMT

{"message":"not found"}
```

## Your Turn

Try a few more requests, and use `Wait-For-Revision` to see their effect on the catalog:

- **Return book `42`**, and then lend it to reader `17`.
- **Return book `43`**, although it is not borrowed. The API answers with `200 OK`, but the revision is empty, since there was nothing to do.
- Send a request **without the `content-type` header** of the examples, so that `curl -d` sends the body as `application/x-www-form-urlencoded`, or with a field the request type does not know, and look at the status codes.
