loading...

Spring Boot REST with JPA by Example

alfonzjanfrithz profile image Alfonz Jan Frithz ・11 min read

Project that we are building

We are going to build a todo list API, where we will have a Project that can have multiple topics. Each of the topics will have individual tasks.

A project could have one or more topics, and a topic will have one or more tasks.

In this post, we will integrate the existing application with Spring JPA. We will then write some integration tests to make sure our application works the way we want it. We will do it in the following sequences:

  • Modify our OpenAPI3 specification to accept POST request in /projects endpoint to save projects in the database.
  • Regenerate the code using the new specifications.
  • Add SpringBoot integration-test to make sure our implementation works the way we want it.
  • Add a POST implementation to save the Project in the database.

The source code for this post will be based on my previous post about Spring Boot Rest with OpenAPI 3. You can follow along with the previous post before follow along with this post, or you can just check out the code in this repo to get started at the same place with me.

If you are in a rush and like to see the ending instead, have a look at this repo.

If you want to know a little bit more about Spring Data JPA, here is an awesome post about The Persistent Layer with Spring Data JPA for your reference.

Introduction

As this post will guide through the implementation, if you want to directly see how the JPA implemented, instead of following along, you may scroll straight to the section of "JPA Implementation".

Write the Test

Objective

We will test the following scenarios:

  • Given few projects created, when user GET /projects then return all available projects
  • Given a project created with id 1, when user GET /projects/1 then return only project with id 1
  • Given a project created with name project-1, when user GET /projects?name=project-1 then return only project with name=project-1

Change the Test Mode to Spring Boot Test

@WebMvcTest is not relevant anymore in our here, because we want to perform the test up to the repository level. According to the official javadoc, it will @WebMvcTest will disable full auto-configuration and instead apply only configuration relevant to MVC tests. This mode will only relevant if only we will mock the repository & service and focus only on the testing up to the Controller.

@SpringBootTest is more relevant for our case as we will use full auto-configuration to test up to database level.

@AutoConfigureMockMvc will be required so that we could use MockMvc object when we use @SpringBootTest. It was not required as @WebMvcTest will give us that object for granted.

Here how our class going to look like now, you should be able to run this with the same result.

@SpringBootTest
@AutoConfigureMockMvc
@DisplayName("ProjectController Integration Test") // junit 5 display name. This will give us a little more flexibility to name our test
@Log4j2 // this come from lombok dependency so we could log things out
class ProjectControllerIT {
    @Autowired
    private MockMvc mockMvc;

    @Test
    @DisplayName("Should Return OK Response when request sent to /projects endpoint")
    public void shouldReturnOkOnProjectsEndpoint() throws Exception {
        mockMvc.perform(get("/projects"))
                .andExpect(status().isOk());
    }
}

Creating a helper test method to create a project

These helper method helps us to create a project from the endpoint. The snippet as follows.

class ProjectControllerIT {
  // previous snippet...

  private ProjectResponse createOneProject() {
    return createProject("project", 1).get(0);
  }

  private List<ProjectResponse> createProject(String prefix, int numberOfProject) {
    List<ProjectResponse> listOfResponse = IntStream.range(0, numberOfProject)
      .mapToObj(num -> createOneProjectWithName(format("%s-%d", prefix, num + 1)))
      .collect(Collectors.toList());

    return listOfResponse;
  }

  private ProjectResponse createOneProjectWithName(String name) {
    try {
      MvcResult result = mockMvc.perform(
        post("/projects")
          .contentType(APPLICATION_JSON)
          .content(project(name))) // we want to create the payload using this method.
        .andExpect(status().isCreated())
        .andReturn();

      String contentResponse = result.getResponse().getContentAsString();
      ProjectResponse project = ProjectModelMapper.jsonToProject(contentResponse);

      return project;
    } catch (Exception e) {
      throw new IllegalArgumentException("Invalid Request to Create Project with name" + name, e);
    }
  }
}

Notice that because we want to track what we have already created when we perform the POST, the following mapper will convert the content response to the ProjectResponse object.

// we will use this class later to also map Project from database object ot the ProjectResponse
public class ProjectModelMapper {
  private static ObjectMapper mapper = new ObjectMapper();

  public static ProjectResponse jsonToProject(String json) throws JsonProcessingException {
    return mapper.readValue(json, ProjectResponse.class);
  }
}

Another helper method we want to have in our testing is the method to create the payload that we want to send to the application so the code will be a little bit more readable.

public class ProjectRequestBuilder {
  private static ObjectMapper mapper = new ObjectMapper();

  public static String project(String name) {
    ProjectRequest pr = new ProjectRequest();
    pr.setName(name);

    return toJson(pr);
  }

  private static String toJson(Object obj) {
    try {
        return mapper.writeValueAsString(obj);
    } catch (JsonProcessingException e) {
        throw new IllegalArgumentException("Failed to parse JSON object", e);
    }
  }
}

Writing test code

We have everything we need now from the testing perspective, let's write the test code.

class ProjectControllerIT {
  // other test written before

  @Test
  @DisplayName("Should return all available projects when request sent to /projects endpoint")
  public void shouldReturnAllRecordsOnProjectsEndpoint() throws Exception {
    createProject("project", 2);

    mockMvc.perform(
      get("/projects"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$", hasSize(2)));

    createOneProject();

    mockMvc.perform(
      get("/projects"))
        .andExpect(status().isOk())
        .andExpect(jsonPath("$", hasSize(3)));
  }

  // other helper methods we wrote before
}    
  • In this test, we will call createProject to create a Project. Behind the scene, this method which will effectively fire up a POST request to /projects. It will form the JSON payload by transforming and object into JSON representation.
  • We created two projects in the first call, so when we call the /projects endpoint. we should get two projects in return. When we added another one, it will return us three results.

If we run this, we will get 501 in return because we haven't implemented anything for POST to /projects

The power of MockMVC

MockMvc helps us a lot writing a test, you can imagine this as your Postman that helps you fire specific request. It could also act as your eyes to validate what you want to see in a project.

The way it structured is as follows:

  • Perform an action The action can be a GET/POST or any other HTTP method. If you have anything in the request that you wanted to include, e.g headers or body, you can do so in the action part.
  • Assert the response .andExpect methods allows you to perform quite a number of assertions of the responses. You can pretty much validate any part of the response.
  • Return the result You can also return the result for later use. For example, the result of the previous call will be used for the future call.

JPA Implementation

Dependencies

We need spring-boot-starter-data-jpa to helps us integrate with JPA, and we also want to use h2 database to write the implementation using in-memory database. Lets add the following dependency to the pom.xml

<!--...-->
<dependency>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
  <groupId>com.h2database</groupId>
  <artifactId>h2</artifactId>
  <scope>runtime</scope>
</dependency>
<!--...-->

with those packages included in our dependencies, we need to put the configuration in our resource/application.yaml as follows:

spring:
  datasource:
    url: jdbc:h2:mem:db;DB_CLOSE_DELAY=-1
    username: super
    password: secret

This tells spring how to connect tot he database.

Entity class

As we want to store a project in a database, we need an entity class representation of it. To mark a class as an entity, you may use @Entity annotation.

The JPA default table name is the name of the class (minus the package) with the first letter capitalized. Each attribute of the class will be stored in a column in the table. @Table annotation allows us to specify a table name. The others annotation is lombok annotations to keep our code concise. @Id is used to mark the field as a primary key, and we can also specify if it is coming from auto-generated value using @GeneratedValue.

Here is our entity class

@Entity
@Builder
@AllArgsConstructor
@NoArgsConstructor
@Data
@Table(name = "project")
public class Project {
  @Id
  @GeneratedValue(strategy = GenerationType.SEQUENCE)
  private Long id;
  private String name;
}

Repository to perform database operations

JPA will make things a little handy for us, what we need now is to create an interface that extends JPARepository. The first generics indicate the object that will be used and the second is the type of the key.

public interface ProjectRepository extends JpaRepository<Project, Long> { }

This alone, has already give you a methods to perform basics operation such as findAll, save, delete. We will write a bit more modification later in this post.

Next, is either we call directly from the controller layer, or we wrap it in the service layer. I would prefer to put a service layer, for us to add a business logic at some point.

@Service
public class ProjectService {
  @Autowired
  private ProjectRepository projectRepository;

  public Project createProject(Project project) {
    return projectRepository.save(project);
  }

  public List<Project> retrieveAllProjects() {
    return projectRepository.findAll();
  }
}

Notice that we actually never done any implementation to save an object, but JPARepository keep us away from creating a lot of bolierplate code e.g prepare connection, build query, extract result set, and so on, and so forth.

Query methods offers by JPARepository

Later on in this post, we will need to query a project by a project name. That would means we need to find the project by name. However, if we look at the JPARepository, there is no method to find anything by name.

To add that, we can simply add a query method in the ProjectRepository interface as the following example:

public interface ProjectRepository extends JpaRepository<Project, Long> {
    List<Project> findAllByName(String name);
}

For more details of what can the query method does, refer to official documentation.

Thats all, we are ready to call the service from the controller and run back our test.

Controller implementation

In this section, we are going to implement the project creation, to save the project into the database. We will also enhance our implementation of searchProject so it will actually returns us the actual result from the database.

At this point, we have two Project object representation, one is facing the API ProjectRequest and ProjectResponse, another one is Project entity that facing the database. Therefore, when the object returned from the Repository or Service we need a mapper to map the entity object become the response. We will write that in this section too.

@Controller
public class ProjectController implements ProjectsApi {
  @Autowired
  private ProjectService service;

  @Override
  public ResponseEntity<List<ProjectResponse>> searchProjects(@Valid String name) {
    return ResponseEntity.ok(ProjectModelMapper.toApi(service.retrieveAllProjects()));
  }

  @Override
  public ResponseEntity<ProjectResponse> createProject(@Valid ProjectRequest projectRequest) {
    Project project = service.createProject(ProjectModelMapper.toEntity(projectRequest));
    return ResponseEntity.status(CREATED).body(toApi(project));
  }
}
public class ProjectModelMapper {
  private static ObjectMapper mapper = new ObjectMapper();

  public static Project toEntity(ProjectRequest projectRequest) {
    return Project.builder()
      .name(projectRequest.getName())
      .build();
  }

  public static ProjectResponse toApi(Project project) {
    ProjectResponse projectResponse = new ProjectResponse();
    projectResponse.setId(project.getId());
    projectResponse.setName(project.getName());

    return projectResponse;
  }

  public static List<ProjectResponse> toApi(List<Project> retrieveAllProjects) {
    return retrieveAllProjects.stream()
      .map(ProjectModelMapper::toApi)
      .collect(Collectors.toList());
  }

  public static ProjectResponse jsonToProject(String json) throws JsonProcessingException {
    return mapper.readValue(json, ProjectResponse.class);
  }
}

We should get our test pass for now.

More tests

We have written 1 out of 3 test that we said we want to do in the previous section, here is the remaining:

  • Given a project created with id 1, when user GET /projects/1 then return only project with id 1
  • Given a project created with name project-1, when user GET /projects?name=project-1 then return only project with name=project-1

Lets enhance our test code. In the following test, we will try to get the project by id. Given that the project created, we should be able to retrieved the project using the id that was returned before.

@Test
@DisplayName("Should be able to retrieve project by project id")
public void shouldRetrieveExistingProject() throws Exception {
  ProjectResponse project = createOneProjectWithName("project-1");

  Long projectId = project.getId();
  mockMvc.perform(
    get("/projects/" + projectId))
    .andExpect(status().isOk())
    .andExpect(jsonPath("$.name", is("project-1")))
    .andExpect(jsonPath("$.id", is(projectId.intValue())));
}

Obviously the test will fail, and returns us 501 errors indicating the functionality is not implemented yet. We should then enhance our controller to include the following methods.

@Override
public ResponseEntity<ProjectResponse> getProjects(@Min(1L) Long projectId) {
  Project project = service.findProject(projectId);
  return ResponseEntity.ok().body(toApi(project));
}

as findProject methods havent been implemented yet, lets get that implemented in our service level. The getOne method is the method that came with JPARepository we do not need to modify our repository implementation at this point.

public Project findProject(Long projectId) {
    return projectRepository.getOne(projectId);
}

And we got the new test pass now. However, we breaks the previous test. The reason why is because, the previous test was retrieving all the projects, but because of we have another test that is also adding the project into the database, the number doesn't add up correctly. The solution is pretty simple, we should reset the database state before we executing any test. To make sure our test is independent of each other.

We can add the following method into our test, this will make sure we clear up the project table before any test is executing.

@Autowired
private JdbcTemplate jdbcTemplate;

@BeforeEach
void setUp() {
  log.info("Deleting records from table");
  JdbcTestUtils.deleteFromTables(jdbcTemplate, "project");
}

Now all of our tests is passed again. The last bit of the test before we wrap up with this post is the following feature:

  • Given a project created with name project-1, when user GET /projects?name=project-1 then return only project with name=project-1

So here is the test:

@Test
@DisplayName("Should be able to search project by name")
public void shouldRetrieveExistingProjectByName() throws Exception {
  ProjectResponse projectOne = createOneProjectWithName("Project One");
  ProjectResponse projectTwo = createOneProjectWithName("Project Two");

  mockMvc.perform(
    get("/projects").param("name", "Project One"))
    .andExpect(status().isOk())
    .andExpect(jsonPath("$", hasSize(1)))
    .andExpect(jsonPath("$[0].id", is(projectOne.getId().intValue())))
    .andExpect(jsonPath("$[0].name", is(projectOne.getName())));

  mockMvc.perform(
    get("/projects").param("name", "Project Two"))
    .andExpect(status().isOk())
    .andExpect(jsonPath("$", hasSize(1)))
    .andExpect(jsonPath("$[0].id", is(projectTwo.getId().intValue())))
    .andExpect(jsonPath("$[0].name", is(projectTwo.getName())));

  mockMvc.perform(
    get("/projects").param("name", "Project X"))
    .andExpect(status().isOk())
    .andExpect(jsonPath("$", hasSize(0)));
}

Again, we will fail the test because we haven't get any implementation in our controller to filter the projects by name. It should complain that the number of returned project is more than what we expect. Let's fix the code to accomodate that.

We don't have findProjectsByName yet in our service, we may need that so our controller can use it to get some data from the repository. So lets add the new method to help us get the Project by name. We have findAllByName query methods added before.

public List<Project> findProjectsByName(String name) {
  return projectRepository.findAllByName(name);
}

At this point, what we need to do is to enhance our controller, to perform a search by name, if there is a name specified, otherwise, we will return all projects.

@Override
public ResponseEntity<List<ProjectResponse>> searchProjects(@Valid String name) {
  if (name != null) {
    return ResponseEntity.ok(ProjectModelMapper.toApi(service.findProjectsByName(name)));
  } else {
    return ResponseEntity.ok(ProjectModelMapper.toApi(service.retrieveAllProjects()));
  }
}

Wrap Up

Here is what we did so far:

  • Modify our OpenAPI3 specification to accept POST request in /projects endpoint to save projects in the database.
  • Regenerate the code using the new specifications.
  • Add SpringBoot integration-test to make sure our implementation works the way we want it.
  • Add a POST implementation to save the Project in the database.

To see the complete source code, you may checkout the repo in my github here

Discussion

pic
Editor guide