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.