Docs / ArchitectureKit 2.0 / Guides / Handling Commands over HTTP

Handling Commands over HTTP

To accept a command over HTTP, define a request type with JSON annotations for the body, and a function that returns the command. It receives the request, the body, decoded into the request type, and the user, as the function that returns a query does (see Handling Queries over HTTP). So it takes from the request what the body does not hold, such as a value of the path, a header, or the context.

For example, for the BorrowBook that checks the revision of the caller (see Checking the Revision of the Caller), with the ID of the book in the path:

type borrowBookRequest struct {
  BorrowedUntil   string `json:"borrowedUntil"`
  ExpectedEventID string `json:"expectedEventId"`
}

func toBorrowBook(r *http.Request, request borrowBookRequest, user User) (BorrowBook, error) {
  bookID := r.PathValue("id")
  if err := bookSubject.Check(bookID); err != nil {
    return BorrowBook{}, err
  }
  if _, err := time.Parse(time.DateOnly, request.BorrowedUntil); err != nil {
    return BorrowBook{}, errors.New("borrowedUntil must be a date")
  }
  if _, err := architecturekit.ParseRevision(request.ExpectedEventID); err != nil {
    return BorrowBook{}, errors.New("expectedEventId must be an event ID")
  }

  return BorrowBook{
    BookID:          bookID,
    ReaderID:        user.ID,
    BorrowedUntil:   request.BorrowedUntil,
    ExpectedEventID: request.ExpectedEventID,
  }, nil
}

The function is the place to validate a request, since an error it returns is answered with 400 Bad Request, unless it has a status code of its own (see Authorizing Commands). Check at least what would otherwise fail later: the ID of the book becomes part of a subject, and Build panics on an empty ID or one with a character that a segment of a subject may not contain, such as a slash or a dot (see Composing Subjects), which is answered with 500 Internal Server Error. A value of the path is no exception, since it may hold a slash, sent as %2F. And a value that does not match the schema of its event is refused by the database, which is a permanent failure answered with 500 Internal Server Error – although it is the caller's mistake. An expected event ID that is empty or not an event ID at all is answered with 400 Bad Request without a check of your own, since OnEventID refuses it before anything is read (see Checking the Revision of the Caller). Checking it here lets the message name the field, though. ParseRevision refuses both, unlike CompareRevisions, which takes an empty revision for the one of a view that has seen nothing (see Comparing Revisions).

Then call the Route function with the API, the mux, a pattern, the function that returns the command, and the decider:

httpapi.Route(api, mux, "POST /api/books/{id}/borrow", toBorrowBook, borrowBook)

A command changes something, so the pattern names a method that may do so, usually POST. A pattern without a method, such as /api/books/{id}/borrow, accepts every method, GET included, and GET, HEAD, OPTIONS, and QUERY must not change anything. A browser sends GET and HEAD from another site without asking, for example for a link that the user follows, along with the cookies of the user, and a route that takes no body lets GET, HEAD, and OPTIONS pass from another origin (see Handling Commands Without a Body). QUERY may change nothing either, since HTTP defines it as safe and repeatable, so a client or a cache may send it again. So Route panics for a pattern without a method, or with one of these, rather than executing the command for any site that links to it, or whenever a query is sent again.

The route decodes the request body, builds the command, and executes it:

curl -X POST http://localhost:8080/api/books/42/borrow \
  -H "Content-Type: application/json" \
  -d '{"borrowedUntil":"2026-10-24","expectedEventId":"0"}'

If this succeeds, it answers with 200 OK and the revision it has written, which is the ID of the last written event:

{ "revision": "1" }

The revision is empty if the command did not write anything. A caller hands it to a query to read its own writes (see Reading Your Own Writes over HTTP).

Otherwise, it answers with the status code that matches the error (see Mapping Errors to Status Codes) and a message:

{ "message": "book 42 is already borrowed" }

The message is the error message if the error is written for the caller, such as a broken business rule or what is wrong with the request. Other errors may name internals, such as the key a token failed to verify with, or the subject a precondition guarded, so their message is fixed:

Status code Message
401 Unauthorized unauthorized
404 Not Found, for a query that found no item not found
409 Conflict conflict: the data has changed since it was read
499 Client Closed Request request canceled
500 Internal Server Error, for a write of the command whose outcome is unknown outcome unknown: the request may have succeeded
500 Internal Server Error, for a view that did not catch up with a write after the command the request succeeded, but its result is not visible yet
500 and above internal server error

The actual error is logged, so that it does not vanish (see Setting Up an HTTP API): at level Info for 401, 404, and 409, since the server did not fail, and at level Error for 500 and above. For 404, that holds only for a query that found no item (see Reporting Missing Items). A request that was canceled, usually because the caller went away, is not logged, since nothing failed.

If handling a request panics, for example because Build received an ID that was not checked, the route answers with 500 Internal Server Error and the message internal server error, like any other internal failure, and logs the panic at level Error, with its value and its stack. Otherwise, net/http would close the connection, and the caller would get no answer at all.

Note that a panic with http.ErrAbortHandler is passed on, since net/http expects it to abort the response.

To answer this way in a handler of your own, call the Respond function with the response writer, the request, the API, the written events, and the error. Like the route, it logs through the logger of the API, with the route of the request. Unlike the route, it does not know where an error comes from, so an error without a status code of its own is answered with 500 Internal Server Error, also if the handler has found a mistake in the request itself. Wrap such an error with httpapi.ErrMalformed, for example with fmt.Errorf("%w: %w", httpapi.ErrMalformed, err), to answer it with 400 Bad Request.

Note that the function has the type httpapi.ToCommand. The request type only describes the body, so it may come from another package, for example one that the application shares with its clients.

Note that calling Route with nil as the API, as the function, or as one of the options, or with a Decider that was not created with NewDecider, panics, rather than failing every request.

Handling Commands Without a Body#

Some commands take everything they need from the path, such as ReturnBook, which only needs the ID of the book. Such a command takes no body, so its function has the request type httpapi.NoBody:

func toReturnBook(r *http.Request, _ httpapi.NoBody, user User) (ReturnBook, error) {
  bookID := r.PathValue("id")
  if err := bookSubject.Check(bookID); err != nil {
    return ReturnBook{}, err
  }

  return ReturnBook{BookID: bookID}, nil
}

httpapi.Route(api, mux, "POST /api/books/{id}/return", toReturnBook, returnBook)

The route then expects no body, so the caller sends neither a Content-Type header nor a body:

curl -X POST http://localhost:8080/api/books/42/return

A body of {} is accepted as well, whatever the Content-Type header says, so that a caller that sends one out of habit keeps working. Any other body is answered with 400 Bad Request, and the message says that the route takes no body.

Requiring application/json is what keeps a browser from sending a command from another site without asking the server first, since a form can not send JSON. A route without a body can not rely on that, so before it looks at the body, it checks where the request comes from, with the CrossOriginProtection of net/http. A request that a browser sends from another origin, which the Sec-Fetch-Site header says, or, without it, an Origin header whose host differs from the Host header, is answered with 403 Forbidden, and the error is httpapi.ErrForbidden. A request from the same origin passes, and so does one without these headers, such as one of curl or of another server.

Note that this also refuses a browser frontend that runs on another origin than the API, even on another subdomain, so such a frontend can not call a route without a body for now.

The check lets a request with GET, HEAD, or OPTIONS pass from any origin, since these methods must not change anything. That is why Route refuses a pattern with one of them, or without a method, which accepts every method (see above). Wire a handler of your own that executes a command to a method that may change something as well, such as POST.

Adding to the Answer#

To answer with more than the revision, for example with the ID that toAcquireBook below makes up for a new book, hand over the Adding option. It takes a function that receives a Handled value with the command and the written events, and returns the fields to add, usually as a struct with JSON annotations, and an error:

type acquireBookRequest struct {
  Title  string `json:"title"`
  Author string `json:"author"`
  ISBN   string `json:"isbn"`
}

func toAcquireBook(r *http.Request, request acquireBookRequest, user User) (AcquireBook, error) {
  return AcquireBook{
    BookID: rand.Text(),
    Title:  request.Title,
    Author: request.Author,
    ISBN:   request.ISBN,
  }, nil
}

httpapi.Route(api, mux, "POST /api/acquire-book", toAcquireBook, acquireBook,
  httpapi.Adding(func(handled httpapi.Handled[AcquireBook]) (any, error) {
    return struct {
      ID string `json:"id"`
    }{handled.Command.BookID}, nil
  }))

The route then answers with both:

{ "id": "…", "revision": "1" }

The function is only called if the command has succeeded. A value that encodes to null adds no fields. That is nil, and also a nil pointer of a concrete type, which an interface does not count as nil, such as the one that return findShelf(handled) hands back if findShelf returns a *Shelf and an error, and fails. The fields are encoded as the result of a query is, so a nil slice or map in them is answered as [] or {} (see Handling Queries over HTTP).

If the function returns an error, the events are written all the same, so the route still answers with 200 OK and the revision, which the caller needs to read its own writes, and must not take for a reason to send the command again, whatever is wrong with the fields the function returned along with the error. The answer then holds these fields if they can be used, and none otherwise. The error is logged through the logger of the API, with the route, and so is why the fields were dropped, if they were.

The kit adds the revision itself, so the fields must not contain one, and they must encode to a JSON object, so they must not hold NaN, for example, which JSON has no number for. Otherwise, unless the function has returned an error, the route answers with 500 Internal Server Error and logs why, although the events have been written, since that is a mistake in the code rather than something that happens at runtime.

Note that the written events are available in Handled as well. Add them only deliberately: they are the inner model of the application, every caller that reads them depends on their shape, and they may contain data that is not meant for the caller.

Note that calling Adding with nil, or giving it twice, panics.

Answering Commands in Your Own Format#

To answer in a format of your own, for example with another status code, 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:

mux.HandleFunc("POST /api/acquire-book", func(w http.ResponseWriter, r *http.Request) {
  handled, err := httpapi.Handle(r, api, toAcquireBook, acquireBook)
  if err != nil {
    httpapi.Respond(w, r, api, 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.

Note that Handle returns a panic as an error, which StatusFor maps to 500, and which Respond logs with the value and the stack of the panic.

Note that calling Handle with nil as the API or as the function, or with a Decider that was not created with NewDecider, panics, as with Route, but on every request, even one whose caller is unknown. That panic, too, comes back as an error, which names the mistake.

Authorizing Commands#

To refuse a command, return httpapi.ErrForbidden from the function that returns it. The request is then answered with 403 Forbidden, and the command is not executed:

func toAcquireBook(r *http.Request, request acquireBookRequest, user User) (AcquireBook, error) {
  if !user.IsLibrarian {
    return AcquireBook{}, httpapi.ErrForbidden
  }

  return AcquireBook{
    BookID: rand.Text(),
    Title:  request.Title,
    Author: request.Author,
    ISBN:   request.ISBN,
  }, nil
}

The same applies to every error that has a status code of its own (see Mapping Errors to Status Codes), such as httpapi.ErrNotFound or an error of the category ErrDomain, and to an error of the category ErrPermanent, one of ErrOutcomeUnknown, or one of ErrNotCaughtUp, which are answered with 500 Internal Server Error and the message internal server error, since the command has not run. So if the function looks something up in another service, with the context of the request, and that service is down, it returns an error of the category ErrTransient. The request is then answered with 503 Service Unavailable, and the failure is logged, rather than blaming the request.

Any other error returned from the function is answered with 400 Bad Request, with the error as the message, after the text of httpapi.ErrMalformed, as in malformed request: borrowedUntil must be a date. In Handle, it wraps httpapi.ErrMalformed as well as the original error, so that errors.Is and errors.As find either.

Validating Requests#

Before the function that returns the command receives the body, the request is validated, as it is for a query (see Handling Queries over HTTP):

  • The Content-Type header must be application/json, otherwise the request is answered with 415 Unsupported Media Type, and the error is httpapi.ErrUnsupportedMediaType.
  • The body must not be larger than httpapi.MaxRequestBody, which is one mebibyte, otherwise the request is answered with 413 Request Entity Too Large, and the error is httpapi.ErrTooLarge. The same goes for a body that a middleware cuts off at a lower limit with http.MaxBytesReader.
  • The body must be a single valid JSON value without unknown fields, otherwise the request is answered with 400 Bad Request, and the error is httpapi.ErrMalformed, which wraps the error of decoding, so that errors.As finds it. Nothing but whitespace may follow the value, and no name may occur twice in an object. Names match fields regardless of case, as with encoding/json, so two names that match the same field count as the same name, even if they differ in case. The body must not be null, which encoding/json would turn into the zero value of the request type, unless the request type decodes itself, and so decides itself what null means, or is an interface, which takes null for nil, while null for a field still leaves the field as it is. And an array must have as many elements as the Go array it is decoded into, such as a [2]int, which encoding/json would otherwise fill up with zeros, or cut off.

Note that parsers disagree on what a name that occurs twice means, and on data after the value: one takes the first value, another the last, and one stops after the value, while another reads on. A filter or a proxy in front of the application might then check another value than the one the application uses, which is why both are refused.

Changed in 2.0If the body can not be decoded, the message says what is wrong in words of its own, rather than in those of the decoder, which name the types of Go. After malformed request: , it says, for example, empty body, invalid JSON, unknown field "borowedUntil", duplicate field "expectedEventId", "borrowedUntil" must be a string, the body must be an object, for a body of null as well, or "pair" must have 2 elements. A nested value is named by its path, such as "items.0.bookId". For a failure that it has no words of its own for, such as a value of a type that JSON has no kind for, it says that the value can not be decoded, as in "rating" can not be decoded, or the body can not be decoded. A body that can not even be read, for example because a middleware fails to decompress it, gives the body could not be read, without the words of the middleware. The error of a type that decodes itself, on the other hand, keeps its own text, as it is, also for a type of a library, such as netip.Addr, as long as it can tell its text. If its Error function panics, as that of a *json.UnmarshalTypeError without a type does, the message says that the value can not be decoded instead. For the full list, see the documentation of BodyOf.

If the request type is httpapi.NoBody, the request is validated differently (see Handling Commands Without a Body): a request that a browser sends from another origin is answered with 403 Forbidden first, and the error is httpapi.ErrForbidden. The Content-Type header is not required, and the body must be empty or {}, otherwise the request is answered with 400 Bad Request, and the error is httpapi.ErrMalformed. A body larger than httpapi.MaxRequestBody is still answered with 413 Request Entity Too Large.

To read a body by the same rules in a handler of your own, call the BodyOf function with the request type as its type parameter, as in httpapi.BodyOf[borrowBookRequest](r). With httpapi.NoBody, it checks that a request has no body, as the route does.