Custom REST API endpoint with SpringBot

In this article, we will create an extra endpoint to an existing entity while enforcing Spring security and easily adding to the Swagger API docs.


For this activity we will using the Learning Management System (LMS) - Example project and we will be adding a custom REST to retrieve a list of all Lessons which have been started by the current user.

LMS Spring Entity diagram

User Story : As a Content Creator I want be able to find all lessons that have been started by different users.

Note: For the purpose of this activity, we will define a lesson as started if there exists a Lesson submission for a lesson and the current user.

To assist us we will be using a third party REST client called Insomnia which provides a helpful interface for interacting with HTTP-based APIs as well as our built in Swagger UI to explore our REST API.

Swagger interface

Setup

  1. Clone a copy of the LMS Spring project, which can be found in the git repository SpringBot LMS project
  2. Download and install Insomnia
  3. Within the Insomnia application, Create a new request and populate it with the following CURL request:
curl --request POST \
  --url http://localhost:8080/auth/login \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --header 'Origin: http://localhost:8080' \
  --data username=super%40example.com \
  --data password=password

See below to see how to copy CURL requests into Insomnia

Run the above request to ensure that the authentication cookie is up to date.

Note: (Ensure this is done before performing another request)

  1. Navigate to the admin side of the application, select the ‘Lesson’ tab on the left navigation page and add some lessons to the application.
  1. For one of the lessons, create a submission to be used later when applying filters.

Creating the REST GET endpoint

  1. Open LessonController.java find the protected region called Add any additional endpoints here and turn it on.
// % protected region % [Add any additional endpoints here] on begin
// % protected region % [Add any additional endpoints here] end
  1. Add a new controller get endpoint by adding the following into the protected region.
@GetMapping(value = "/get-started-lessons", produces = "application/json")
public ResponseEntity<List<LessonEntity>> getStartedLessons() {
    var lessons = new ArrayList<LessonEntity>();
    return new ResponseEntity<>(lessons, HttpStatus.OK);
}

What we have done here is:

  • Set an API URL /api/lesson/get-started-lessons with the first part being contributed by the LessonController.
  • Returned a 200 OK response with an empty list as the response body

To test this, copy the following request into Insomnia.

curl --request GET \
  --url http://localhost:8080/api/lesson/get-started-lessons \

Our response when querying this endpoint with is:

[]
  1. To retrieve some lessons, replace the lesson variable declaration with a basic service call.
@GetMapping(value = "/get-started-lessons", produces = "application/json")
public ResponseEntity<List<LessonEntity>> getStartedLessons() {
    var lessons = lessonService.findAllWithPage(1, 10);
    return new ResponseEntity<>(lessons, HttpStatus.OK);
}

At this stage we are just retrieving a single standard page worth of lessons with zero filtering applied.

The response for this request would appear something like follows:

[
  {
    "id": "9681e997-1906-430a-9ef1-82be2b3bc0a6",
    "created": "2021-01-07T13:36:24.918754+10:00",
    "modified": "2021-01-07T13:36:24.918754+10:00",
    "summary": "Et iure aperiam ut i",
    "description": "Cum quibusdam quo au",
    "duration": 59,
    "name": "Andrew Guthrie"
  },
  {
    "id": "7e24b078-d6df-4dd6-a3b5-4ea8cd63a82b",
    "created": "2021-01-07T13:36:28.662861+10:00",
    "modified": "2021-01-07T13:36:28.662861+10:00",
    "summary": "Similique doloribus ",
    "description": "Similique harum mini",
    "duration": 8,
    "name": "Kylie Ramos"
  },
 ...
]
  1. Next we need to add support for pagination. So far we have only retrieved the first page with a page size of 10, to allow us to modify this we will add two new properties, page and pageSize and utilise them in our service query.
@GetMapping(value = "/get-started-lessons", produces = "application/json")
public ResponseEntity<List<LessonEntity>> getStartedLessons(
        @ApiParam("The page to return.")
        @RequestParam(value = "page", defaultValue = "1", required = false) int page,
        @ApiParam("The size of the page to return.")
        @RequestParam(value = "pageSize", defaultValue = "10", required = false) int pageSize
) {
    var lessons = lessonService.findAllWithPage((page > 0) ? page - 1: page, pageSize);
    return new ResponseEntity<>(lessons, HttpStatus.OK);
}

By adding the @ApiParam annotation, we have added more detail to your API documentation. This can be seen by looking at the Swagger documentation for this endpoint.

Swagger Documentation

To test this, update the URL of the endpoint to be something like the following:

http://localhost:8080/api/lesson/get-started-lessons?page=1&pageSize=2

You should now only see two results after executing the request.expo

Filtering our result

For this section we are moving into the service layer for demonstration purposes. This is not strictly required but can be useful. To understand the service layer further refer to Custom business logic with SpringBot.
Now that we have a functional REST endpoint we want to filter by lessons that have been started. To begin with, we need to add a new condition to our LessonService

  1. Open LessonService.java and within the protected region called Add any additional cases for the custom query parameters here, add a new case.
// % protected region % [Add any additional cases for the custom query parameters here] on begin  
case "isStarted":  
    QLessonFormVersionEntity formVersionEntity = QLessonFormVersionEntity.lessonFormVersionEntity;  
    QLessonFormSubmissionEntity lessonFormSubmissionEntity =            QLessonFormSubmissionEntity.lessonFormSubmissionEntity;  
    predicate = entity.id.in(  
         JPAExpressions.select(entity.id)  
               .innerJoin(entity.publishedVersion, formVersionEntity)  
               .innerJoin(formVersionEntity.submission, lessonFormSubmissionEntity)
               .where(lessonFormSubmissionEntity.createdBy.eq(UUID.fromString(condition.getValue())))
   );
// % protected region % [Add any additional cases for the custom query parameters here] end

What we have done here is created a QueryDSL query from the following SQL

select public.lesson_entity.id
from public.lesson_entity
         inner join lesson_form_version_entity lfve on lesson_entity.id = lfve.form_id
         inner join lesson_form_submission_entity lfse on lfve.id = lfse.submitted_form_id
where lfve.created_by = ?;

As you can see in our QueryDSL query, we have two inner joins same as our SQL. We do not need to define the connections as these are inferred by our entity models. For more details on QueryDSL please visit the QueryDSL tutorial.

  1. Now update our request to use this new condition.
@GetMapping(value = "/get-started-lessons", produces = "application/json")
public ResponseEntity<List<LessonEntity>> getStartedLessons(
        @ApiParam("The page to return.")
        @RequestParam(value = "page", defaultValue = "1", required = false) int page,
        @ApiParam("The size of the page to return.")
        @RequestParam(value = "pageSize", defaultValue = "10", required = false) int pageSize
) {

    var securityContext = SecurityContextHolder.getContext().getAuthentication();
    UserEntity loggedInUser = null;
    if (securityContext != null) {
        loggedInUser = (UserEntity) securityContext.getPrincipal();
    }
    var isStarted = new Where();
    isStarted.setPath("isStarted");
    isStarted.setValue((loggedInUser != null) ? loggedInUser.getId().toString() : null);

    var lessons = lessonService.findSortedPageWithQuery(
            (page > 0) ? page - 1: page,
            pageSize,
            Collections.singletonList(
                    Collections.singletonList(isStarted)
            ),
            Sort.by("modified").descending()
    );
    return new ResponseEntity<>(lessons, HttpStatus.OK);

As you can see in this example, we have defined a single Where that utilises our new condition. This is then past down to one of our existing service methods with a default sort applied. We also set the value of our logged in user as pulled from our security context.

  1. Add the additional imports within the protected region [Add any additional imports here]:
// % protected region % [Add any additional imports here] on begin
import org.springframework.data.domain.Sort;
import lmsspring.graphql.utils.Where;
// % protected region % [Add any additional imports here] end

Add security

Currently, there is no security measures in place. Anyone with credentials to the application can access this endpoint, regardless of their level of access. However, given we are using an existing service method our data is still safe and any attempt to access the data will result in a 403.

The code that exists that allows this is the PreAuthorize annotation on the LessonService#findSortedPageWithQuery method :

@PreAuthorize("hasPermission('LessonEntity', 'read')")
  1. To fully lock down our endpoint, we will add this same annotation to our new endpoint as follows:
// % protected region % [Add any additional endpoints here] on begin  
@PreAuthorize("hasPermission('LessonEntity', 'read')")  
@GetMapping(value = "/get-started-lessons", produces = "application/json")  
public ResponseEntity<List<LessonEntity>> getStartedLessons(
    ...

Testing

Given this is a custom endpoint, we would want to add a test to verify that it works as expected. To simplify we will be doing a unit test which gives us access to the application runtime context.

  1. Create a new package called services inside of the serverside/src/test/java/lmsspring/ directory.
  2. Create a new class called LessonServiceTest inside of this new directory
  3. Add the following annotations to our new class:
@Tag("service")  
@Tag("humanWritten")

Our class at this stage should appear as follows:

package lmsspring.services;  


import org.junit.jupiter.api.*;

@Tag("service")  
@Tag("humanWritten")
public class LessonServiceTest {  
}
  1. Setup our service under test and our Mocks.
private LessonController lessonController;  
private LessonEntity entity;  

@Mock  
private LessonService lessonService;  

@Mock  
private AuthenticationService authenticationService;
  1. Add our mock setup
@BeforeEach
void setup() {
    MockitoAnnotations.initMocks(this);

    entity = new LessonEntity();
    entity.setDifficulty(DifficultyEnum.BEGINNER);
    entity.setDuration(10);
    entity.setName("Example Lesson");

    var isStarted = new Where();
    isStarted.setPath("isStarted");

    when(
            lessonService.findSortedPageWithQuery(
                    0,
                    10,
                    Collections.singletonList(
                            Collections.singletonList(isStarted)
                    ),
                    Sort.by("modified").descending()
            )
    ).thenReturn(Collections.singletonList(entity));
    lessonController = new LessonController(authenticationService, lessonService);
}

In this snippet, we are doing the following:

  • Creating a simple entity that we will retrieve,
  • Mocking the service so that it returns this entity when provided our filter
  • Initializing our controller.
  1. Write our test
@Test
void testLessonController() {
    var expectedResponse = new ResponseEntity<>(Collections.singletonList(entity), HttpStatus.NO_CONTENT);
    var actualResponse = lessonController.getStartedLessons(1, 10);

    assertEquals(expectedResponse, actualResponse);
}

Our LessonControllerTest class should now appear as follows

package lmsspring.controllers;

import lmsspring.configs.security.services.AuthenticationService;
import lmsspring.entities.LessonEntity;
import lmsspring.entities.enums.DifficultyEnum;
import lmsspring.graphql.utils.Where;
import lmsspring.services.LessonService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Tag;
import org.junit.jupiter.api.Test;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;

import java.util.Collections;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.Mockito.when;


@Tag("service")
@Tag("humanWritten")
public class LessonControllerTest {

    private LessonController lessonController;
    private LessonEntity entity;

    @Mock
    private LessonService lessonService;

    @Mock
    private AuthenticationService authenticationService;

    @BeforeEach
    void setup() {
        MockitoAnnotations.initMocks(this);

        entity = new LessonEntity();
        entity.setDifficulty(DifficultyEnum.BEGINNER);
        entity.setDuration(10);
        entity.setName("Example Lesson");

        var isStarted = new Where();
        isStarted.setPath("isStarted");

        when(
                lessonService.findSortedPageWithQuery(
                        0,
                        10,
                        Collections.singletonList(
                                Collections.singletonList(isStarted)
                        ),
                        Sort.by("modified").descending()
                )
        ).thenReturn(Collections.singletonList(entity));
        lessonController = new LessonController(authenticationService, lessonService);
    }

    @Test
    void testGetStartedLessonsControllerEndpoint() {
        var expectedResponse = new ResponseEntity<>(Collections.singletonList(entity), HttpStatus.OK);
        var actualResponse = lessonController.getStartedLessons(1, 10);

        assertEquals(expectedResponse, actualResponse);
    }
}

This of course does not test our query, for that we will need an integration test.

This will be left as an exercise of the reader.

Next steps,

Adding this same functionality to GraphQL will be covered in Custom GraphQL API with SpringBot.

Solution

Have a look at the custom-api branch to see the code solution.

Video

Our ‘How To: Build a custom API with REST and GRAPHQL’ video outlines the steps above.