How to design a REST API, correctly?

Emrecan Özkan
11 min readAug 15, 2023

--

bob ross designing a rest api

In this content, I would like to touch on the REST API design, which is an important issue that many people miss. I realize that many people in the industry are writing random REST APIs. This shouldn’t happen. It’s actually not that difficult.

I am a computer engineer developing software and hardware professionally since 2018. My area of expertise is designing software, drawing its architecture, and playing an active role in developing and deploying. Over the years, I have developed dozens of mobile applications, websites, and web services. Now I will explain to you one of the important subjects called REST API designing.

Don’t forget to watch the video before everything!

rest api design youtube video

Let’s get started with the design!

rest api design

Many people are trying to be simple when coding REST APIs. They don’t think ahead. They write a new function for each operation and even use the wrong method most of the time. They don’t even know which status code to return etc. etc.

In this content, I will talk about the standards that we have to comply with while designing a REST API and give practical examples. Let’s talk about methods first.

Methods

We have a total of 5 methods. But I’m sure many of you have only used 2 of them so far. GET and POST. But this should not be done. Now let’s examine in detail which method and how we should use it.

  • GET

The GET method should be used when retrieving data. It should never, ever be used while updating or deleting data. A successful GET request is expected to return 200 HTTP status codes.

Although the GET method is not very common, it can also be used to check whether the request is authenticated. (POST method mostly preferred.) But this is not a very common usage style. In this case, you can return the 204 HTTP status code.

An example usage of the GET method in Java Spring Boot:

GET /users/1
@GetMapping(path = "/users/{id}", headers = Constant.API_VERSION_HEADER_NAME + "=v1.0")
public ResponseEntity<User> getUserById(@PathVariable int id) throws UserNotFoundException {
// Find the user with the given ID in the database
User user = userRepository.findById(id).orElse(null);
if (user == null) {
// If the user with the specified ID is not found, return a 404 Not Found response
throw new UserNotFoundException("There is no user with this id.");
}
return ResponseEntity.ok(user);
}
  • POST

The POST method should be used when performing a data insertion operation. You should never, ever use it while updating data. There is another method that does this job. After a successful POST request, 201 HTTP status code returns are expected.

An example usage of the POST method in Java Spring Boot:

POST /books
@PostMapping("/books")
public ResponseEntity<BookDTO> createBook(@RequestBody BookDTO bookDTO) {
// Perform validation and any necessary operations before creating the book
// For example, you can convert the BookDTO to Book entity and save it to the database
Book bookToCreate = new Book();
bookToCreate.setName(bookDTO.getName());
bookToCreate.setAuthor(bookDTO.getAuthor());
bookToCreate.setImageS3Id(bookDTO.getImageS3Id());
bookToCreate.setVisible(bookDTO.isVisible());

Book createdBook = bookRepository.save(bookToCreate);

// Convert the created book back to BookDTO and return it in the response
BookDTO createdBookDTO = new BookDTO();
createdBookDTO.setId(createdBook.getId());
createdBookDTO.setName(createdBook.getName());
createdBookDTO.setAuthor(createdBook.getAuthor());
createdBookDTO.setImageS3Id(createdBook.getImageS3Id());
createdBookDTO.setVisible(createdBook.isVisible());

return ResponseEntity.ok(createdBookDTO);
}
  • PUT

The PUT method should be used if you want to update an entire object. For example, suppose a book object has a name, number of pages, and author name information. You should not use this method if you only want to update the page count of this book object. This method should only be used if and only if all information is to be updated. A successful PUT request is expected to return 200 HTTP status codes.

An example usage of the PUT method in Java Spring Boot:

PUT /books/1
@PutMapping(path = "/books/{id}", headers = Constant.API_VERSION_HEADER_NAME + "=v1.0")
public ResponseEntity<BookDTO> updateWholeBookV1dot0(@PathVariable int id, @RequestBody Book book) throws RequestBodyException {
if (book == null || book.getName() == null || book.getAuthor() == null || book.getImageS3Id() == null) {
throw new RequestBodyException("All book fields are required for PUT method.");
}

// Validate that the ID in the request body matches the ID in the path
if (book.getId() != id) {
throw new RequestBodyException("The ID in the request body does not match the ID in the path.");
}

Book existingBook = bookRepository.findById(id).orElse(null);
if (existingBook == null) {
throw new BookNotFoundException("There is no book with this id.");
}

// Update the book's fields only if they are different from the current values
if (!existingBook.getName().equals(book.getName())) {
existingBook.setName(book.getName());
}

if (!existingBook.getAuthor().equals(book.getAuthor())) {
existingBook.setAuthor(book.getAuthor());
}

if (!existingBook.getImageS3Id().equals(book.getImageS3Id())) {
existingBook.setImageS3Id(book.getImageS3Id());
}

if (!existingBook.isVisible() == book.isVisible()) {
existingBook.setVisible(book.isVisible());
}


Book updatedBook = bookRepository.save(existingBook);

BookDTO bookDTO = new BookDTO();
bookDTO.setId(updatedBook.getId());
bookDTO.setName(updatedBook.getName());
bookDTO.setAuthor(updatedBook.getAuthor());
bookDTO.setVisible(updatedBook.isVisible());
bookDTO.setImageS3Id(updatedBook.getImageS3Id());
return ResponseEntity.ok(bookDTO);
}
  • PATCH

The PATCH method should be used when updating one or more properties of an object. To give an example on the book object, if you want to update only the name of that, you should use this method. A successful PATCH request is expected to return 200 HTTP status codes.

PATCH /books/1
@PatchMapping(path = "/books/{id}", headers = Constant.API_VERSION_HEADER_NAME + "=v1.0")
public ResponseEntity<BookDTO> updatePartialBookV1dot0(@PathVariable int id, @RequestBody Book book) throws RequestBodyException {
// Validate the request body for PATCH method to ensure at least one field is present
if (book == null || (book.getName() == null && book.getAuthor() == null && book.getImageS3Id() == null)) {
throw new RequestBodyException("At least one book field is required for PATCH method.");
}

Book existingBook = bookRepository.findById(id).orElse(null);
if (existingBook == null) {
throw new BookNotFoundException("There is no book with this id.");
}

// Update the book's fields if they are present and different from the current values
if (book.getName() != null && !existingBook.getName().equals(book.getName())) {
existingBook.setName(book.getName());
}

if (book.getAuthor() != null && !existingBook.getAuthor().equals(book.getAuthor())) {
existingBook.setAuthor(book.getAuthor());
}

if (book.getImageS3Id() != null && !existingBook.getImageS3Id().equals(book.getImageS3Id())) {
existingBook.setImageS3Id(book.getImageS3Id());
}

if (existingBook.isVisible() != book.isVisible()) {
existingBook.setVisible(book.isVisible());
}

Book updatedBook = bookRepository.save(existingBook);

BookDTO bookDTO = new BookDTO();
bookDTO.setId(updatedBook.getId());
bookDTO.setName(updatedBook.getName());
bookDTO.setAuthor(updatedBook.getAuthor());
bookDTO.setImageS3Id(updatedBook.getImageS3Id());
bookDTO.setVisible(updatedBook.isVisible());
return ResponseEntity.ok(bookDTO);
}
  • DELETE

As the name suggests, the DELETE method should be used when deleting data. A successful DELETE request is expected to return 200 or 204 HTTP status codes.

DELETE /books/1
@DeleteMapping("/books/{id}")
public ResponseEntity<?> deleteBook(@PathVariable int id) {
// Check if the book with the given ID exists in the database
Book existingBook = bookRepository.findById(id).orElse(null);
if (existingBook == null) {
throw new BookNotFoundException("There is no book with this id.");
}
bookRepository.deleteById(id);
return ResponseEntity.noContent().build();
}

We learned their general meanings and how to use them. But there is one important thing I would like to add. While preparing the endpoints, we should not send the ID or Primary Key value in the request body. Instead, we need to place it in the URL correctly.

For example;
To list the books in the database:
GET /books
To review a book in the database:
GET /books/1
To add a book to the database:
POST /books
To update a book in the database:
PUT /books/1
To update the name of a book in the database:
PATCH /books/1
To delete a book in the database:
DELETE /books/1
To list a user’s books in the database:
GET /users/1/books
To review a book by a user in the database:
GET /users/1/books/1

Now that we have examined the examples, we can move on to the principles.

Principles

When designing a REST API, we must strictly adhere to 6 principles in total. We must grasp these principles well and integrate them while coding.

  • Cacheable

We should be able to measure whether an endpoint is cacheable on the server side. Because it will cost time and money to retrieve data that does not change frequently from a continuous source. The client does not like to wait too long. Therefore, data should be presented to them quickly via a cache service like Elasticache Redis.

@GetMapping(path = "/books", headers = Constant.API_VERSION_HEADER_NAME + "=v1.0")
@Cacheable("books")
public CustomPageResponse getBooksV1dot0(
@RequestParam(required = false) String name,
@RequestParam(required = false) String author,
@RequestParam(required = false) String search,
@RequestParam(defaultValue = "true", required = false) String visible,
@RequestParam(defaultValue = "asc") String sortDirection,
@RequestParam(defaultValue = "id") String sortBy,
Pageable pageable
) {
...
}
  • Code On-Demand

Among the principles, this is the only one that is optionally applicable. It is not mandatory to apply. In the responses returned by the server on the client, there may be an executable code that the client can use directly. For example, we can say Javascript codes returning from the server. The benefit of this is to improve the flexibility of the client side. Because the server can decide from case to case what the client will do. In this way, the client does not always use if-else statements and we produce a cleaner code.

  • Stateless

When a request is sent from the client to the server, it must be completely stateless. If a request can be sent depending on any component on the client side, this is unfortunately not correct. The API cannot be scaled easily in this case. At the same time, when sending a request, there must be information necessary for the server to understand this request, and when the server receives this request, it should not need another request or function to read the information in the request.

I would like to add an important note that the session should always be time-based on the client side. The server should only use the session to authenticate the request and not store the session.

  • Layered System

When sending a request, the client cannot know exactly whether it is sending a request to the endpoint or to a proxy. Therefore, you should have the opportunity to add more than one layer between the server and the client. These layers can be used for more than one purpose. For example, you can use it as a Load Balancer or as a Security Policy layer.

  • Client-Server

The client and server must be completely separate from each other. Any API query should be able to work correctly on different platforms, completely independently. An update on the client should not affect the server side.

  • Uniform Interface

The uniform interface has a total of 4 different features. Thanks to these features, the interface is separated from the implementation. As a result, a more scalable API is created.

The best example of a uniform interface is internet browsers. You use a single browser to read news, browse social media or talk to your friends, right? Here is the best example of a uniform interface where you can do all the work and at the same time develop all the work independently of each other.

A uniform interface allows the conversation between the client and server to always be in one language. In this way, the developments become more standardized and proceed in a logical way. For example, take a look at the image below.

rest uniform interface

Let’s check a little longer on the most complex part, HATEOAS. When sending any request to the server, the client can send it as headers, query parameters, URLs, or body contents. At the same time, the server can respond to this request with header, body, or response codes.

For example, let’s look at a response from a server:

{
"id": 1,
"name": "Cracking the Coding Interview",
"author": "Gayle Laakmann McDowell",
"numberOfPages": 200,
"links": {
"href": "10/books",
"rel": "books",
"type": "GET",
}
}

As you can see the Server helped the Client side how to make a request.

Best Practices

Every REST API should definitely have 7 common features, which I will list below. We can say that an API with these features has been designed successfully.

Simple and Fine-Grained

Always keep your endpoints simple and clear. It can mimic your system’s underlying application domain or database architecture. The client does not want to open the documents when trying to request an endpoint. They can easily send requests according to standards.

Pagination

Endpoints that need to return a lot of results should use pagination. But it doesn’t just end here. When using pagination, the body content must have “first, last, next, prev” values and the client should be able to use them easily.

Filtering/Ordering

Endpoints that return multiple data should have filtering and ordering features. At the same time, it should be possible to intervene in which fields the data to be returned will have. Because the returned data should always be the data that the Client needs. Otherwise, the bandwidth will swell and costs will be incurred.

Resource Naming

You should definitely group your data well. Because when naming your endpoints, you will prepare them according to the names of these data. Remember that REST APIs are made for consumers. Not for the producers. Resource naming and URL structure should be clear. Always design for your clients, not your data.

I would like to make a short and important note. Always use pluralization in your URLs. It would be more accurate to use /books instead of /book for clarity.

Versioning

The prospect of making drastic API changes, in the long run, is not a good prospect. But sometimes you can’t see it and some drastic changes are necessary. In this case, you should definitely consider adding versioning to your API endpoints. There are 3 different versioning types in total.

URL Versioning: /api/v1/books
Headers Versioning: Api-Version:v1
Query Param Versioning: api/books?api-version=v1

Caching

If your endpoints frequently return the same data, it would make sense to cache them. We’ve already talked about this in the principles, but at this point, it doesn’t end with just caching. At the same time, you should inform the client about the cache details. It would be best to give this information with Response headers. It is very useful for the Client to set headers such as Cache-Control, Expires, Pragma, Last-Modified etc..

Security

It’s time for a must-have feature for a REST API. Security. When setting the security of an endpoint, we need to pay attention to 5 different items in total.

  • Authorization/Authentication

While preparing your endpoints, you need to choose whether that endpoint will be accessible to everyone. In this case, only authorized users can access that endpoint. For example, we want everyone to be able to see the books. But who wants everyone to be able to delete the books in the database?

  • CORS

Implementing the CORS feature on endpoints is very simple. You just need to add values like Access-Control-Allow-Origin or Access-Control-Allow-Credentials to the HTTP header. When CORS is enabled, the clients that you have given permission to the relevant endpoint can send requests. This could be from other sources as well.

  • TLS

All your connections must use SSL for confidentiality reasons. But OAuth2 requires the authorization server and access token credentials to use TLS.

  • Idempotence

Each endpoint should be able to give the same response to the same request every time. When the same request is sent to the same endpoint twice, scalability and improvability problems may arise if different responses are received. Inconsistencies are not tolerated when implementing a REST API.

  • Input Validation

Always make sure to validate all the input values in the sent requests. Because you avoid potential SQL or NoSQL injections and protect your database. In particular, don’t be afraid to limit the size of messages that can be sent. When an error occurs, only show that error to the client, do not report lines of code.

Yes, now you will be able to design a REST API yourself and make it secure. Today, most of the professional frameworks offer you all this in a way that makes it easier for me. Therefore, it may be useful for you to practice using these frameworks at the beginning stage. For example Laravel, Spring Boot etc.

Thanks for reading the content,
See you in the next one.
Emrecan Ozkan.

--

--