Handling Commands over HTTP
To accept a command over HTTP, define a request type with JSON annotations, and implement the ToCommand function, which receives the user and returns the command:
type borrowBookRequest struct {
BookID string `json:"bookId"`
BorrowedUntil string `json:"borrowedUntil"`
ExpectedEventID string `json:"expectedEventId"`
}
func (r borrowBookRequest) ToCommand(user User) (BorrowBook, error) {
return BorrowBook{
BookID: r.BookID,
ReaderID: user.ID,
BorrowedUntil: r.BorrowedUntil,
ExpectedEventID: r.ExpectedEventID,
}, nil
}
Then call the Route function with the request type, the API, the mux, a pattern, and the decider:
httpapi.Route[borrowBookRequest](api, mux, "POST /api/borrow-book", borrowBook)
The route decodes the request body, builds the command, and executes it:
curl -X POST http://localhost:8080/api/borrow-book \
-H "Content-Type: application/json" \
-d '{"bookId":"42","borrowedUntil":"2026-10-24","expectedEventId":"0"}'
If this succeeds, it answers with 200 OK and the IDs of the written events:
{ "eventIds": [ "1" ], "message": "ok" }
Otherwise, it answers with the status code that matches the error (see Mapping Errors to Status Codes) and the error message. For status codes of 500 and above, the message is internal server error.
To answer this way in a handler of your own, call the Respond function with the response writer, the written events, and the error.
Answering Commands in Your Own Format#
To answer in a format of your own, call the Handle function in a handler of your own. It does the same as a route, but writes nothing to the response. Instead, it returns a Handled value with the command it has built and the written events.
This allows you to answer with something that ToCommand has generated, for example the ID of a new book:
type acquireBookRequest struct {
Title string `json:"title"`
Author string `json:"author"`
ISBN string `json:"isbn"`
}
func (r acquireBookRequest) ToCommand(user User) (AcquireBook, error) {
return AcquireBook{
BookID: rand.Text(),
Title: r.Title,
Author: r.Author,
ISBN: r.ISBN,
}, nil
}
mux.HandleFunc("POST /api/acquire-book", func(w http.ResponseWriter, r *http.Request) {
handled, err := httpapi.Handle[acquireBookRequest](r, api, acquireBook)
if err != nil {
httpapi.Respond(w, nil, err)
return
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
json.NewEncoder(w).Encode(map[string]string{
"id": handled.Command.BookID,
})
})
Note that Handled contains the command even if executing it fails.
Authorizing Commands#
To refuse a command, return httpapi.ErrForbidden from ToCommand. The request is then answered with 403 Forbidden, and the command is not executed:
func (r acquireBookRequest) ToCommand(user User) (AcquireBook, error) {
if !user.IsLibrarian {
return AcquireBook{}, httpapi.ErrForbidden
}
return AcquireBook{
BookID: rand.Text(),
Title: r.Title,
Author: r.Author,
ISBN: r.ISBN,
}, nil
}
The same applies to the other errors of the httpapi package, such as httpapi.ErrNotFound, and to errors of the category ErrDomain: they keep their status code. Any other error returned from ToCommand is answered with 400 Bad Request.
Validating Requests#
Before a request reaches ToCommand, it is validated:
- The
Content-Typeheader must beapplication/json, otherwise the request is answered with415 Unsupported Media Type, and the error ishttpapi.ErrUnsupportedMediaType. - The body must not be larger than
httpapi.MaxRequestBody, which is one mebibyte, otherwise the request is answered with413 Request Entity Too Large, and the error ishttpapi.ErrTooLarge. - The body must be valid JSON without unknown fields, otherwise the request is answered with
400 Bad Request, and the error ishttpapi.ErrMalformed.