Introduction
Imagine the Twitter API. You can use it to post, get, and even delete tweets. The Twitter API is an example of an external API. It's provided by a third-party service, Twitter, that allows us to interact with their service programmatically.
We need a way to interact with the Twitter API. How we do that is where API design comes in.
In this post, we'll focus on the basics of API design.
CRUD
When designing an API, we often talk about CRUD operations. CRUD stands for Create, Read, Update, and Delete. These are the basic operations that we can perform on a resource.
Often, it'll be helpful to think about the operations as functions, e.g., createTweet, getTweet, updateTweet, deleteTweet.
However, in REST APIs, the HTTP methods dictate the operation. POST
is used to create a resource, GET
is used to read a resource, PUT
is used to update a resource, and DELETE
is used to delete a resource.
Resource
A resource is an object or representation of something with data associated with it. For example, a user is a resource, and a user's name, email, and address are data associated with that resource.
Referring back to the Twitter example, a Tweet is a resource. A Tweet has data associated with it, like the tweet's content, the user who posted the tweet, and the timestamp when the tweet was posted.
Why API Design Matters
When designing an API, we need to consider how it will be used and evolve over time. We can't simply change it whenever we want. Once an API is released and used, we need to be careful about making changes. We need to maintain backward compatibility so existing clients can continue using the API without any issues.
Example of introducing a new feature
Let's say we have createTweet(userId, content)
API for creating a tweet.
In REST, it'd look something like https://api.twitter.com/tweets
with a POST
request.
We want to add a feature where users can reply to a tweet. We can't simply add another field like parentId
to the createTweet
API. It wouldn't be backward compatible. We could make the field optional to maintain backward compatibility, but that's not a good design. It's not a good design because it's not clear that the parentId
field is for replying to a tweet.
But, you can imagine a different scenario where introducing an optional field is a good design not to break existing clients.
Instead, we could create a new API like createReply(userId, content, parentId)
.
In REST, it'd look something like https://api.twitter.com/replies
with a POST
request.
Versioning
If we have to introduce breaking changes, we can version our API. We can have different versions of our API running simultaneously. This way, existing clients can continue to use the old version, and new clients can use the new version.
For example, the Twitter API might look like https://api.twitter.com/v1/tweets
for version 1 and https://api.twitter.com/v2/tweets
for version 2. Existing clients can continue to use v1
, and new clients can use v2
.
What to pass in the request
Let's take a look at what a Tweet may look like:
{
id: string
userId: string
content: string
createdAt: Date
likes: int
}
If the user wants to create a tweet, they'd send a POST
request to https://api.twitter.com/tweets
with the following payload:
{
userId: "123",
content: "Hello, world!"
}
They would only need to pass the userId
and content
fields. The id
, createdAt
, and likes
fields would be generated by the server.
It's important to understand that not everything in a resource needs to be passed in the request. Some fields should be generated by the server.
Get a specific resource
What if we wanted to get a specific tweet?
We could make a GET
request to https://api.twitter.com/tweets/:id
, where :id
is the ID of the tweet. It would look something like https://api.twitter.com/tweets/123
.
To the same endpoint, we could make a DELETE
request to delete the tweet.
Get a list of resources by a related resource
What if we wanted a specific user's list of tweets?
We could make a GET
request to https://api.twitter.com/users/:userId/tweets
, where :userId
is the ID of the user. It would look something like https://api.twitter.com/users/123/tweets
.
Pagination
What happens if the user has thousands of tweets?
It would be inefficient to return all the tweets at once. Instead, we can use pagination. We can limit the number of tweets returned per page and provide a way to get to the next page of tweets.
This is where query parameters like limit
and offset
come in. We could make a GET
request to https://api.twitter.com/users/123/tweets?limit=10&offset=0
to get the first 10 tweets, and https://api.twitter.com/users/123/tweets?limit=10&offset=10
to get the next 10 tweets.
limit
specifies how many tweets to return, and offset
specifies where to start fetching the tweets.
Internally, we may order the tweets by createdAt
in descending order to get the latest tweets first.
Idempotence
GET
and PUT
requests are supposed to be idempotent, meaning they should not have any side effects.
POST
and DELETE
requests are not idempotent. They can have side effects. For example, making the same POST
request multiple times will create multiple resources.
Conclusion
The most important points to remember when designing an API are:
Keep your API backward compatible.
Make sure your API is idempotent where it should be.
Endpoints should be intuitive and easy to understand.
Use query parameters for filtering, sorting, and pagination.
Use nouns for resources and HTTP methods for operations.
If you have to introduce breaking changes, version your API.