If you’re struggling to fit an operation into the REST API architecture paradigm, try adding a field to an existing model or creating a new model, such that a change to some fields in the model represents the operation.
A colleague asked this (paraphrased) question:
I’m adding an endpoint that will check an authentication token for a user in our token store and refresh it if necessary (
/user-auth-tokens/{userId}
). Which HTTP method should I use? POST seems to be a bad fit. JSON:API doesn’t support PUT. Should I use PATCH? JSON:API requires a body in PATCH requests, but I have no data to update. I’m passing the user id in the URL. Should I move it to the body, so I could use PATCH? Using GET seems worse, because I’m potentially making an update to refresh the token if it’s expired, and I don’t actually want the client to be able to fetch the user’s token either way. What do I do?
Note that while the question references JSON:API, which we use at my current job, the problem is more general. Assuming you don’t want to drop below level 2 of the Richardson Maturity Model, when designing REST APIs how do you represent operations that are conditional or don’t neatly align with the common HTTP verbs?
The following is my response. My colleague found it useful, so I’m reposting it here (after a bit of cleanup to remove internal/sensitive info).
I love this question because it lets me share a general approach with the group that’s worked well for me in the past:
Rule of thumb for REST: if you’re acting on something without updating anything, change the model so you have something to update! 😀
In this case, you could have GET /user-auth-tokens/{userId}
return a refreshedOn
datetime field, such that the client could then call PATCH /user-auth-tokens/{userId}
and set refreshedOn
to the current time. The server would respond with the updated refreshedOn
.
Let’s pretend the current time is 2024-03-09T10:00:00.000Z
. The client asks the server to refresh the user’s token if it’s invalid:
PATCH /user-auth-tokens/someUserId123
{"refreshedOn": "2024-03-09T10:00:00.000Z"}
The server could respond with the “new now” from the server’s perspective, if the token was refreshed:
200 OK
{"refreshedOn": "2024-03-09T10:00:00.150Z"}
Note that it took the server 150ms to refresh the token, so its date is different in the response than what the client provided. If the server supports the ability to set the refresh date explicitly, the date in the response may match the client’s.
If the token is still valid, the server could respond with the old token refresh date:
200 OK
{"refreshedOn": "2024-02-01T09:59:14.697Z"}
But the point is, there’s now a field that the client changes (or tries to change) to indicate their intent, and the server responds with the result. This means you don’t need a separate endpoint for different operations on the same entity. An operation here is actually a modification (PATCH
) of one or more data model fields. This leaves POST
, PUT
, and DELETE
for creating, replacing, or deleting the entity as a whole. This works for a lot of situations where you’d usually be tempted to switch to the RPC-over-HTTP style, i.e. POST /user-auth-tokens/{userId}/refresh-if-needed
.
Let’s take another example. This time, we want to migrate legacy tokens to a new format. Instead of /user-auth-tokens/{userId}/migrate-legacy
we could add a version
field to the existing model. So, it would now contain two fields: refreshedOn
and version
. To migrate a user’s token, the client would (again) call PATCH /user-auth-tokens/{userId}
and set the new version in the request body:
PATCH /user-auth-tokens/someUserId123
{"version": 2}
The server may reply with a bunch of different error codes if, for example, the token is already at version 2 or if it can’t be migrated for some reason. A 200 OK would mean “migration is done” and version
in the response would be 2
.
This extends to batch operations too! For these cases, it’s best to add new endpoints to represent the batch operation as its own entity. The client would call POST /user-auth-token-migrations
with some user ids and versions in the body to kick off a migration. The response is 201
with an added header Location: /user-auth-token-migrations/migration-id-123
. Calling GET /user-auth-token-migrations/migration-id-123
returns the model representing the status of the batch operation, e.g. {"status":"running"}
. Client could PATCH
with {"status":"paused"}
to pause the batch, if the server supports that. If that’s not supported, the server could reply with a 400-series error.
The beauty of this approach is the server may execute many operations behind the scenes and update multiple fields in the response without requiring a new endpoint. This stops the proliferation of endpoints that’s common with RPC APIs and allows each one to evolve along with the product. For example, let’s go back to the token refresh endpoint. Let’s say the model is now two fields: refreshedOn
and expiresOn
. When clients call PATCH
with {"refreshedOn": "2024-03-09T10:00:00.000Z"}
the server would respond with that date in refreshedOn
and a new expiresOn
. Maybe that’s set to one day later (2024-03-10T10:00:00.000Z), if that’s the standard token validity duration:
PATCH /user-auth-tokens/someUserId123
{"refreshedOn": "2024-03-09T10:00:00.000Z"}
200 OK
{
"refreshedOn": "2024-03-09T10:00:00.000Z",
"expiresOn": "2024-03-10T10:00:00.000Z"
}
Now the client knows when it should request the next refresh.
What if we want to support the ability for some clients to get a long-lived token? They could just send both fields in the PATCH
, setting expiresOn
to something like 2025-01-01
. If that’s allowed, the server responds with 200 OK and new values in both fields, including the 2025 date in expiresOn
. If not, the response is a 400-series error, or a 200 OK
with new refreshedOn
and the standard one-day expiresOn
(basically ignoring the client’s update to the field). Perhaps we now support extending an existing valid token without refreshing it? Clients can PATCH
with just the new expiresOn
date! With the RPC style, you have to either create separate endpoints /refresh
and /extend
or move this logic into query params (/user-auth-tokens/someUserId123?extendTo=date
). The latter doesn’t seem too bad at first, but it opens the door to infinite scope creep of the endpoint, because the query param becomes a catch-all “do something” instruction, even if it doesn’t make sense for the entity (e.g. /user-auth-tokens/{userId}?generateReport=true
). PATCH
ing fields in the model constrains the endpoint to the entity it represents.
This “one weird trick” really unlocked REST for me when I was struggling with it earlier in my career. It feels strange at first to represent operations as updates to some fields of the entity model. But REST is all about entities! The fundamental contract is: clients perform operations by attempting to update entities to look a desired way, and the server responds with the model that represents the result of that attempt. The client then needs to take that response as the new true state of the entity, even if nothing changed or if there were more changes to the model than the client expected.