Reading Your Own Writes over HTTP
A caller that sends a query right after a command can have the query wait until the view has caught up with the write of the command.
To let a caller read its own writes over HTTP, take a Reading in the handler, an extractor that reads the headers with which the caller waits and asks. Build the query, and then let the reading answer with its revisioned method. It takes a view that implements Revisioned, whose projection records every event it has seen (see Tracking Revisions), the query, how long to wait at most, and a function that answers the query:
use architecturekit::httpapi::{DEFAULT_WAIT, Reading};
pub async fn handle_list_books(
State(app): State<App>,
_: User,
reading: Reading,
Strict(request): Strict<ListBooksRequest>,
) -> Result<QueryAnswer<Vec<BookBody>>, HttpError> {
let query = ListBooks { only_available: request.only_available, limit: request.limit };
reading
.revisioned(&app.catalog_projection.catalog, &query, DEFAULT_WAIT, || async {
let books = list_books(&app.catalog_projection.catalog, query.clone()).await?;
Ok(QueryAnswer::new(books.into_iter().map(BookBody::from).collect()))
})
.await
}
The query goes into the tag of the answer (see below), and the function that answers hands list_books a clone of it, so its type has to derive Serialize and Clone, here ListBooks. After sending a command, the caller takes the revision from the answer and sends it in the Wait-For-Revision header of the query:
curl -X QUERY http://localhost:8080/api/books \
-H "Authorization: Bearer …" \
-H "Content-Type: application/json" \
-H "Wait-For-Revision: 1" \
-d '{}'
The reading waits until the view has reached this revision, but at most for the given duration, which is five seconds for DEFAULT_WAIT. Then it answers with what the view holds, even if the time has run out. Without the header, it does not wait at all. If the header holds something that is not a revision, the request is answered with 400 Bad Request, as in malformed request: not a revision: "abc". If the view fails while the reading waits, or can not read its revision once it has waited, the request is answered with the error, by its category, for example with 503 Service Unavailable for an error of the category Category::Transient (see Mapping Errors to Status Codes).
Once the view has seen at least one event, the response contains the revision it shows in the Revision header, as well as an ETag header and Cache-Control: private, no-cache. no-cache makes a cache ask again before it hands out the answer, and private keeps shared caches, such as proxies, from keeping it at all. If the caller sends the ETag in the If-None-Match header, asks the same, and the view has not changed since, the request is answered with 304 Not Modified, which carries the same headers, and no body. The function that answers is not called then.
The header is read as HTTP has it: it may hold a list of tags, separated by commas, or *, which stands for any tag. A tag also counts if it is marked as weak, as W/"…", which a proxy does when it compresses the answer.
HTTP has 304 Not Modified for GET and HEAD requests, and for QUERY, which it treats like GET, also when the query asks with a body. Since query routes only QUERY to the handler, a query whose answer has not changed is always answered with 304 Not Modified. Browsers, though, never cache answers to QUERY, so they never send If-None-Match for one. The ETag and 304 Not Modified only help a client that keeps the ETag and sends it itself, such as one of your own.
Every other answer that the kit writes carries no revision, and says Cache-Control: no-store, so that no cache keeps it. That holds for the answer to a command, for every failure, and for a QueryAnswer that a handler returns without revisioned, or whose view has not seen any event yet. Without a word on caching, HTTP would let a cache keep such an answer for a while it picks itself, and hand it out again.
The ETag holds the path and the query string of the request, and the query that was asked, with every field. So two callers get the same ETag only if they ask the same address with equal queries: a query that holds the user, or anything else that tells callers apart, gets an ETag of its own for each of them. That matters as soon as callers share a client that keeps the tags, one after the other, since it asks with the ETag it kept for the one before. The handler builds the query before the reading waits or tells the caller that nothing has changed, so a caller who may not ask is refused first.
This holds as long as the answer depends on nothing but the query and the view. The function that answers is a closure, which could capture anything, so keep it to the query and the view. Three things get past the tag:
- The clock, or anything else outside the events. See Depending on More Than the Read Model.
- Another view. The revision is that of the view handed over, so an answer that also reads from another view does not notice when that one changes.
- Anything else the closure captures, such as the user. It never shows up in the
ETag. Put it into the query instead.
For an answer that depends on more than the query and varying can capture, wait without a tag (see Waiting for a Revision Without a Tag).
A query that serde can not encode as JSON, such as one with a map whose keys are tuples, can not be written into an ETag, and its answer goes without one.
Note that the constants WAIT_FOR_REVISION and REVISION contain the names of the two headers.
Note that for a web framework other than axum, Reading::of takes the same from the parts of a request of the http crate.
Depending on More Than the Read Model#
If an answer depends on more than the view, for example on the current date, put that value into the query, as today below. Since the query is part of the ETag, the ETag changes with it, and revisioned is all it takes:
use chrono::Local;
#[derive(Serialize)]
pub struct ListOverdueBooks {
pub today: String,
}
pub async fn list_overdue_books<V: View<BookItem>>(
catalog: &V,
query: &ListOverdueBooks,
) -> Result<Vec<BookItem>, V::Error> {
catalog
.all()
.try_filter(|book| future::ready(book.is_borrowed && book.borrowed_until < query.today))
.try_collect()
.await
}
pub async fn handle_list_overdue_books(
State(app): State<App>,
_: User,
reading: Reading,
_: NoBody,
) -> Result<QueryAnswer<Vec<BookBody>>, HttpError> {
let query = ListOverdueBooks { today: Local::now().format("%Y-%m-%d").to_string() };
reading
.revisioned(&app.catalog_projection.catalog, &query, DEFAULT_WAIT, || async {
let books = list_overdue_books(&app.catalog_projection.catalog, &query).await?;
Ok(QueryAnswer::new(books.into_iter().map(BookBody::from).collect()))
})
.await
}
If the answer takes such a value from elsewhere instead, for example because it reads the clock itself, call the varying method on the reading as well, with a value that changes whenever the answer would. It becomes part of the ETag as well:
pub async fn handle_list_books_due_today(
State(app): State<App>,
_: User,
reading: Reading,
_: NoBody,
) -> Result<QueryAnswer<Vec<BookBody>>, HttpError> {
let query = ListBooksDueToday;
reading
.varying(Local::now().format("%Y-%m-%d"))
.revisioned(&app.catalog_projection.catalog, &query, DEFAULT_WAIT, || async {
let books = list_books_due_today(&app.catalog_projection.catalog, &query).await?;
Ok(QueryAnswer::new(books.into_iter().map(BookBody::from).collect()))
})
.await
}
Here, list_books_due_today answers like list_overdue_books, but takes the current day from the clock itself, rather than from its query, and ListBooksDueToday is a struct without fields that derives Serialize.
Note that such a value, whether it is part of the query or comes from varying, holds the time only as precisely as the answer depends on it, such as the day or the hour, never as an instant. An instant, such as Local::now() itself, differs on every request, and so does every ETag, so the answer is never 304 Not Modified.
Note that varying matters only for revisioned, since awaiting tags nothing (see below).
Waiting for a Revision Without a Tag#
Some answers depend on more than the query and varying can capture, for example on the instant, on the configuration, or on another view. A tag would then tell a caller that nothing has changed when it has. To let the caller read its own writes all the same, call the awaiting method of the reading instead of revisioned, and answer afterwards. It waits exactly as revisioned does, but tags nothing: the answer carries neither a Revision header nor an ETag, says Cache-Control: no-store, as every answer without a revision does, and is never 304 Not Modified. Its extractors take the request apart before the handler waits, so a body that does not fit is refused at once.
For example, a preview of borrowing a book tells whether the book is available, which the caller wants to see right after returning it, and until when the book would be borrowed, which depends on the current time and on the loan period, which comes from the configuration. So the state of the router holds the loan period as well, as a TimeDelta of the chrono crate:
use chrono::{TimeDelta, Utc};
#[derive(Clone)]
pub struct App {
// ...
pub loan_period: TimeDelta,
}
The handler waits for the revision the caller asks for, and then answers with the book and the loan period:
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
pub struct LoanPreviewBody {
is_available: bool,
borrowed_until: String,
}
pub async fn handle_preview_loan(
State(app): State<App>,
Path(book_id): Path<String>,
_: User,
reading: Reading,
_: NoBody,
) -> Result<QueryAnswer<LoanPreviewBody>, HttpError> {
reading.awaiting(&app.catalog_projection.catalog, DEFAULT_WAIT).await?;
let Some(book) = app.catalog_projection.catalog.get(&book_id).await? else {
return Err(HttpError::not_found(format!("book {book_id}")));
};
Ok(QueryAnswer::new(LoanPreviewBody {
is_available: !book.is_borrowed,
borrowed_until: (Utc::now() + app.loan_period).format("%Y-%m-%d").to_string(),
}))
}
So use revisioned while the answer depends on nothing but the query and the view, plus what varying adds, since the ETag then saves sending an answer that has not changed. Use awaiting for an answer that depends on more, and neither for a query whose caller never needs to read its own writes.
Waiting for a Revision in a Handler of Your Own#
To read its own writes in a handler that answers in another format than JSON, such as the CSV export (see Answering Queries in Your Own Format), call awaiting as well. It waits for the revision the request asks for. Running out of time is not an error: awaiting then stops waiting and returns Ok, so the handler answers with what the view holds. It returns an error if the header holds something that is not a revision, which is answered with 400 Bad Request, or if the view fails while it waits. Determine the caller first, so that nobody can make the server wait without being allowed to ask, which the extractor of the user does before the handler runs:
pub async fn handle_export_books(
State(app): State<App>,
_: User,
reading: Reading,
Query(parameters): Query<ExportBooksParameters>,
) -> Result<impl IntoResponse, HttpError> {
reading.awaiting(&app.catalog_projection.catalog, DEFAULT_WAIT).await?;
// ...
}
Note that such a handler answers with neither an ETag nor 304 Not Modified. Those come with revisioned, which ties the ETag to the query, so that callers never share one by accident. For a query that answers in the default format, awaiting waits the same way (see Waiting for a Revision Without a Tag).