[p36]
A complete API action plan might look like this:
Entity | Actions |
---|---|
Checkins | - Create - Read - Update - Delete - List - Image |
Opps | - Create - Read - Update - Delete - List - Image - Checkins |
Places | - Create - Read - Update - Delete - List (lat, lon, distance or box) - Image |
Users | - Create - Read - Update - Delete - List (active, suspended) - Image - Favorites - Checkins - Followers |
[p38]
GET Resources
/resources
Some paginated list of stuff, in some logical default order, for that specific data.
/resources/X
Just entity X. That can be an ID, hash, slug, username, etc., as long as it’s unique to one “resource”.
/resources/X,Y,Z
The client wants multiple things, so give them multiple things.
[p40]
Auto-Increment is the Devil
In these examples X and Y can be an auto-incrementing ID as many developers will assume. One important factor with auto-incrementing ID’s is that anyone with access to your API will know exactly how many resources you have, which might not be a statistic you want your competitors to have.
Consumers could also write a script which hits /users/1, then /users/2 and /users/3, etc., scraping all data as it goes. Sure they could probably do that from the “list” endpoints anyway, but not all resources should have a “get all” approach.
Instead a unique identifier is often a good idea. A universal unique identifier (UUID)
[p40]
DELETE resources
/places/X
Delete a single place.
/places/X,Y,Z
Delete a bunch of places.
/places
This is a potentially dangerous endpoint that could be skipped, as it should delete all places.
/places/X/image
Delete the image for a place, or:
/places/X/images
If you chose to have multiple images this would remove all of them.
[p43]
POST resources
/me/settings
I would expect this to allow me to POST specific fields one at a time, not force me to send the entire body of settings.PUT resources
-/me/settings
Send me ALL the settings.
[p44]
Plural consistently allows for consistently named subresources:
/places
/places/45
/places/45/checkins
/places/45/checkins/91
/checkins/91”
[p45]
Some API developers consider the following approach to be more RESTful because it uses a subresource:
POST /users/5/send-message HTTP/1.1
Host: example.com
Content-Type: application/json
{ "message" : "Hello!" }
Nope, because that is still using a verb in the URL. A verb is an action - a doing term - and our API only needs one verb - the HTTP Method. All other verbs need to stay out of the URL.
A noun is a place or a thing. Resources are things, and a URL becomes the place on the Internet where a thing lives.
This example would be drastically more RESTful:
POST /users/5/messages HTTP/1.1
Host: example.com
Content-Type: application/json
{ "message" : "Hello!" }”
Perfect! We are creating a new message that belongs to a user.
The best part about keeping it nice and RESTful like this is that other HTTP actions can be made to the identical URL:
/users/philsturgeon/messages
/users/philsturgeon/messages/xdWRwerG
/users/philsturgeon/messages/xdWRwerG
This is all much easier to document and much easier to understand for both humans and software
[p49]
Routes
Try to avoid the temptation to screw around with magic routing conventions, it is best to just write them manually. I will keep going with the previous examples and show the process of turning the action plan into routes using Laravel syntax, because why not:
Action | Endpoint | Route |
---|---|---|
Create | POST /users | Route::post('users', 'UsersController@create'); |
Read | GET /users/X | Route::get('users/{id}', 'UsersController@show'); |
Update | PUT /users/X | Route::put('users/{id}', 'UsersController@update'); |
Delete | DELETE /users/X | Route::delete('users/{id}', 'UsersController@delete'); |
List | GET /users | Route::get('users', 'UsersController@list'); |
Image | PUT /users/X/image | Route::put('users/{id}/image', 'UsersController@uploadImage'); |
Favorites | GET /users/X/favorites | Route::get('users/{id}/favorites', 'UsersController@favorites'); |
Checkins | GET /users/X/checkins | Route::get('users/{user_id}/checkins', 'CheckinsController@index'); |
[p72]
By placing the collection into the “data” namespace, you can easily add other content next to it, which relates to the response, but is not part of the list of resources at all. Counts, links, etc., can all go here (more on this later). It also means when you embed other nested relationships you can include a ”data” element for them and even include metadata for those embedded relationships.
Namespace the resource:
{
"data": {
"name": "Phil Sturgeon",
"id": "511501255"
}
}
Namespace the collection:
{
"data": [
{
"name": "Hulk Hogan",
"id": "100002"
},
{
"name": "Mick Foley",
"id": "100003"
}
]
}
This is close to the JSON-API response. It has the benefits of the Facebook approach, and is just like Twitter, but everything is namespaced. Some folks (including me in the past) will suggest that you should change “data” to “users”, but when you start to nest your data, you want to keep that special name for the name of the relationship. For example:
{
"data": {
// here
"name": "Hulk Hogan",
"id": "100002",
"comments": {
"data": [
// here
{
"id": 123423,
"text": "Sorry I said those inappropriate things!"
}
]
}
}
}
[p107]
Some ORM’s have a “hidden” option to hide specific fields from being output. If you can promise that you and every single other developer on your team (now, next year and for the entire lifetime of this application) will remember about that, then congratulations, you could also achieve world peace with a team that focused.
[p112]
Use serialisers
[p140]
Embedded Documents (aka Nesting)
Instead of flattening the entire response to top level collections and losing the obvious context of the data, embedding data leaves it in the structure a client would expect.
It offers the most flexibility for the API consumer; it can reduce HTTP requests or reduce download size depending on what the consumer wants.
An API consumer could call the endpoint with the following query string parameter: /places?include=checkins,merchant
{
"data": [
{
"id": 2,
"name": "Videology",
"lat": 40.713857,
"lon": -73.961936,
"created_at": "2013-04-02",
"checkins": {
"data": [
{
"id": 123423,
"text": "Sorry I said those inappropriate things!"
}
]
},
"merchant": {
"data": [
{
"id": 123423,
"text": "Sorry I said those inappropriate things!"
}
]
}
}
]
}
[p208]
Define a Maximum
When you take the limit/number parameter from the client, you absolutely have to set an upper bound on that number, make sure it is over 0 and depending on the data source you might want to make sure it is an integer as decimal places could have some interesting effects.
Obviously nobody wants to go to page 83.333, so round that up to page 84. Using these variables, an API can output some simple metadata that goes next to the main data namespace:
{
"data": [
// ...
],
"pagination": {
"total": 1000,
"count": 12,
"per_page": 12,
"current_page": 1,
"total_pages": 84,
"next_url": "/places?page=2&number=12"
}
}
The names of items in this pagination example are purely based on what Kapture’s iPhone developer suggested at the time, but should portray the intent”.
[p209]
Versioning
Nobody Understands REST or HTTP
Generally accepted to be the proper HATEOAS approach, content negotiation for specific resources using media types is one of the most complex solutions, but is a very scalable way to approach things. It solves the all-or-nothing approach of versioning the entire API, but still lets breaking changes be made to the API in a manageable way.
Basically, if GitHub were to do this, they would take their current media-type and add an extra item:
Accept: application/vnd.github.user.v4+json
Alternatively, the Accept header is capable of containing arbitrary parameters.
Accept: application/vnd.github.user+json; version=4.0”
Required Parts:
return axios.get("/users", {
headers: {
Accept: "application/vnd.mycompany.user.v3+json",
},
});
application/
- Base MIME type prefix (required)json
- The actual format (required if that’s what you’re using)Optional/Conventional Parts:
vnd
- Vendor prefix (“vendor” - conventional for custom types)github
- Organization/company name (can be your org name)user
- Resource typev3
- Version identifier+
- Suffix separator (used before the format)Lambda
// lambda/user-v4/index.ts
export const handler = async (event: any) => {
const acceptHeader = event.accept || "";
// Parse version from Accept header
let version = "4"; // default version
if (acceptHeader.includes("vnd.github.user.v3")) {
version = "3";
} else if (acceptHeader.includes("version=")) {
version = acceptHeader.split("version=")[1].split(".")[0];
}
// Route to appropriate version handler
switch (version) {
case "3":
return {
statusCode: 200,
headers: {
"Content-Type": "application/vnd.github.user.v3+json",
},
body: JSON.stringify({
version: "v3",
data: {
// v3 response structure
id: 123,
name: "John Doe",
email: "[email protected]",
},
}),
};
case "4":
default:
return {
statusCode: 200,
headers: {
"Content-Type": "application/vnd.github.user.v4+json",
},
body: JSON.stringify({
version: "v4",
data: {
// v4 response structure with breaking changes
userId: 123,
profile: {
firstName: "John",
lastName: "Doe",
contactInfo: {
email: "[email protected]",
},
},
},
}),
};
}
};