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 with JSON annotations for the body, and a function that returns the query.
Like the function that returns a command, it receives the request, the body, decoded into the request type, and the user:
type listBooksRequest struct {
OnlyAvailable bool `json:"onlyAvailable"`
Limit int `json:"limit"`
}
func toListBooks(r *http.Request, request listBooksRequest, user User) (ListBooks, error) {
return ListBooks{
OnlyAvailable: request.OnlyAvailable,
Limit: request.Limit,
}, nil
}
As for a command, the request type describes the body, and the query is what the application works with, so the query needs no JSON annotations. The function 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 toBorrowBook adds the reader to the command (see Handling Commands over HTTP).
Items carry no JSON annotations (see Defining Views). So to answer with JSON, define a response type with JSON annotations, 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:
type bookBody struct {
ID string `json:"id"`
Title string `json:"title"`
Author string `json:"author"`
IsBorrowed bool `json:"isBorrowed"`
BorrowedUntil string `json:"borrowedUntil"`
EventID string `json:"eventId"`
}
func bookBodyOf(book BookItem) bookBody {
return bookBody{
ID: book.ID,
Title: book.Title,
Author: book.Author,
IsBorrowed: book.IsBorrowed,
BorrowedUntil: book.BorrowedUntil,
EventID: book.EventID,
}
}
To answer with such bodies, wrap the function that answers the query with items, such as listBooks (see Defining Queries), and map what it returns. Since every query that lists books returns items, one wrapper serves all of them:
func answerBooks[TQuery any](ask httpapi.Answer[TQuery, []BookItem]) httpapi.Answer[TQuery, []bookBody] {
return func(ctx context.Context, q TQuery) ([]bookBody, error) {
books, err := ask(ctx, q)
if err != nil {
return nil, err
}
bodies := make([]bookBody, 0, len(books))
for _, book := range books {
bodies = append(bodies, bookBodyOf(book))
}
return bodies, nil
}
}
Then call the Query function with the API, the mux, a pattern, the function that returns the query, and the function that answers it:
httpapi.Query(api, mux, "QUERY /api/books", toListBooks, answerBooks(listBooks(catalog)))
The pattern names the method QUERY. A pattern without a method, which accepts every method, or with another method, makes Query panic. net/http does not name the method yet, so the constant httpapi.MethodQuery does, as http.MethodGet names GET, for example for a request in a test. A handler of your own that answers with Ask 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 "Content-Type: application/json" \
-d '{"onlyAvailable":true}'
The route determines the user, and then decodes the body by the same rules as for a command (see Validating Requests), before it calls the function that returns 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, has the request type httpapi.NoBody, 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):
func toGetBook(r *http.Request, _ httpapi.NoBody, user User) (GetBook, error) {
return GetBook{BookID: r.PathValue("id")}, nil
}
The route 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 as encoding/json encodes it, except that a nil slice is answered as an empty list, [], and a nil map as an empty object, {}, at every depth, such as the nil slice that query.Collect returns when there are no items, or a field of a response type that holds a nil slice. So a caller gets a list or an object, whether it holds anything or not. A nil pointer, on the other hand, is answered as null. A result that can not be encoded, for example because it holds NaN, 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, and errors returned from the first function are treated as they are from the function that returns a command (see Authorizing Commands).
To answer this way in a handler of your own, call the RespondResult function with the response writer, the request, the API, the result, and the error. As with Respond, an error without a status code of its own is answered with 500 Internal Server Error, so wrap a mistake in the request that the handler finds itself with httpapi.ErrMalformed (see Handling Commands over HTTP). Like the route, it answers a result that can not be encoded with 500 Internal Server Error, also if a MarshalJSON function panics while it is encoded, and logs the panic with its value and its stack, rather than leaving net/http to close the connection without an answer. To answer an error without a result, call the RespondError function (see Answering Queries in Your Own Format).
Note that a nil slice of bytes is answered as an empty string, "", since a slice of bytes is encoded as a string in base64, and that a nil json.RawMessage is answered as null, since it holds JSON text.
Note that the functions have the types httpapi.ToQuery and httpapi.Answer. The answering function receives neither the request nor the user.
Note that calling Query with nil as the API, for either function, or as one of the options, panics, rather than failing every request.
Answering Queries in Your Own Format#
To answer in a format of your own, call the Ask function in a handler of your own. It does the same as a route, but writes nothing to the response. Instead, it returns the result. 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 its function has the request type httpapi.NoBody, and takes the input from the URL instead:
toExportBooks := func(r *http.Request, _ httpapi.NoBody, user User) (ListBooks, error) {
return ListBooks{
OnlyAvailable: r.URL.Query().Get("available") == "true",
}, nil
}
mux.HandleFunc("GET /api/books.csv", func(w http.ResponseWriter, r *http.Request) {
books, err := httpapi.Ask(r, api, toExportBooks, listBooks(catalog))
if err != nil {
httpapi.RespondError(w, r, api, err)
return
}
w.Header().Set("Content-Type", "text/csv")
// ...
})
To answer an error the way a route does, call the RespondError function with the response writer, the request, the API, and the error, as above. It answers with the same status codes and messages as RespondResult, says Cache-Control: no-store, and logs the same way, but needs no result. As with RespondResult, an error without a status code of its own is answered with 500 Internal Server Error.
Note that a GET request without a body passes from any origin, unlike a QUERY request without one, since GET must not change anything (see Handling Commands Without a Body).
Note that calling RespondError with nil as the API, or with a nil error, panics.
Note that Ask returns a panic as an error, as Handle does (see Answering Commands in Your Own Format).
Note that calling Ask with nil as the API, or for either function, panics, as with Query, but on every request, even one whose caller is unknown. That panic, too, comes back as an error, which names the mistake.
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:
type CheckAvailability struct {
BookIDs []string
}
func checkAvailability(catalog architecturekit.KeyedView[string, BookItem]) func(context.Context, CheckAvailability) (map[string]bool, error) {
return func(ctx context.Context, q CheckAvailability) (map[string]bool, error) {
isAvailable := make(map[string]bool, len(q.BookIDs))
for _, bookID := range q.BookIDs {
book, isFound, err := catalog.Get(ctx, bookID)
if err != nil {
return nil, err
}
isAvailable[bookID] = isFound && !book.IsBorrowed
}
return isAvailable, nil
}
}
type checkAvailabilityRequest struct {
BookIDs []string `json:"bookIds"`
}
func toCheckAvailability(r *http.Request, request checkAvailabilityRequest, user User) (CheckAvailability, error) {
return CheckAvailability{BookIDs: request.BookIDs}, nil
}
httpapi.Query(api, mux, "QUERY /api/check-availability", toCheckAvailability, checkAvailability(catalog))
Reporting Missing Items#
If the answering function returns query.ErrNoItems, as query.Single does if no item matches, the request is answered with 404 Not Found, and the message not found, since the text of query.ErrNoItems, no items, does not say what was not found. The error is logged at level Info, as for 401 and 409. So a wrapper that maps a single item hands on the error as it is:
func answerBook[TQuery any](ask httpapi.Answer[TQuery, BookItem]) httpapi.Answer[TQuery, bookBody] {
return func(ctx context.Context, q TQuery) (bookBody, error) {
book, err := ask(ctx, q)
if err != nil {
return bookBody{}, err
}
return bookBodyOf(book), nil
}
}
httpapi.Query(api, mux, "QUERY /api/books/{id}", toGetBook, answerBook(getBook(catalog)))
Here, toGetBook is the function of a query without input, whose request type is httpapi.NoBody (see above).
To report a missing item yourself, return httpapi.ErrNotFound, or an error that wraps it, such as fmt.Errorf("%w: book %s", httpapi.ErrNotFound, q.BookID). Such an error is written for the caller, so it is the message, here not found: book 42.