I just announced the new Spring Boot 2 material, coming in REST With Spring:

>> CHECK OUT THE COURSE

1. Overview

In this article we’re going to keep moving our little case study app forward by implementing small but useful improvements to the already existing features.

2. Better Tables

Let’s start by using the jQuery DataTables plugin to replace the old, basic tables the app was using before.

2.1. Post Repository and Service

First, we’ll add a method to count the scheduled posts of a user – leveraging the Spring Data syntax of course:

public interface PostRepository extends JpaRepository<Post, Long> {
    ...
    Long countByUser(User user);
}

Next, let’s take a quick look at the service layer implementation – retrieving the posts of a user based on pagination parameters:

@Override
public List<SimplePostDto> getPostsList(int page, int size, String sortDir, String sort) {
    PageRequest pageReq = new PageRequest(page, size, Sort.Direction.fromString(sortDir), sort);
    Page<Post> posts = postRepository.findByUser(userService.getCurrentUser(), pageReq);
    return constructDataAccordingToUserTimezone(posts.getContent());
}

We’re converting the dates based on the timezone of the user:

private List<SimplePostDto> constructDataAccordingToUserTimezone(List<Post> posts) {
    String timeZone = userService.getCurrentUser().getPreference().getTimezone();
    return posts.stream().map(post -> new SimplePostDto(
      post, convertToUserTomeZone(post.getSubmissionDate(), timeZone)))
      .collect(Collectors.toList());
}
private String convertToUserTomeZone(Date date, String timeZone) { 
    dateFormat.setTimeZone(TimeZone.getTimeZone(timeZone)); 
    return dateFormat.format(date); 
}

2.2. The API with Pagination

Next, we’re going to publish this operation with full pagination and sorting, via the API:

@RequestMapping(method = RequestMethod.GET)
@ResponseBody
public List<SimplePost> getScheduledPosts(
  @RequestParam(value = "page", required = false, defaultValue = "0") int page, 
  @RequestParam(value = "size", required = false, defaultValue = "10") int size,
  @RequestParam(value = "sortDir", required = false, defaultValue = "asc") String sortDir, 
  @RequestParam(value = "sort", required = false, defaultValue = "title") String sort, 
  HttpServletResponse response) {
    response.addHeader("PAGING_INFO", 
      scheduledPostService.generatePagingInfo(page, size).toString());
    return scheduledPostService.getPostsList(page, size, sortDir, sort);
}

Note how we’re using a custom header to pass the pagination info to the client. There are other, slightly more standard ways to do this – ways we might explore later.

This implementation however is simply enough – we have a simple method to generate paging information:

public PagingInfo generatePagingInfo(int page, int size) {
    long total = postRepository.countByUser(userService.getCurrentUser());
    return new PagingInfo(page, size, total);
}

And the PagingInfo itself:

public class PagingInfo {
    private long totalNoRecords;
    private int totalNoPages;
    private String uriToNextPage;
    private String uriToPrevPage;

    public PagingInfo(int page, int size, long totalNoRecords) {
        this.totalNoRecords = totalNoRecords;
        this.totalNoPages = Math.round(totalNoRecords / size);
        if (page > 0) {
            this.uriToPrevPage = "page=" + (page - 1) + "&size=" + size;
        }
        if (page < this.totalNoPages) {
            this.uriToNextPage = "page=" + (page + 1) + "&size=" + size;
        }
    }
}

2.3. Front End

Finally, the simple front-end will use a custom JS method to interact with the API and handle the jQuery DataTable parameters:

<table>
<thead><tr>
<th>Post title</th><th>Submission Date</th><th>Status</th>
<th>Resubmit Attempts left</th><th>Actions</th>
</tr></thead>   
</table>

<script>
$(document).ready(function() {
    $('table').dataTable( {
        "processing": true,
        "searching":false,
        "columnDefs": [
            { "name": "title", "targets": 0 },
            { "name": "submissionDate", "targets": 1 },
            { "name": "submissionResponse", "targets": 2 },
            { "name": "noOfAttempts", "targets": 3 } ],
        "columns": [
            { "data": "title" },
            { "data": "submissionDate" },
            { "data": "submissionResponse" },
            { "data": "noOfAttempts" }],
        "serverSide": true,
        "ajax": function(data, callback, settings) {
            $.get('api/scheduledPosts', {
              size: data.length,
              page: (data.start/data.length),
              sortDir: data.order[0].dir,
              sort: data.columns[data.order[0].column].name
              }, function(res,textStatus, request) {
                var pagingInfo = request.getResponseHeader('PAGING_INFO');
                var total = pagingInfo.split(",")[0].split("=")[1];
                callback({recordsTotal: total, recordsFiltered: total,data: res});
              });
          }
    } );
} );
</script>

2.4. API Testing for Paging

With the API now published, we can write a few simple API tests to make sure the basics of the paging mechanism work as expected:

@Test
public void givenMoreThanOnePage_whenGettingUserScheduledPosts_thenNextPageExist() 
  throws ParseException, IOException {
    createPost();
    createPost();
    createPost();

    Response response = givenAuth().
      params("page", 0, "size", 2).get(urlPrefix + "/api/scheduledPosts");

    assertEquals(200, response.statusCode());
    assertTrue(response.as(List.class).size() > 0);

    String pagingInfo = response.getHeader("PAGING_INFO");
    long totalNoRecords = Long.parseLong(pagingInfo.split(",")[0].split("=")[1]);
    String uriToNextPage = pagingInfo.split(",")[2].replace("uriToNextPage=", "").trim();

    assertTrue(totalNoRecords > 2);
    assertEquals(uriToNextPage, "page=1&size=2");
}

@Test
public void givenMoreThanOnePage_whenGettingUserScheduledPostsForSecondPage_thenCorrect() 
  throws ParseException, IOException {
    createPost();
    createPost();
    createPost();

    Response response = givenAuth().
      params("page", 1, "size", 2).get(urlPrefix + "/api/scheduledPosts");

    assertEquals(200, response.statusCode());
    assertTrue(response.as(List.class).size() > 0);

    String pagingInfo = response.getHeader("PAGING_INFO");
    long totalNoRecords = Long.parseLong(pagingInfo.split(",")[0].split("=")[1]);
    String uriToPrevPage = pagingInfo.split(",")[3].replace("uriToPrevPage=", "").trim();

    assertTrue(totalNoRecords > 2);
    assertEquals(uriToPrevPage, "page=0&size=2");
}

3. Email Notifications

Next, we’re going to build out a basic email notification flow – where a user receives emails when their scheduled posts are being sent:

3.1. Email Configuration

First, let’s do the email configuration:

@Bean
public JavaMailSenderImpl javaMailSenderImpl() {
    JavaMailSenderImpl mailSenderImpl = new JavaMailSenderImpl();
    mailSenderImpl.setHost(env.getProperty("smtp.host"));
    mailSenderImpl.setPort(env.getProperty("smtp.port", Integer.class));
    mailSenderImpl.setProtocol(env.getProperty("smtp.protocol"));
    mailSenderImpl.setUsername(env.getProperty("smtp.username"));
    mailSenderImpl.setPassword(env.getProperty("smtp.password"));
    Properties javaMailProps = new Properties();
    javaMailProps.put("mail.smtp.auth", true);
    javaMailProps.put("mail.smtp.starttls.enable", true);
    mailSenderImpl.setJavaMailProperties(javaMailProps);
    return mailSenderImpl;
}

Along with the necessary properties to get SMTP working:

smtp.host=email-smtp.us-east-1.amazonaws.com
smtp.port=465
smtp.protocol=smtps
smtp.username=example
smtp.password=
[email protected]

3.2. Fire an Event When a Post is Published

Let’s now make sure we fire off an event when a scheduled post gets published to Reddit successfully:

private void updatePostFromResponse(JsonNode node, Post post) {
    JsonNode errorNode = node.get("json").get("errors").get(0);
    if (errorNode == null) {
        ...
        String email = post.getUser().getPreference().getEmail();
        eventPublisher.publishEvent(new OnPostSubmittedEvent(post, email));
    } 
    ...
}

3.3. Event and Listener

The event implementation is pretty straightforward:

public class OnPostSubmittedEvent extends ApplicationEvent {
    private Post post;
    private String email;

    public OnPostSubmittedEvent(Post post, String email) {
        super(post);
        this.post = post;
        this.email = email;
    }
}

And the listener:

@Component
public class SubmissionListner implements ApplicationListener<OnPostSubmittedEvent> {
    @Autowired
    private JavaMailSender mailSender;

    @Autowired
    private Environment env;

    @Override
    public void onApplicationEvent(OnPostSubmittedEvent event) {
        SimpleMailMessage email = constructEmailMessage(event);
        mailSender.send(email);
    }

    private SimpleMailMessage constructEmailMessage(OnPostSubmittedEvent event) {
        String recipientAddress = event.getEmail();
        String subject = "Your scheduled post submitted";
        SimpleMailMessage email = new SimpleMailMessage();
        email.setTo(recipientAddress);
        email.setSubject(subject);
        email.setText(constructMailContent(event.getPost()));
        email.setFrom(env.getProperty("support.email"));
        return email;
    }

    private String constructMailContent(Post post) {
        return "Your post " + post.getTitle() + " is submitted.\n" +
          "http://www.reddit.com/r/" + post.getSubreddit() + 
          "/comments/" + post.getRedditID();
    }
}

4. Using Post Total Votes

Next, we’ll do some work to simplify the resubmit options to – instead of working with the upvote ratio (which was difficult to understand) – it’s now working with the total number of votes.

We can calculate total number of votes using post score and upvote ratio:

  • Score = upvotes – downvotes
  • Total number of votes = upvotes + downvotes
  • Upvote ratio = upvotes/total number of votes

And so:

Total number of votes = Math.round( score / ((2 * upvote ratio) – 1) )

First, we’ll modify our scores logic to calculate and keep track of this total number of votes:

public PostScores getPostScores(Post post) {
    ...

    float ratio = node.get("upvote_ratio").floatValue();
    postScore.setTotalVotes(Math.round(postScore.getScore() / ((2 * ratio) - 1)));
    
    ...
}

And of course we’re going to make use of it when checking if a post is considered failed or not:

private boolean didPostGoalFail(Post post) {
    PostScores postScores = getPostScores(post);
    int totalVotes = postScores.getTotalVotes();
    ...
    return (((score < post.getMinScoreRequired()) || 
            (totalVotes < post.getMinTotalVotes())) && 
            !((noOfComments > 0) && post.isKeepIfHasComments()));
}

Finally, we’ll of course remove the old ratio fields from use.

5. Validate Resubmit Options

Finally, we will help the user by adding some validations to the complex resubmit options:

5.1. ScheduledPost Service

Here is the simple checkIfValidResubmitOptions() method:

private boolean checkIfValidResubmitOptions(Post post) {
    if (checkIfAllNonZero(
          post.getNoOfAttempts(), 
          post.getTimeInterval(), 
          post.getMinScoreRequired())) {
        return true;
    } else {
        return false;
    }
}
private boolean checkIfAllNonZero(int... args) {
    for (int tmp : args) {
       if (tmp == 0) {
           return false;
        }
    }
    return true;
}

We’ll make good use of this validation when scheduling a new post:

public Post schedulePost(boolean isSuperUser, Post post, boolean resubmitOptionsActivated) 
  throws ParseException {
    if (resubmitOptionsActivated && !checkIfValidResubmitOptions(post)) {
        throw new InvalidResubmitOptionsException("Invalid Resubmit Options");
    }
    ...        
}

Note that if the resubmit logic is on – the following fields need to have non-zero values:

  • Number of attempts
  • Time interval
  • Minimum score required

5.2. Exception Handling

Finally – in case of invalid input, the InvalidResubmitOptionsException is handled in our main error handling logic:

@ExceptionHandler({ InvalidResubmitOptionsException.class })
public ResponseEntity<Object> handleInvalidResubmitOptions
  (RuntimeException ex, WebRequest request) {
    
    logger.error("400 Status Code", ex);
    String bodyOfResponse = ex.getLocalizedMessage();
    return new ResponseEntity<Object>(
      bodyOfResponse, new HttpHeaders(), HttpStatus.BAD_REQUEST);
}

5.3. Test Resubmit Options

Finally, let’s now test our resubmit options – we will test both activating and deactivating conditions:

public class ResubmitOptionsLiveTest extends AbstractLiveTest {
    private static final String date = "2016-01-01 00:00";

    @Test
    public void 
      givenResubmitOptionsDeactivated_whenSchedulingANewPost_thenCreated() 
      throws ParseException, IOException {
        Post post = createPost();

        Response response = withRequestBody(givenAuth(), post)
          .queryParams("resubmitOptionsActivated", false)
          .post(urlPrefix + "/api/scheduledPosts");

        assertEquals(201, response.statusCode());
        Post result = objectMapper.reader().forType(Post.class).readValue(response.asString());
        assertEquals(result.getUrl(), post.getUrl());
    }

    @Test
    public void 
      givenResubmitOptionsActivated_whenSchedulingANewPostWithZeroAttempts_thenInvalid() 
      throws ParseException, IOException {
        Post post = createPost();
        post.setNoOfAttempts(0);
        post.setMinScoreRequired(5);
        post.setTimeInterval(60);

        Response response = withRequestBody(givenAuth(), post)
          .queryParams("resubmitOptionsActivated", true)
          .post(urlPrefix + "/api/scheduledPosts");

        assertEquals(400, response.statusCode());
        assertTrue(response.asString().contains("Invalid Resubmit Options"));
    }

    @Test
    public void 
      givenResubmitOptionsActivated_whenSchedulingANewPostWithZeroMinScore_thenInvalid() 
      throws ParseException, IOException {
        Post post = createPost();
        post.setMinScoreRequired(0);
        post.setNoOfAttempts(3);
        post.setTimeInterval(60);

        Response response = withRequestBody(givenAuth(), post)
          .queryParams"resubmitOptionsActivated", true)
          .post(urlPrefix + "/api/scheduledPosts");

        assertEquals(400, response.statusCode());
        assertTrue(response.asString().contains("Invalid Resubmit Options"));
    }

    @Test
    public void 
      givenResubmitOptionsActivated_whenSchedulingANewPostWithZeroTimeInterval_thenInvalid() 
      throws ParseException, IOException {
        Post post = createPost();
        post.setTimeInterval(0);
        post.setMinScoreRequired(5);
        post.setNoOfAttempts(3);

        Response response = withRequestBody(givenAuth(), post)
          .queryParams("resubmitOptionsActivated", true)
          .post(urlPrefix + "/api/scheduledPosts");

        assertEquals(400, response.statusCode());
        assertTrue(response.asString().contains("Invalid Resubmit Options"));
    }

    @Test
    public void 
      givenResubmitOptionsActivated_whenSchedulingNewPostWithValidResubmitOptions_thenCreated() 
      throws ParseException, IOException {
        Post post = createPost();
        post.setMinScoreRequired(5);
        post.setNoOfAttempts(3);
        post.setTimeInterval(60);

        Response response = withRequestBody(givenAuth(), post)
          .queryParams("resubmitOptionsActivated", true)
          .post(urlPrefix + "/api/scheduledPosts");

        assertEquals(201, response.statusCode());
        Post result = objectMapper.reader().forType(Post.class).readValue(response.asString());
        assertEquals(result.getUrl(), post.getUrl());
    }

    private Post createPost() throws ParseException {
        Post post = new Post();
        post.setTitle(randomAlphabetic(6));
        post.setUrl("test.com");
        post.setSubreddit(randomAlphabetic(6));
        post.setSubmissionDate(dateFormat.parse(date));
        return post;
    }
}

6. Conclusion

In this installment, we made several improvements that are moving the case study app in the right direction – ease of use.

The whole idea of the Reddit Scheduler app is to allow the user to quickly schedule new articles to Reddit, by getting into the app, doing the work and getting out.

It’s getting there.

I just announced the new Spring Boot 2 material, coming in REST With Spring:

>> CHECK OUT THE LESSONS