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, hand over the Revisioned option to Query, with a view that implements Revisioned, whose projection is tracked (see Tracking Revisions), and how long to wait at most:
httpapi.Query(api, mux, "QUERY /api/books", toListBooks, answerBooks(listBooks(catalog)),
httpapi.Revisioned(catalog, httpapi.DefaultWait),
)
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 "Content-Type: application/json" \
-H "Wait-For-Revision: 1" \
-d '{}'
The route waits until the view has reached this revision, but at most for the given duration, which is five seconds for httpapi.DefaultWait. 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. If the view can not read its revision once it has waited, the request is answered with the error, as with any other error, for example with 503 Service Unavailable for an error of the category ErrTransient (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 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 accepts only QUERY, 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 the answer of a query without Revisioned, or of one 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 query that was asked, with every field. So two callers get the same ETag only if their queries are equal: 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 query is built before the route 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, which is why the answer sees neither the request nor the user. Three things get past it:
- 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.
- The context. A value that a middleware puts into the context, such as the user, 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 holds a function, a channel, or an unsafe pointer, or a value that contains itself, can not be written into an ETag, and its answer goes without one.
Note that the constants httpapi.HeaderWaitFor and httpapi.HeaderRevision contain the names of the two headers.
Note that Revisioned panics for a nil view or a negative wait, and so does giving it twice, or along with Awaiting. A nil pointer counts as a nil view, such as a view that was declared but never created with NewInMemoryView.
Depending on More Than the Read Model#
If an answer depends on more than the view, for example on the current date, the simplest way is to 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:
type ListOverdueBooks struct {
Today string
}
func listOverdueBooks(catalog architecturekit.View[BookItem]) func(context.Context, ListOverdueBooks) ([]BookItem, error) {
return func(ctx context.Context, q ListOverdueBooks) ([]BookItem, error) {
return query.Collect(query.Where(catalog.All(ctx), func(item BookItem) bool {
return item.IsBorrowed && item.BorrowedUntil < q.Today
}))
}
}
httpapi.Query(api, mux, "QUERY /api/overdue-books",
func(r *http.Request, _ httpapi.NoBody, user User) (ListOverdueBooks, error) {
return ListOverdueBooks{Today: time.Now().Format(time.DateOnly)}, nil
},
answerBooks(listOverdueBooks(catalog)),
httpapi.Revisioned(catalog, httpapi.DefaultWait),
)
If the answer takes such a value from elsewhere instead, for example because it reads the clock itself, hand over the Varying option as well, with a function of the type httpapi.Volatile. It receives the request and returns a value that changes whenever the answer would, and that becomes part of the ETag as well:
func today(*http.Request) string {
return time.Now().Format(time.DateOnly)
}
httpapi.Query(api, mux, "QUERY /api/books-due-today", toListBooksDueToday, answerBooks(listBooksDueToday(catalog)),
httpapi.Revisioned(catalog, httpapi.DefaultWait),
httpapi.Varying(today),
)
Here, listBooksDueToday answers like listOverdueBooks, but takes the current day from time.Now itself, rather than from its query, and toListBooksDueToday has the request type httpapi.NoBody and returns that query.
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 time.Now() itself, differs on every request, and so does every ETag, so the answer is never 304 Not Modified.
Note that Varying panics for nil, and so does giving it twice, or without Revisioned.
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, hand over the Awaiting option instead of Revisioned. 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. As with Revisioned, the query is built before the route 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:
type PreviewLoan struct {
BookID string
}
type loanPreviewBody struct {
IsAvailable bool `json:"isAvailable"`
BorrowedUntil string `json:"borrowedUntil"`
}
func previewLoan(catalog architecturekit.KeyedView[string, BookItem], loanPeriod time.Duration) func(context.Context, PreviewLoan) (loanPreviewBody, error) {
return func(ctx context.Context, q PreviewLoan) (loanPreviewBody, error) {
book, isFound, err := catalog.Get(ctx, q.BookID)
if err != nil {
return loanPreviewBody{}, err
}
if !isFound {
return loanPreviewBody{}, fmt.Errorf("%w: book %s", httpapi.ErrNotFound, q.BookID)
}
return loanPreviewBody{
IsAvailable: !book.IsBorrowed,
BorrowedUntil: time.Now().Add(loanPeriod).Format(time.DateOnly),
}, nil
}
}
httpapi.Query(api, mux, "QUERY /api/books/{id}/loan-preview", toPreviewLoan, previewLoan(catalog, loanPeriod),
httpapi.Awaiting(catalog, httpapi.DefaultWait),
)
Here, toPreviewLoan has the request type httpapi.NoBody, and takes the ID of the book from the path.
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.
Note that Awaiting panics for a nil view or a negative wait, and so does giving it twice, or along with Revisioned. A nil pointer counts as a nil view, such as a view that was declared but never created with NewInMemoryView. Since only a query with Revisioned has a tag, Varying panics along with Awaiting as well.
Waiting for a Revision in a Handler of Your Own#
To read its own writes in a handler of your own, for example one that answers in another format than JSON, call the Await function with the request, the view, and how long to wait at most. It waits for the revision the request asks for, within the context of the request. Running out of time is not an error, and neither is the end of the context of the request, for example because the caller went away: in both cases, Await stops waiting and returns nil, so the handler answers with what the view holds. It returns an error if the header holds something that is not a revision, which wraps httpapi.ErrMalformed as well as architecturekit.ErrNotARevision, or if waiting fails for another reason. Determine the caller first, so that nobody can make the server wait without being allowed to ask:
mux.HandleFunc("GET /api/books.csv", func(w http.ResponseWriter, r *http.Request) {
if _, err := httpapi.UserOf(r, api); err != nil {
httpapi.RespondError(w, r, api, err)
return
}
if err := httpapi.Await(r, catalog, httpapi.DefaultWait); err != nil {
httpapi.RespondError(w, r, api, err)
return
}
// ...
})
Note that such a handler answers with neither an ETag nor 304 Not Modified. Those come with the Revisioned option of Query, which ties the ETag to the query, so that callers never share one by accident. For a query that answers in the default format, the Awaiting option of Query waits the same way (see Waiting for a Revision Without a Tag).