Best Practices for designing REST APIs

Bubu Tripathy
15 min readMar 5, 2023

In this post, we’ll discuss some of the best practices for designing RESTful APIs, including real-world examples.

First, let’s start with a brief overview of REST (Representational State Transfer) and what it means for API design. REST is a set of architectural principles that guide the design of web services. RESTful APIs are designed to be scalable, reliable, and flexible, making them a popular choice for building web services. Here are some best practices for designing RESTful APIs:

Use clear and concise URLs

One of the key principles of RESTful API design is that URLs should be easy to understand and should convey the purpose of the resource being accessed. Let’s say we have an e-commerce website and we want to design an API that allows clients to retrieve information about products. Instead of using a URL like this:

https://api.example.com/getProduct?id=1234

We can use a URL that clearly indicates that we are retrieving a product resource with the ID of 1234:

https://api.example.com/products/1234

In this example, “products” is the resource being accessed and “1234” is the identifier for the specific product resource. This URL is much easier to understand, and it conveys the purpose of the resource being accessed.

Use descriptive and consistent resource URIs

One of the most important aspects of REST API design is the choice of resource URIs. Resource URIs should be descriptive and consistent, making it easy for clients to understand how to interact with the API. Here are some best practices for choosing resource URIs:

  • Use nouns instead of verbs: Resource URIs should describe resources, not actions. For example, /users is a better URI than /get-users.
  • Use lowercase letters: Resource URIs should use lowercase letters for consistency.
  • Use hyphens or underscores to separate words: Hyphens and underscores are both acceptable ways to separate words in resource URIs. For example, /blog-posts or /blog_posts are both valid URIs.

Use HTTP methods

RESTful APIs rely on HTTP methods (GET, POST, PUT, DELETE, etc.) to perform actions on resources. For example, GET is used to retrieve a resource, POST is used to create a new resource, PUT is used to update an existing resource, and DELETE is used to delete a resource.

Let’s say we have an API that allows clients to manage a list of customers. We can use HTTP methods to perform actions on the customer resource. For example:

  • GET /customers - retrieves a list of all customers
  • GET /customers/{id} - retrieves a specific customer by ID
  • POST /customers - creates a new customer
  • PUT /customers/{id} - updates an existing customer by ID
  • DELETE /customers/{id} - deletes a customer by ID

In this example, HTTP methods are used to perform different actions on the customer resource. GET retrieves customer data, POST creates a new customer, PUT updates an existing customer, and DELETE removes a customer from the list. By using HTTP methods in this way, the API becomes more consistent and easier to use.

Use HTTP response codes

HTTP response codes are an important part of RESTful API design. They provide valuable information to clients about the status of a request. For example, a 200 response code indicates success, a 400 response code indicates a client-side error, and a 500 response code indicates a server-side error.

Let’s continue with the example of managing a list of customers. When a client sends a request to the API, the API should respond with an appropriate HTTP response code to indicate the status of the request. Here are some examples:

  • 200 OK - the request was successful and the response contains the requested data
  • 201 Created - the request was successful and a new resource was created
  • 400 Bad Request - the request was invalid or missing required parameters
  • 401 Unauthorized - the client needs to authenticate to access the resource
  • 404 Not Found - the requested resource was not found
  • 500 Internal Server Error - an unexpected error occurred on the server

By using HTTP response codes in this way, the API can provide valuable information to clients about the status of their requests. For example, if a client sends an invalid request, the API can respond with a 400 Bad Request code to indicate that the request was not processed due to invalid input. If a client tries to access a resource that doesn't exist, the API can respond with a 404 Not Found code to indicate that the resource was not found.

Use consistent resource representations

Resource representations should be consistent across the API. For example, if a user resource has an ID, name, and email field in one endpoint, it should have the same fields in all other endpoints that return a user resource.

Let’s say we have an API that allows clients to manage a list of products. Each product has a name, description, price, and image. To maintain consistency in the API, we can use a consistent representation of the product resource in all API endpoints that deal with product data.

For example, we can use a JSON representation of the product resource like this:

{
"id": 1234,
"name": "Product Name",
"description": "Product Description",
"price": 9.99,
"image": "https://example.com/product_image.jpg"
}

In this example, the product resource has a consistent representation that includes all the relevant data for the product. This representation can be used in all API endpoints that deal with product data, such as retrieving a list of products, creating a new product, updating an existing product, and so on.

By using a consistent representation of the resource, clients can easily work with the API, and the API can maintain consistency in the way it represents data. This approach also makes it easier to version the API, as any changes to the representation can be made in a consistent manner.

Use pagination for large responses

When returning large responses, it’s important to use pagination to avoid overloading the client with too much data at once. Pagination allows the client to request a subset of the data, and then request additional subsets as needed.

Let’s say we have an API that allows clients to retrieve a list of blog posts. If the list of posts is very long, it can be difficult to retrieve and process all of the data at once. To address this issue, we can implement pagination in the API. For example, we can add query parameters to the URL to allow clients to specify the number of results they want to retrieve, as well as the offset of the first result:

  • GET /posts?limit=10&offset=0 - retrieves the first 10 posts
  • GET /posts?limit=10&offset=10 - retrieves the second 10 posts
  • GET /posts?limit=10&offset=20 - retrieves the third 10 posts, and so on

In this example, pagination allows clients to retrieve a large number of results in smaller, more manageable chunks. By specifying a limit and offset in the query parameters, clients can control the number of results they retrieve, and which portion of the results they want to start from. This approach reduces the load on the server and makes it easier for clients to work with large data sets.

Use versioning

APIs change over time, and it’s important to provide a way for clients to use different versions of the API as needed. This can be done by including a version number in the URL or in a request header.

Let’s say we have an API that allows clients to manage user profiles. Over time, we may need to make changes to the API that could break existing clients. To avoid breaking existing clients, we can use versioning in the API.

One common approach to versioning in REST API design is to include the version number in the URL of the API endpoint. Here’s an example:

GET /api/v1/users/1234

In this example, the API endpoint retrieves the profile of the user with ID 1234 using version 1 of the API. If we make changes to the API that affect the user profile endpoint, we can introduce a new version of the API by changing the URL:

GET /api/v2/users/1234

In this example, the API endpoint retrieves the profile of the user with ID 1234 using version 2 of the API.

Another approach to versioning in REST API design is to include the version number in a custom HTTP header. Here’s an example:

GET /api/users/1234
Custom-API-Version: 1

In this example, the API endpoint retrieves the profile of the user with ID 1234 using version 1 of the API. If we make changes to the API that affect the user profile endpoint, we can introduce a new version of the API by changing the version number in the custom HTTP header:

GET /api/users/1234
Custom-API-Version: 2

In this example, the API endpoint retrieves the profile of the user with ID 1234 using version 2 of the API.

Using versioning in the API can help to maintain backward compatibility, which is important in large, complex APIs with many clients. By versioning the API, we can ensure that existing clients continue to work as expected, while allowing us to make changes and improvements to the API over time.

Use Caching

Caching can be a powerful tool for improving the performance and scalability of REST APIs. Here are some best practices for caching in REST API design:

Use HTTP caching headers: HTTP caching headers such as Cache-Control and ETag can help control how clients cache responses. By setting appropriate caching headers, you can ensure that clients cache responses for an appropriate length of time and avoid caching stale data.

HTTP/1.1 200 OK
Cache-Control: max-age=3600
ETag: "abc123"

{
"id": 1234,
"name": "John Doe",
"email": "johndoe@example.com"
}

In this example, the Cache-Control header sets a maximum age of 3600 seconds (1 hour) for the cache entry, and the ETag header provides a unique identifier for the response. The client can use these headers to cache the response and avoid unnecessary requests to the server.

Use appropriate cache storage: The choice of cache storage can have a significant impact on the performance of the API. In-memory caches such as Redis are fast and easy to use, but may not be appropriate for large datasets or long-lived caches. Disk-based caches such as memcached can be more scalable but may have higher latency. Choose a cache storage that is appropriate for the use case and workload.

const redis = require('redis');
const client = redis.createClient({
host: 'localhost',
port: 6379
});

client.get('user:1234', (err, data) => {
if (err) {
// handle error
} else if (data) {
// cache hit
} else {
// cache miss
// fetch data from backend and store in cache
client.set('user:1234', JSON.stringify({
"id": 1234,
"name": "John Doe",
"email": "johndoe@example.com"
}));
}
});

In this example, we use Redis as the cache storage to cache user data. The client.get() method retrieves the cached data, and the client.set() method stores new data in the cache. Redis is a popular in-memory cache that is fast and easy to use, but may not be appropriate for all use cases.

Invalidate caches appropriately: When data is updated, the corresponding cache entries should be invalidated to ensure that clients receive the latest data. One common approach to cache invalidation is to use a time-based expiration, where cache entries are invalidated after a fixed time period. Another approach is to use a “write-through” cache, where updates to the data are also written to the cache to ensure that the cache is always up to date.

const redis = require('redis');
const client = redis.createClient({
host: 'localhost',
port: 6379
});

// cache user data for 1 hour
client.set('user:1234', JSON.stringify({
"id": 1234,
"name": "John Doe",
"email": "johndoe@example.com"
}), 'EX', 3600);

// invalidate cache when data is updated
client.set('user:1234', JSON.stringify({
"id": 1234,
"name": "Jane Smith",
"email": "janesmith@example.com"
}), 'EX', 3600);

In this example, we use Redis to cache user data for 1 hour. When the data is updated, we overwrite the cache entry with the new data and set the same expiration time. This ensures that the cache is always up-to-date.

Use cache keys that are unique and stable: Cache keys should be unique to the data being cached to avoid collisions and overwriting of cache entries. Cache keys should also be stable over time, so that clients can reliably retrieve the correct data from the cache. One common approach to generating cache keys is to use a combination of the request URL and any relevant query parameters.

const redis = require('redis');
const client = redis.createClient({
host: 'localhost',
port: 6379
});

const userId = 1234;
const cacheKey = `user:${userId}`;

client.get(cacheKey, (err, data) => {
if (err) {
// handle error
} else if (data) {
// cache hit
} else {
// cache miss
// fetch data from backend and store in cache
client.set(cacheKey, JSON.stringify({
"id": userId,
"name": "John Doe",
"email": "johndoe@example.com"
}));
}
});

In this example, we generate a cache key that is unique to the user ID, using the string user: as a prefix. This ensures that each cache entry is unique and avoids collisions with other cache entries.

Use cache hierarchies: If you have multiple layers of cache, consider using a cache hierarchy to improve performance and reduce load on the backend. For example, you could use a local in-memory cache on each server, backed by a shared disk-based cache for the entire cluster. This can improve the hit rate of the cache and reduce the number of requests that hit the backend.

Let’s say we have an API endpoint that returns a list of books:

GET /api/books

Each book resource has its own URL, which includes an ID:

GET /api/books/1234

To improve performance and reduce load on the server, we can use cache hierarchies to cache the list of books and individual book resources separately.

We can use a cache key like books:list to cache the list of books, and a cache key like book:1234 to cache the individual book resource. Here's an example using Redis as the cache storage:

const redis = require('redis');
const client = redis.createClient({
host: 'localhost',
port: 6379
});

// cache the list of books for 1 hour
client.set('books:list', JSON.stringify([
{
"id": 1234,
"title": "The Great Gatsby",
"author": "F. Scott Fitzgerald"
},
{
"id": 5678,
"title": "To Kill a Mockingbird",
"author": "Harper Lee"
}
]), 'EX', 3600);

// cache individual book resources for 24 hours
client.set('book:1234', JSON.stringify({
"id": 1234,
"title": "The Great Gatsby",
"author": "F. Scott Fitzgerald",
"description": "The story of Jay Gatsby, a wealthy man who throws lavish parties in the hopes of winning back his lost love."
}), 'EX', 86400);

// retrieve the list of books from cache
client.get('books:list', (err, data) => {
if (err) {
// handle error
} else if (data) {
// cache hit
const books = JSON.parse(data);
// return list of books to client
} else {
// cache miss
// fetch list of books from backend and store in cache
// return list of books to client
}
});

// retrieve a specific book resource from cache
const bookId = 1234;
const cacheKey = `book:${bookId}`;
client.get(cacheKey, (err, data) => {
if (err) {
// handle error
} else if (data) {
// cache hit
const book = JSON.parse(data);
// return book resource to client
} else {
// cache miss
// fetch book resource from backend and store in cache
// return book resource to client
}
});

In this example, we cache the list of books using the cache key books:list for 1 hour, and individual book resources using cache keys like book:1234 for 24 hours. When a client requests the list of books or a specific book resource, we first check if the data is cached using the appropriate cache key. If the data is cached, we return it to the client as a cache hit. If the data is not cached, we fetch it from the backend and store it in the cache with the appropriate cache key for future requests. This reduces the load on the backend and improves performance for clients.

Use security measures

RESTful APIs can be vulnerable to security threats, such as SQL injection and cross-site scripting (XSS) attacks. It’s important to use security measures such as encryption, authentication, and authorization to protect against these threats.

Let’s say we have an API endpoint that requires authentication to access:

GET /api/protected-resource

To secure this endpoint, we can implement several security measures, such as:

Authentication using tokens: Clients must provide a valid authentication token in the request headers to access the protected resource. The authentication token is typically obtained by logging in to the API using a username and password. Here’s an example implementation using JSON Web Tokens (JWT):

const jwt = require('jsonwebtoken');
const secretKey = 'secret'; // secret key used to sign and verify JWTs

// authenticate user and generate JWT
function login(username, password) {
// authenticate user using username and password
// if authentication succeeds, generate JWT and return it to the client
const payload = { sub: username };
const token = jwt.sign(payload, secretKey, { expiresIn: '1h' });
return token;
}

// middleware to verify JWT in request headers
function verifyToken(req, res, next) {
const authHeader = req.headers.authorization;
if (!authHeader) {
return res.status(401).json({ error: 'Missing authentication token' });
}
const token = authHeader.split(' ')[1];
jwt.verify(token, secretKey, (err, decoded) => {
if (err) {
return res.status(401).json({ error: 'Invalid authentication token' });
}
req.user = decoded.sub; // save username in request object
next();
});
}

// protected endpoint that requires authentication
app.get('/api/protected-resource', verifyToken, (req, res) => {
// use req.user to access user information
res.json({ message: 'Access granted!' });
});

In this example, we use JWT to authenticate users and generate tokens. When a client logs in to the API, we generate a JWT with a payload that includes the user’s username. The token is signed using a secret key that is known only to the server. When the client makes a request to the protected resource, they must include the JWT in the Authorization header of the request. We use a middleware function to verify the token and extract the username from the payload. If the token is invalid or missing, we return a 401 Unauthorized response. If the token is valid, we save the username in the request object and allow the request to proceed to the protected endpoint.

Rate limiting: To prevent brute-force attacks or other forms of abuse, we can limit the number of requests that a client can make to the API within a certain time period. Here’s an example implementation using the express-rate-limit middleware:

const rateLimit = require('express-rate-limit');

// limit to 100 requests per hour per IP address
const limiter = rateLimit({
windowMs: 60 * 60 * 1000, // 1 hour
max: 100
});

// apply rate limiter to all API endpoints
app.use('/api/', limiter);

In this example, we use the express-rate-limit middleware to limit the number of requests that a client can make to the API to 100 requests per hour per IP address. If a client exceeds this limit, they will receive a 429 Too Many Requests response.

Encryption of sensitive data: If the API deals with sensitive data, such as passwords or personal information, we can encrypt the data to prevent unauthorized access. Let’s say we have an API endpoint that allows users to create an account by providing a username and password:

POST /api/signup

To protect the user’s password, we can encrypt it before storing it in the database. Here’s an example implementation using the bcrypt library:

const bcrypt = require('bcrypt');

// create user account
app.post('/api/signup', async (req, res) => {
const { username, password } = req.body;
const hashedPassword = await bcrypt.hash(password, 10); // encrypt password
// store username and hashed password in database
// ...
res.json({ message: 'Account created successfully' });
});

// authenticate user
app.post('/api/login', async (req, res) => {
const { username, password } = req.body;
// retrieve hashed password from database using username
const hashedPassword = '...'; // fetch from database
const passwordMatch = await bcrypt.compare(password, hashedPassword); // compare passwords
if (passwordMatch) {
// password is correct, generate JWT and return it to client
// ...
} else {
// password is incorrect, return error message
res.status(401).json({ error: 'Invalid username or password' });
}
});

In this example, we use the bcrypt library to encrypt the user's password before storing it in the database. When the user logs in, we retrieve the hashed password from the database and compare it to the password provided by the user. If the passwords match, we generate a JWT and return it to the client. If the passwords do not match, we return a 401 Unauthorized response.

By encrypting the user’s password, we can protect it from unauthorized access. If an attacker gains access to the database, they will not be able to read the user’s password unless they also have the encryption key. This helps to prevent password reuse attacks, where an attacker uses a password obtained from one website to gain access to other websites where the user has used the same password.

Now, let’s take a look at some real-world examples of RESTful APIs that follow these best practices:

  1. Twitter API: The Twitter API allows developers to access Twitter data and functionality, such as posting tweets and retrieving user information. The API uses clear and concise URLs, HTTP methods, and HTTP response codes to perform actions on resources. It also uses pagination for large responses and versioning to support different versions of the API.
  2. GitHub API: The GitHub API allows developers to access GitHub data and functionality, such as creating repositories and retrieving commit information. The API uses consistent resource representations and HTTP response codes to perform actions on resources. It also uses pagination for large responses and versioning to support different versions of the API.
  3. Stripe API: The Stripe API allows developers to accept payments online. The API uses clear and concise URLs, HTTP methods, and HTTP response codes to perform actions on resources. It also uses pagination for large responses and versioning to support different versions of the API. Additionally, the API uses security measures such as encryption, authentication, and authorization to protect against security threats.

By following these best practices, you can use caching to improve the performance and scalability of REST APIs, while ensuring that clients receive up-to-date and accurate data.

RESTful APIs are designed to provide a standardized way for clients to communicate with servers over the internet. To design a good REST API, it’s important to follow best practices. In this blog post, we discussed several best practices for designing REST APIs, including using descriptive and consistent resource URIs, using HTTP verbs to represent CRUD operations, designing resource representations, using pagination, supporting content negotiation, using versioning, and using security measures. By following these best practices, you can design a REST API that is easy to use, flexible, and secure.

Thank you for your attention. Happy Learning!

--

--