I'm Waving Goodbye to REST
Generally when people look to leave REST, they jump to GraphQL. This is a terrible idea for most orgs (especially small ones) -- the complexity of optimizing GraphQL with a database is a pain you'll feel forever, whereas REST makes it easy to ensure every endpoint performs well.
Instead, I want to define a new approach similar to REST, but removing its most annoying footgun: dynamic paths. RESTful routes are unnecessarily complex to build and use because they duplicate state in the URL and the request body.
For instance in the request PUT /customers/1/addresses/1
, which would have a body in the format of:
{
"id": 1,
"customerId": 1,
"street1": "100 Penn Ave",
"city": "Los Angeles",
…
}
We can see that `customerID` and `addressID` exist in the URL *and* the request body. This is duplicated state which introduces the opportunity for bugs and confusing behavior.
What should we do if I pass a `customerID` of 1 in the URL path, but a `customerID` of 2 in the request body? Is that an error? Should we overwrite one for the other, and if so, which one should have precendence? It's unclear to users of the API and would have to exist only in documentation.
The code for the backend to handle this is much worse as well, as you need to enforce this logic. For instance, here is how we could get the IDs from the URL and validate that they match any IDs provided in the request body.
func (x *Router) putCustomerAddress(
w http.ResponseWriter,
r *http.Request,
) (any, error) {
customerID, err := strconv.ParseInt(chi.URLParam(r, "customerID"), 10,
64)
if err != nil {
return nil, fmt.Errorf("parse int customer id: %w", err)
}
addressID, err := strconv.ParseInt(chi.URLParam(r, "addressID"), 10,
64)
if err != nil {
return nil, fmt.Errorf("parse int address id: %w", err)
}
var params store.UpdateCustomerAddressParams
if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
if params.CustomerID != 0 && params.CustomerID != customerID {
return nil, errors.New("mismatched customer ids")
}
if params.AddressID != 0 && params.AddressID != addressID {
return nil, errors.New("mismatched address ids")
}
params.CustomerID = customerID
params.AddressID = addressID
return x.db.UpdateCustomerAddress(r.Context(), params)
}
Instead, let's remove the duplicated state and ensure that IDs are only passed in one, easy-to-access place. If we remove the IDs from the path and wave goodbye to REST, we have clean, concise, simple, and consistent code across endpoints:
func (x *Router) putCustomerAddress(
w http.ResponseWriter,
r *http.Request,
) (any, error) {
var params store.UpdateCustomerAddressParams
if err := json.NewDecoder(r.Body).Decode(¶ms); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
return x.db.UpdateCustomerAddress(r.Context(), params)
}
The users of our API cannot make the earlier mistake in mismatching IDs because IDs only exist in the request body. In fact, we no longer even need a router that supports dynamic pattern matching, so our backend could become even simpler without trie-based routing!
We still need to differentiate between operating on single items vs collections, and we no longer have the REST design like this to do so:
GET /customers/1/addresses # Get many addresses
GET /customers/1/addresses/1 # Get an address
POST /customers/1/addresses # Create an address
PUT /customers/1/addressses/1 # Update an address
DELETE /customers/1/addresses/1 # Delete an address
Instead, I'm swapping to something that's even simpler, and I would argue is more naturally intuitive to a new programmer arriving at this without any bias:
If you're operating on one thing, the name is singular.
If you're operating on multiple, the name is plural.
It's easy and intuitive. For instance:
GET /customer-addresses # Get many addresses
GET /customer-address # Get an address
POST /customer-address # Create an address
PUT /customer-address # Update an address
DELETE /customer-address # Delete an address
This even allows us to easily add bulk endpoints that operate on more than one thing at a time. REST would need to append /bulk
or some other indication to the path/headers/body, and that would need to be documented somewhere because it's not standard.
For the approach outlined above, we can just follow our convention. To create multiple customer addresses, it's POST /customer-addresses
(plural). Same if we want to delete things in bulk or update in bulk: DELETE /customer-addresses
, PUT /customer-addresses
. If we want to support verbs that don't fit the CRUD pattern, such as validating that an address is correct, we could do: POST /customer-address/validate
to validate one address, and POST /customer-addresses/validate
to validate many.
Of course, since GET
requests don't support bodies, I place the customerId
and addressId
in the query parameters. In the backend using this works out great using Gorilla Schema (x.params
is the *schema.Decoder
):
func (x *Router) getCustomerAddresses(
w http.ResponseWriter,
r *http.Request,
) (any, error) {
if err := r.ParseForm(); err != nil {
return nil, fmt.Errorf("parse form: %w", err)
}
var params store.GetCustomerAddressesParams
if err := x.params.Decode(¶ms, r.Form); err != nil {
return nil, fmt.Errorf("decode: %w", err)
}
params.Limit = min(params.Limit, 25)
return x.db.GetCustomerAddresses(r.Context(), params)
}
For everything else, those IDs are sent to the server exclusively in the request body like we saw earlier.
It's simple and intuitive for the programmer writing the backend and for all API users. It maintains a close relationship to REST, so it's immediately familiar and easy to learn, removes duplicated state footguns and unnecessary complexity, and in the process provides a mental model that handles more scenarios than REST. It manages to achieve all of this in a naturally-obvious way with a consistent pattern.
I'm waving goodbye to REST in favor of this simplified form.
So long and thanks for all the fish.
Weekend Wine Recommendation
Sip on Veuve Ambal Peche Brut this brunch - a dry delight with a peachy punch. Crisp, fresh, and fruity without the sweet; it's your perfect weekend treat!