Spring Boot REST API Best Practices - Part 2
In this Spring Boot REST API Best Practices - Part-2, I will explain some of the best practices we should follow while implementing Create and Update API endpoints.
- Spring Boot REST API Best Practices - Part 1 : Implementing Get Collection API
- Spring Boot REST API Best Practices - Part 2 : Implementing Create and Update APIs (This article)
- Spring Boot REST API Best Practices - Part 3 : Implementing FindById and DeleteById APIs
- Spring Boot REST API Best Practices - Part 4 : Exception Handling in REST APIs
This article is a continuation of Spring Boot REST API Best Practices - Part 1. So, if you haven’t already, please read Part-1 first. We are going to build the APIs on top of the code we have implemented in Part-1.
You can find the sample code for this tutorial in this GitHub repository.
Implementing POST /api/bookmarks API endpoint
We may think of implementing the POST /api/bookmarks API endpoint as follows:
package com.sivalabs.bookmarks.api.controllers;
import com.sivalabs.bookmarks.domain.BookmarkService;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/bookmarks")
class BookmarkController {
private final BookmarkService bookmarkService;
//...
//...
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
BookmarkDTO create(@RequestBody @Validated BookmarkDTO bookmark) {
return bookmarkService.create(bookmark);
}
}
At a glance this may looks fine.
- We are not using JPA entities to bind request payload or returning as response.
- We are returning the proper response code 201 when the bookmark resource is successfully created.
If we generate Open API documentation from this code using springdoc-openapi then the expected request payload will be shown as follows:
{
"id": 0,
"title": "",
"url": "",
"createdAt": ""
}
When I look at the request payload, I have a bunch of questions:
- Should I generate the id from client side and send it in the payload, or it will be generated on the server-side?
- If I include the id in the request payload, does it override the bookmark details if an entity wth same id value exist or ignore the id and creates a new bookmark?
- Should I include createdAt or server will use the timestamp of the record insertion into DB?
- What if I set a future date for createdAt?
All these questions came up because we are not explicit in our contract.
The actual API behaviour we want is, the client should only send the title and url. Then we will automatically generate the id and use the current timestamp as createdAt value.
To avoid the confusion and bring more clarity to what is the expected payload, it is better to create a request class for this specific API endpoint as follows:
package com.sivalabs.bookmarks.api.models;
import jakarta.validation.constraints.NotEmpty;
public record CreateBookmarkRequest(
@NotEmpty(message = "Title is required")
String title,
@NotEmpty(message = "URL is required")
String url) {
}
The next question that comes to our mind is, should we return BookmarkDTO or ResponseEntity<BookmarkDTO>?
I would prefer to use ResponseEntity as return type if:
- I need to send different HTTP Status Codes for different kinds of failures or validation errors.
- I need to add headers.
Basically, if I want more fine-grained control over the response I would choose ResponseEntity, otherwise I simply return the response object.
Now we have some clarity on how our Controller method implementation would look like. What about Service layer implementation?
Should we send CreateBookmarkRequest as input to BookmarkService.create(…) method? Or, create BookmarkDTO object from CreateBookmarkRequest and then send it to BookmarkService.create(…) method?
My preference is to create a new CreateBookmarkCommand class with title and url properties and send it to BookmarkService.create(…) method. This may seem unnecessary because CreateBookmarkRequest and CreateBookmarkCommand are exactly same in this scenario.
But imagine this API endpoint can only be invoked by an authenticated user. Then we may need to include createdBy property in the input to BookmarkService.create(…) method which is not available in CreateBookmarkRequest. So, to keep each layer’s responsibilities separate, I would use a separate command object.
package com.sivalabs.bookmarks.domain;
public record CreateBookmarkCommand(String title, String url) {}
Here is the final implementation for POST /api/bookmarks API endpoint.
BookmarkService.java
@Service
@Transactional(readOnly = true)
public class BookmarkService {
private final BookmarkRepository repo;
//...
//...
@Transactional
public BookmarkDTO create(CreateBookmarkCommand cmd) {
Bookmark bookmark = new Bookmark();
bookmark.setTitle(cmd.title());
bookmark.setUrl(cmd.url());
bookmark.setCreatedAt(Instant.now());
return BookmarkDTO.from(repo.save(bookmark));
}
}
To create BookmarkDTO instance from Bookmark entity we will create a static method as follows:
package com.sivalabs.bookmarks.domain;
import java.time.Instant;
public record BookmarkDTO(
Long id,
String title,
String url,
Instant createdAt
) {
static BookmarkDTO from(Bookmark bookmark) {
return new BookmarkDTO(bookmark.getId(),
bookmark.getTitle(),
bookmark.getUrl(),
bookmark.getCreatedAt()
);
}
}
BookmarkController.java
package com.sivalabs.bookmarks.api.controllers;
@RestController
@RequestMapping("/api/bookmarks")
class BookmarkController {
private final BookmarkService bookmarkService;
//...
//...
@PostMapping
ResponseEntity<BookmarkDTO> create(@RequestBody @Validated CreateBookmarkRequest request) {
CreateBookmarkCommand cmd = new CreateBookmarkCommand(request.title(), request.url());
BookmarkDTO bookmark = bookmarkService.create(cmd);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/api/bookmarks/{id}")
.buildAndExpand(bookmark.id()).toUri();
return ResponseEntity.created(location).body(bookmark);
}
}
Now you can start the application and invoke the API endpoint using CURL as follows:
$ curl --location 'http://localhost:8080/api/bookmarks' \
--header 'Content-Type: application/json' \
--data '{
"title": "SivaLabs blog",
"url": "https://sivalabs.in"
}'
// response
{"id":17,"title":"SivaLabs blog","url":"https://sivalabs.in","createdAt":"2023-08-23T04:24:17.975268Z"}
Implementing PUT /api/bookmarks/{id} API endpoint
We will follow the similar pattern and implement the PUT /api/bookmarks/{id} API endpoint for updating a bookmark.
UpdateBookmarkCommand.java
package com.sivalabs.bookmarks.domain;
public record UpdateBookmarkCommand(
Long id,
String title,
String url) {
}
BookmarkNotFoundException.java
package com.sivalabs.bookmarks.domain;
public class BookmarkNotFoundException extends RuntimeException {
public BookmarkNotFoundException(Long id) {
super(String.format("Bookmark with id=%d not found", id));
}
public static BookmarkNotFoundException of(Long id) {
return new BookmarkNotFoundException(id);
}
}
BookmarkService.java
package com.sivalabs.bookmarks.domain;
@Service
@Transactional(readOnly = true)
public class BookmarkService {
private final BookmarkRepository repo;
//...
//...
@Transactional
public void update(UpdateBookmarkCommand cmd) {
Bookmark bookmark = repo.findById(cmd.id())
.orElseThrow(() -> BookmarkNotFoundException.of(cmd.id()));
bookmark.setTitle(cmd.title());
bookmark.setUrl(cmd.url());
bookmark.setUpdatedAt(Instant.now());
repo.save(bookmark);
}
}
UpdateBookmarkRequest.java
package com.sivalabs.bookmarks.api.models;
import jakarta.validation.constraints.NotEmpty;
public record UpdateBookmarkRequest(
@NotEmpty(message = "Title is required")
String title,
@NotEmpty(message = "URL is required")
String url) {
}
BookmarkController.java
package com.sivalabs.bookmarks.api.controllers;
@RestController
@RequestMapping("/api/bookmarks")
class BookmarkController {
private final BookmarkService bookmarkService;
@PutMapping("/{id}")
void update(@PathVariable(name = "id") Long id,
@RequestBody @Validated UpdateBookmarkRequest request) {
UpdateBookmarkCommand cmd = new UpdateBookmarkCommand(id, request.title(), request.url());
bookmarkService.update(cmd);
}
}
You can try invoking this endpoint using CURL as follows:
$ curl -v --location --request PUT 'http://localhost:8080/api/bookmarks/17' \
--header 'Content-Type: application/json' \
--data '{
"title": "SivaLabs - TechBlog",
"url": "https://www.sivalabs.in"
}'
There are a couple of things that can be improved in the current implementation:
- Exception Handling - This we will tackle in the upcoming part in this series.
- Inserting and updating the createdAt and updatedAt column values.
In the current implementation, we are manually setting the createdAt and updatedAt values as follows:
bookmark.setCreatedAt(Instant.now());
bookmark.setUpdatedAt(Instant.now());
But instead of manually setting these values, we can leverage some of the JPA and Spring Data JPA features to automatically set these values while inserting or updating the entities.
Using @PrePersist and @PreUpdate
We can use JPA’s @PrePersist and @PreUpdate annotations to automatically set the createdAt and updatedAt values as follows:
@Entity
class Bookmark {
...
...
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "updated_at", insertable = false)
private Instant updatedAt;
@PreUpdate
@PrePersist
public void updateTimeStamps() {
updatedAt = Instant.now();
if (createdAt == null) {
createdAt = Instant.now();
}
}
}
Using Spring Data JPA’s @CreatedDate and @LastModifiedDate
You can also use Spring Data JPA’s @CreatedDate and @LastModifiedDate annotations to automatically set the createdAt and updatedAt values as follows:
@Entity
@EntityListeners(AuditingEntityListener.class)
class Bookmark {
...
...
@Column(name = "created_at", nullable = false, updatable = false)
@CreatedDate
private Instant createdAt;
@Column(name = "updated_at", insertable = false)
@LastModifiedDate
private Instant updatedAt;
}
You also need to enable auditing in your Spring Boot application by adding @EnableJpaAuditing annotation on a configuration class.
Hibernate also provides @CreationTimestamp and @UpdateTimestamp annotations for similar purpose. But I prefer to use one of the above-mentioned 2 approaches to keep the code independent of underlying persistence implementation framework.
Which Java DataType to use for storing Date or Timestamp in Database?
Please read this excellent StackOverflow Answer to find which Java datatype is more suitable for storing Date or Timestamp values in the database.
Testing the API endpoint using RestAssured and Testcontainers
Let’s write tests for our API endpoints as follows:
package com.sivalabs.bookmarks.api.controllers;
import com.sivalabs.bookmarks.domain.BookmarkDTO;
import com.sivalabs.bookmarks.domain.BookmarkService;
import com.sivalabs.bookmarks.domain.CreateBookmarkCommand;
import io.restassured.RestAssured;
import io.restassured.http.ContentType;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.web.server.LocalServerPort;
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
import org.testcontainers.containers.PostgreSQLContainer;
import org.testcontainers.junit.jupiter.Container;
import org.testcontainers.junit.jupiter.Testcontainers;
import org.testcontainers.utility.DockerImageName;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.matchesRegex;
import static org.hamcrest.Matchers.notNullValue;
import static org.hamcrest.Matchers.nullValue;
import static org.springframework.boot.test.context.SpringBootTest.WebEnvironment.RANDOM_PORT;
@SpringBootTest(webEnvironment = RANDOM_PORT)
@Testcontainers
class BookmarkControllerTests {
@Container
@ServiceConnection
static PostgreSQLContainer<?> postgres =
new PostgreSQLContainer<>(DockerImageName.parse("postgres:15.4-alpine"));
@LocalServerPort
private Integer port;
@Autowired
private BookmarkService bookmarkService;
@BeforeEach
void setUp() {
RestAssured.port = port;
}
@Test
void shouldCreateBookmarkSuccessfully() {
given().contentType(ContentType.JSON)
.body(
"""
{
"title": "SivaLabs blog",
"url": "https://sivalabs.in"
}
""")
.when()
.post("/api/bookmarks")
.then()
.statusCode(201)
.header("Location", matchesRegex(".*/api/bookmarks/[0-9]+$"))
.body("id", notNullValue())
.body("title", equalTo("SivaLabs blog"))
.body("url", equalTo("https://sivalabs.in"))
.body("createdAt", notNullValue())
.body("updatedAt", nullValue());
}
@Test
void shouldUpdateBookmarkSuccessfully() {
CreateBookmarkCommand cmd = new CreateBookmarkCommand("SivaLabs blog", "https://sivalabs.in");
BookmarkDTO bookmark = bookmarkService.create(cmd);
given().contentType(ContentType.JSON)
.body(
"""
{
"title": "SivaLabs - Tech Blog",
"url": "https://www.sivalabs.in"
}
""")
.when()
.put("/api/bookmarks/{id}", bookmark.id())
.then()
.statusCode(200);
}
}
Now you can run the tests using ./mvnw test from the command-line.
We will explore how to implement FindById and DeleteById API endpoints in the upcoming Part-3 in this series.
Spring Boot Tutorials
You can find more Spring Boot tutorials on Spring Boot Tutorials page.
Summary
In this second part of Spring Boot REST API Best Practices series, we have learned how to implement an API endpoints to create and update a resource by following some best practices.
You can find the sample code for this tutorial in this GitHub repository.
Related content
- Spring Boot + jOOQ Tutorial - 5 : Fetching Many-to-Many Relationships
- Spring Boot + jOOQ Tutorial - 4 : Fetching One-to-Many Relationships
- Spring Boot + jOOQ Tutorial - 3 : Fetching One-to-One Relationships
- Spring Boot + jOOQ Tutorial - 2 : Implementing CRUD Operations
- Spring Boot + jOOQ Tutorial - 1 : Getting Started