
REST APIs are among the most popular categories of online services, yet they are often challenging to build. They enable different clients to communicate with a server, including browsers, desktop programs, mobile applications, and essentially any device with an internet connection. Therefore, it's crucial to properly build REST APIs in order to avoid issues in the future. The amount of things that must be in place when building an API from scratch can be intimidating. Basic security, implementing authentication, choosing which requests and responses are approved and returned, and a host of other issues are covered. In this post, I’m trying my best to compress into 15 items some powerful recommendations on what makes a good API. All these tips are language-agnostic, so they potentially apply to any framework or technology.
1. Make sure to use nouns in endpoint paths.
We should always favor utilizing plural designations and should always utilize the nouns that reflect the entity that we are getting or altering as the pathname. Because our HTTP request method already contains the verb and doesn't really contribute any additional information, avoid utilizing verbs in the endpoint paths.
The HTTP request method we're using should indicate the action. GET, POST, PATCH, PUT, and DELETE are the most often used techniques.
- GET retrieves resources.
- POST submits new data to the server.
- PUT/PATCH updates existing data.
- DELETE removes data.
The verbs map to CRUD operations.
With these principles in mind, we should create routes like GET/books for getting a list of books and not GET/get-books nor GET/book. Likewise, POST/books is for adding a new book, PUT/books/: id is for updating the full book data with a given id, and PATCH/books/: idupdates partial changes to the book. Finally, DELETE /books/: id is for deleting an existing article with the given ID.
2. JSON as the main format for sending and receiving data
Up until a few years ago, most API requests were accepted and responses were given in XML. But in most apps nowadays, JSON (JavaScript Object Notation) is the "standard" format for delivering and receiving API data. Therefore, our second piece of advice is to make sure that our APIs accept and return data in JSON format when accepting HTTP message payloads.
While Form Data is good for sending data from the client, especially if we want to send files, it is not ideal for text and numbers. We don’t need Form Data to transfer those since with most frameworks we can transfer JSON directly on the client side. When receiving data from the client, we need to ensure the client interprets JSON data correctly, and for this, the Content-Type type in the response header should be set to application/json while making the request.
It's worth mentioning once again the exception if we’re trying to send and receive files between client and server. In this particular case, we need to handle file responses and send form data from client to server.
3. Use a set of predictable HTTP status codes
It is always a good idea to use HTTP status codes according to their definitions to indicate the success or failure of a request. Don’t use too many, and use the same status codes for the same outcomes across the API. Some examples are:
200for general success201for successful creation400for bad requests from the client like invalid parameters401for unauthorized requests403for missing permission on the resources404for missing resources429for too many requests5xxfor internal errors (these should be avoided as much as possible)
There might be more depending on your use case, but limiting the amount of status code helps the client to consume a more predictable API.
4. Return standardized messages
Always utilize consistent answers for similar endpoints in addition to the use of HTTP status codes that indicate the result of the request. Customers may consistently anticipate the same structure and behave appropriately. This is true for both success and error notifications. Be consistent while fetching collections by following a specific format. The body of the answer contains a variety of data, including:
[
{
bookId: 1,
name: "The Republic"
},
{
bookId: 2,
name: "Animal Farm"
}
]
or a combined object like this:
{
"data": [
{
"bookId": 1,
"name": "The Republic"
},
{
"bookId": 2,
"name": "Animal Farm"
}
],
"totalDocs": 200,
"nextPageId": 3
}
The advice is to be consistent regardless of the approach you choose for this. The same behavior should be implemented when fetching an object and also when creating and updating resources, which is usually a good idea to return the last instance of the object.
// Response after successfully calling POST /books
{
"bookId": 3,
"name": "Brave New World"
}
Although it won’t hurt, it is redundant to include a generic message like "Book successfully created" as that is implied by the HTTP status code.
Last but not least, error codes are even more important when having a standard response format. This message should include information that a consumer client can use to present errors to the end user; accordingly, it should not be a generic "Something went wrong" alert, which we should avoid as much as possible. Here’s an example:
{
"code": "book/not_found",
"message": "A book with the ID 6 could not be found"
}
Again, it is not necessary to include the status code in the response content, but it is useful to define a set of error codes like book/not_found in order for the consumer to map them to different strings and decide on their own error message for the user. In particular, for development and staging environments, it might seem adequate to also include the error stack in the response to help debug bugs. But please do not include those in production as it’d create a security risk by exposing unpredictable information.
5. Use pagination, filtering, and sorting when fetching collections of records.
As soon as we build an endpoint that returns a list of items, pagination should be put in place. Collections usually grow over time, so it is important to always return a limited and controlled number of elements. It is fair to let API consumers choose how many objects to get, but it is always a good idea to predefine a number and have a maximum for it. The main reason for this being that it will be very time- and bandwidth consuming to return a huge array of data.
To implement pagination, there are two well known ways to do it: skip/limit or keyset. The first option allows a more user friendly way to fetch data, but is usually less performant as databases will have to scan many documents when fetching "bottom line" records. On the other hand, and the one I prefer, keyset pagination receives an identifier/id as the reference to "cut" a collection or table with a condition without scanning records.
In the same line of thinking, APIs should provide filters and sorting capabilities that enrich how data is obtained. In order to improve performance, database indexes are part of the solution to maximize performance with the access patterns that are applied through these filters and sorting options.
As part of the API design, these properties of pagination, filtering, and sorting are defined as query parameters on the URL. For instance, if we want to obtain the first 10 books that belong to a "romance" category, our endpoint would look like this:
GET/books?limit=10&category=romance
6. PATCH instead of PUT
It is quite improbable that we will ever need to update an entire record all at once. Usually, we wish to protect complex or sensitive data from user modification. In light of this, PATCH requests should be used to make partial adjustments to resources, whereas PUT requests completely replace existing resources. Both parties should send the revised information using the request body. PATCH requests only return modified fields, whereas PUT requests return the entire object. However, it's important to note that there is no restriction on us utilizing PUT for partial updates. No "network transfer constraints" exist to support this. Simply following this convention is a good idea.
7. Provide extended response options
When developing the available API resources and deciding which data is returned, access patterns are crucial. Record properties expand as a system expands, but not all of those features are always necessary for clients to function. Giving the option to return either limited or complete replies for the same endpoint is helpful in these circumstances. Providing consumers with a streamlined answer when they only require a few basic fields can help to save bandwidth and possibly simplify the process of retrieving more complicated computed information.
An easy way to approach this feature is by providing an extra query parameter to enable or disable the provision of the extended response.
GET /books/: id "bookId": 1,
"name": "The Republic"
GET /books/: id? extended=true 5."bookId": 1,
"name": "The Republic"
"tags": ["philosophy", "history", "Greece"] ,
"author": "id": 1,
"name": "Plato"
;
8. Endpoint Responsibility
The Single Responsibility Principle focuses on the concept of keeping a function, method, or class focused on a narrow behavior that it does well. When we think about a given API, we can say it is a good API if it does one thing and never changes. This helps consumers to better understand our API and make it predictable, which facilitates the overall integration. It is preferable to extend our list of available endpoints to include more in total rather than building very complex endpoints that try to solve many things at the same time.
9. Provide Accurate API Documentation
Consumers of your API should be able to understand how to use it and what to expect from the available endpoints. This is only possible with good and detailed documentation. Take into consideration the following aspects to provide a well documented API.
- Endpoints are available, describing the purpose of them
- Permissions required to execute an endpoint
- Examples of invocation and response
- Error messages to expect
The other important part for this to be a success is to always have the documentation up-to-date following the system changes and additions. The best way to achieve this is to make API documentation a fundamental piece of the development process. Two well known tools in this regard are Swagger and Postman, which are available for most of the API development frameworks out there.
10. Use SSL for security and configure CORS.
Security is another fundamental property that our API should have. Setting up SSL by installing a valid certificate on the server will ensure secure communication with consumers and prevent several potential attacks.
CORS (Cross-origin resource sharing) is a browser security feature that restricts cross-origin HTTP requests that are initiated by scripts running in the browser. If your REST API’s resources receive non-simple cross-origin HTTP requests, you need to enable CORS support for consumers to operate accordingly.
The CORS protocol requires the browser to send a preflight request to the server and wait for approval (or a request for credentials) from the server before sending the actual request. The preflight request appears into the API as an HTTP request that uses the OPTIONS method (among other headers). Therefore, to support CORS a REST API resource needs to implement an OPTIONS method that can respond to the OPTIONS preflight request with at least the following response headers mandated by the Fetch standard:
Access-Control-Allow-MethodsAccess-Control-Allow-HeadersAccess-Control-Allow-Origin
Which values to assign to these keys will depend on how open and flexible we want our API to be. We can assign specific methods and known origins or use wildcards to have open CORS restrictions.
11. Version the API
As part of the development evolution process, endpoints start to change and get rebuilt. But we should avoid, as much as possible, suddenly changing endpoints for consumers. It is a good idea to think of the API as a backwards compatible resource where new and updated endpoints should become available without affecting previous standards.
Here’s where API versioning becomes useful; clients should be able to select which version to connect to. There are multiple ways to declare API versioning:
1. Adding a new header "x-version = v2"
2. Having a query parameter "?apiVersion=2"
3. Making the version part of the URL: "/v2/books/: id"
Getting into the details on which approach is more convenient, when to make official a new version and when to deprecate old versions are certainly interesting questions to ask, but to not extend this item in excess, that analysis will be part of another post.
Cache data to improve performance.
In order to help the performance of our API, it is beneficial to keep an eye on data that rarely changes and is frequently accessed. For this type of data, we can consider using an in-memory or caché database that saves time from accessing the main database. The main challenge with this approach is that data might get outdated, so a process to put the latest version in place should be considered as well.
Using cached data will become useful for consumers to load configurations and catalogs of information that are not meant to change much over time. When using caching, make sure to include Cache-Control information in the headers. This will help users effectively use the caching system.
Use standard UTC dates.
I cannot think of a system’s reality that doesn’t work with dates at some point. At the data level it is important to be consistent on how dates are displayed for client applications. The ISO 8601 is the international standard format for date and time related data. The dates should be in "Z" or UTC format from which then clients can decide a timezone for it in case such date needs to be displayed under any conditions. Here’s an example on how dates should look like:
{
"createdAt": "2022-03-08T19:15:08Z"
}
14. A health check endpoint
There might be rough times where our API is down and it might take some time to get it up and running. Under this circumstances clients will like to know that services are not available so they can be aware of the situation and act accordingly. In order to achieve this, provide an endpoint (like GET /health) that determines whether or not the API is healthy. This endpoint can be called by other applications such as load balancers. We can even take this one step further and inform about maintenance periods or health conditions on parts of the API.
15. Accept API key authentication
Allowing authentication via API keys offers the ability for third party applications to easily create an integration with our API. These API keys should be passed using a custom HTTP header (such as Api-Key or X-Api-Key). Keys should have an expiration date, and it must be possible to revoke them so that they can be invalidated for security reasons.
Post a Comment
Post a Comment