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

Handling Queries over HTTP

A query is asked with the method QUERY, which HTTP defines in RFC 10008. Like GET, it is safe, so it changes nothing, but like POST, it carries a body, so a query takes its input from the body, as a command does. To answer a query over HTTP, define a request type that derives Deserialize for the body, and a handler of axum that builds the query from it and answers it.

Like the handler of a command, it takes the body with Strict, the user, and whatever else it needs from other extractors. The request type describes the body:

#[derive(Default, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct ListBooksRequest {
  only_available: bool,
  limit: Option<usize>,
}

With #[serde(default)], a field that the body leaves out takes its default, so a caller may send {}, rather than having a missing field refused (see Validating Requests). As for a command, the request type describes the body, and the query is what the application works with, so the query needs no Deserialize. The handler builds the query from the request type, so it may check the input, and add what the body does not hold, such as the user or a value of the path, as handle_borrow_book adds the reader to the command (see Handling Commands over HTTP).

An item describes what the view stores, not what a caller sees (see Defining Views). So to answer with JSON, define a response type that derives Serialize, as the counterpart of the request types that commands use (see Handling Commands over HTTP), and map the items to it. Here, the revision of a book goes along as eventId, so that a caller can send it back as expectedEventId:

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

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

The handler answers the query with the function that reads the view, such as list_books (see Defining Queries), maps what it returns, and wraps the result in a QueryAnswer:

use architecturekit::httpapi::QueryAnswer;

pub async fn handle_list_books(
  State(app): State<App>,
  _: User,
  Strict(request): Strict<ListBooksRequest>,
) -> Result<QueryAnswer<Vec<BookBody>>, HttpError> {
  let query = ListBooks { only_available: request.only_available, limit: request.limit };
  let books = list_books(&app.catalog_projection.catalog, query).await?;

  Ok(QueryAnswer::new(books.into_iter().map(BookBody::from).collect()))
}

The view in memory never fails while it is read, so its error is Infallible, which ? turns into an HttpError that never occurs. A view in a database hands on its error, which ? answers by its category. Then route the handler with the query function of the kit, as get and post of axum route theirs:

use architecturekit::httpapi::query;

let router = Router::new().route("/api/books", query(handle_list_books));

axum names no filter for the method QUERY before its version 0.9, so the route that query makes takes every method, but only QUERY reaches the handler: every other method is answered before it, with 405 Method Not Allowed and the header Allow: QUERY. A handler that answers in a format of its own may use another method, though, for example GET for a download or a CSV export (see Answering Queries in Your Own Format).

The caller sends the input as JSON, as for a command:

curl -X QUERY http://localhost:8080/api/books \
  -H "Authorization: Bearer …" \
  -H "Content-Type: application/json" \
  -d '{"onlyAvailable":true}'

The handler determines the user, and then Strict decodes the body by the same rules as for a command (see Validating Requests), before the handler builds the query. So a body that does not claim to be JSON, that is too large, or that is not valid JSON or does not fit the request type is answered with 415, 413, or 400, and the same message as for a command.

A query without input, such as one that takes everything it needs from the path, takes NoBody instead of Strict, so that the caller sends no body, or {}. As for a command without a body, a request that a browser sends from another origin is then answered with 403 Forbidden (see Handling Commands Without a Body). Here, the handler answers with get_book (see Defining Queries):

pub async fn handle_get_book(
  State(app): State<App>,
  Path(book_id): Path<String>,
  _: User,
  _: NoBody,
) -> Result<QueryAnswer<BookBody>, HttpError> {
  let book = get_book(&app.catalog_projection.catalog, GetBook { book_id }).await?;

  Ok(QueryAnswer::new(BookBody::from(book)))
}
let router = Router::new().route("/api/books/{id}", query(handle_get_book));

QueryAnswer answers with 200 OK and the result as JSON, with Cache-Control: no-store, so that no cache keeps it (see Reading Your Own Writes over HTTP). The result is encoded with serde, so an empty Vec is answered as an empty list, [], an empty map as an empty object, {}, and None as null. A caller gets a list or an object, whether it holds anything or not. A result that can not be encoded, for example a map whose keys are tuples, is a mistake in the code, and is answered with 500 Internal Server Error and logged, like any other internal failure. Errors and panics are answered as for commands (see Handling Commands over HTTP).

Note that the function that reads the view, such as list_books, receives the view and the query, but neither the request nor the user. Whatever it needs from them, the handler puts into the query, which matters once a caller reads its own writes (see Reading Your Own Writes over HTTP).

Answering Queries in Your Own Format#

To answer in a format of your own, return whatever axum can turn into a response, and keep HttpError for the failures. Such a handler may use another method than QUERY, for example GET for a CSV export that a browser can download from a link. A GET request carries no body, so the handler takes its input from the URL instead, with the Query extractor of axum:

use std::fmt::Write;

use axum::{extract::Query, http::header, response::IntoResponse, routing::get};

#[derive(Deserialize)]
pub struct ExportBooksParameters {
  #[serde(default)]
  available: bool,
}

pub async fn handle_export_books(
  State(app): State<App>,
  _: User,
  Query(parameters): Query<ExportBooksParameters>,
) -> Result<impl IntoResponse, HttpError> {
  let query = ListBooks { only_available: parameters.available, limit: None };
  let books = list_books(&app.catalog_projection.catalog, query).await?;

  let mut csv = String::from("id,title,author\n");
  for book in books {
    let _ = writeln!(csv, "{},{},{}", book.id, book.title, book.author);
  }

  Ok(([(header::CONTENT_TYPE, "text/csv")], csv))
}
let router = Router::new().route("/api/books.csv", get(handle_export_books));

A failure is still an HttpError, which answers with the same status codes and messages as for a query in the default format, says Cache-Control: no-store, and logs the same way.

Note that this sketch writes the values as they are. A real export escapes values that contain commas, quotes, or line breaks, for example with the csv crate.

Note that a GET request that takes NoBody passes from any origin, unlike a QUERY request that takes it, since GET must not change anything (see Handling Commands Without a Body).

Asking for Several Items at Once#

Since a QUERY request carries a body, the input of a query may hold more than a URL could, for example a list of books to check at once. Here, the answer tells for every book whether it is available:

use std::collections::HashMap;

use architecturekit::KeyedView;

pub struct CheckAvailability {
  pub book_ids: Vec<String>,
}

pub async fn check_availability<V: KeyedView<str, BookItem>>(
  catalog: &V,
  query: &CheckAvailability,
) -> Result<HashMap<String, bool>, V::Error> {
  let mut is_available = HashMap::with_capacity(query.book_ids.len());
  for book_id in &query.book_ids {
    let book = catalog.get(book_id).await?;
    let _ = is_available.insert(book_id.clone(), book.is_some_and(|book| !book.is_borrowed));
  }

  Ok(is_available)
}

#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CheckAvailabilityRequest {
  book_ids: Vec<String>,
}

pub async fn handle_check_availability(
  State(app): State<App>,
  _: User,
  Strict(request): Strict<CheckAvailabilityRequest>,
) -> Result<QueryAnswer<HashMap<String, bool>>, HttpError> {
  let query = CheckAvailability { book_ids: request.book_ids };
  let is_available = check_availability(&app.catalog_projection.catalog, &query).await?;

  Ok(QueryAnswer::new(is_available))
}
let router = Router::new().route("/api/check-availability", query(handle_check_availability));

Reporting Missing Items#

If the function that reads the view returns SingleError::NoItems, as single does if no item matches, ? answers with 404 Not Found, and the message not found, since nothing says what was not found. The error is logged at level info, as for 401 and 409. That is how handle_get_book above answers a book that does not exist. To look up a single item by its key, hand what get of a KeyedView returns to QueryAnswer::found, which answers None the same way:

pub async fn handle_get_book(
  State(app): State<App>,
  Path(book_id): Path<String>,
  _: User,
  _: NoBody,
) -> Result<QueryAnswer<BookBody>, HttpError> {
  let book = app.catalog_projection.catalog.get(&book_id).await?;

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

single fails with SingleError::TooManyItems if several items match, which ? answers with 500 Internal Server Error, logged at level error, since the caller can not fix it.

To say what was not found, return HttpError::not_found with what is missing. Such an error is written for the caller, so it is the message, here not found: book 42, and it is not logged:

let Some(book) = app.catalog_projection.catalog.get(&book_id).await? else {
  return Err(HttpError::not_found(format!("book {book_id}")));
};