1. Overview
In this article, we're going to continue the case study and add a new feature to the Reddit application, with the goal of making it much simpler to schedule articles.
Instead of slowly adding in every article by hand in the schedule UI, the user can now just have some favorite sites to post articles to Reddit from. We're going to use RSS to do that.
2. The Site Entity
First – let's create an entity to represent the site:
@Entity
public class Site {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
private Long id;
@Column(nullable = false)
private String name;
@Column(nullable = false)
private String url;
@ManyToOne
@JoinColumn(name = "user_id", nullable = false)
private User user;
}
Note that the url field represents the URL of the RSS feed of the site.
3. The Repository and the Service
Next – lets create the repository to work with the new Site entity:
public interface SiteRepository extends JpaRepository<Site, Long> {
List<Site> findByUser(User user);
}
And the service:
public interface ISiteService {
List<Site> getSitesByUser(User user);
void saveSite(Site site);
Site findSiteById(Long siteId);
void deleteSiteById(Long siteId);
}
@Service
public class SiteService implements ISiteService {
@Autowired
private SiteRepository repo;
@Override
public List<Site> getSitesByUser(User user) {
return repo.findByUser(user);
}
@Override
public void saveSite(Site site) {
repo.save(site);
}
@Override
public Site findSiteById(Long siteId) {
return repo.findOne(siteId);
}
@Override
public void deleteSiteById(Long siteId) {
repo.delete(siteId);
}
}
4. Load Data from the Feed
Now – let's see how to load the articles details from website feed using the Rome Library.
We'll first need to add Rome into our pom.xml:
<dependency>
<groupId>com.rometools</groupId>
<artifactId>rome</artifactId>
<version>1.5.0</version>
</dependency>
And then use it to parse out the feeds of the sites:
public List<SiteArticle> getArticlesFromSite(Long siteId) {
Site site = repo.findOne(siteId);
return getArticlesFromSite(site);
}
List<SiteArticle> getArticlesFromSite(Site site) {
List<SyndEntry> entries;
try {
entries = getFeedEntries(site.getUrl());
} catch (Exception e) {
throw new FeedServerException("Error Occurred while parsing feed", e);
}
return parseFeed(entries);
}
private List<SyndEntry> getFeedEntries(String feedUrl)
throws IllegalArgumentException, FeedException, IOException {
URL url = new URL(feedUrl);
SyndFeed feed = new SyndFeedInput().build(new XmlReader(url));
return feed.getEntries();
}
private List<SiteArticle> parseFeed(List<SyndEntry> entries) {
List<SiteArticle> articles = new ArrayList<SiteArticle>();
for (SyndEntry entry : entries) {
articles.add(new SiteArticle(
entry.getTitle(), entry.getLink(), entry.getPublishedDate()));
}
return articles;
}
Finally – here's the simple DTO that we're going to use in the response:
public class SiteArticle {
private String title;
private String link;
private Date publishDate;
}
5. Exception Handling
Notice how, when parsing the feed, we're wrapping the entire parsing logic into a try-catch block and – in case of an exception (any exception) – we're wrapping it and throwing it.
The reason for that is simple – we need to control the type of exception that gets thrown out of the parsing process – so that we can then handle that exception and provide a proper response to the client of the API:
@ExceptionHandler({ FeedServerException.class })
public ResponseEntity<Object> handleFeed(RuntimeException ex, WebRequest request) {
logger.error("500 Status Code", ex);
String bodyOfResponse = ex.getLocalizedMessage();
return new ResponseEntity<Object>(bodyOfResponse, new HttpHeaders(),
HttpStatus.INTERNAL_SERVER_ERROR);
}
6. The Sites Page
6.1. Display the Sites
First, we will see how to show list of sites belonging to the logged in user:
@RequestMapping(value = "/sites")
@ResponseBody
public List<Site> getSitesList() {
return service.getSitesByUser(getCurrentUser());
}
And here is the very simple front end piece:
<table>
<thead>
<tr><th>Site Name</th><th>Feed URL</th><th>Actions</th></tr>
</thead>
</table>
<script>
$(function(){
$.get("sites", function(data){
$.each(data, function( index, site ) {
$('.table').append('<tr><td>'+site.name+'</td><td>'+site.url+
'</td><td><a href="#" onclick="deleteSite('+site.id+') ">Delete</a> </td></tr>');
});
});
});
function deleteSite(id){
$.ajax({ url: 'sites/'+id, type: 'DELETE', success: function(result) {
window.location.href="mysites"
}
});
}
</script>
6.2. Add a New Site
Next, let's see how a user can create a new favorite site:
@RequestMapping(value = "/sites", method = RequestMethod.POST)
@ResponseStatus(HttpStatus.OK)
public void addSite(Site site) {
if (!service.isValidFeedUrl(site.getUrl())) {
throw new FeedServerException("Invalid Feed Url");
}
site.setUser(getCurrentUser());
service.saveSite(site);
}
And here is the – again very simple – client side:
<form>
<input name="name" />
<input id="url" name="url" />
<button type="submit" onclick="addSite()">Add Site</button>
</form>
<script>
function addSite(){
$.post("sites",$('form').serialize(), function(data){
window.location.href="mysites";
}).fail(function(error){
alert(error.responseText);
});
}
</script>
6.3. Validating a Feed
The validation of a new feed is a bit of an expensive operation – we need to actually retrieve the feed and parse it out to validate it fully. Here is the simple service method:
public boolean isValidFeedUrl(String feedUrl) {
try {
return getFeedEntries(feedUrl).size() > 0;
} catch (Exception e) {
return false;
}
}
6.3. Delete a Site
Now, let's see how the user can delete a site from their list of favorite sites:
@RequestMapping(value = "/sites/{id}", method = RequestMethod.DELETE)
@ResponseStatus(HttpStatus.OK)
public void deleteSite(@PathVariable("id") Long id) {
service.deleteSiteById(id);
}
And here the – again very simple – service level method:
public void deleteSiteById(Long siteId) {
repo.delete(siteId);
}
7. Schedule a Post from a Site
Now – let's actually start using these sites and implement a basic way a user can schedule a new post to go out to Reddit not manually, but by loading in an article from an existing site.
7.1. Modify Scheduling Form
Let's start with the client site and modify the existing schedulePostForm.html – we're going to add:
<button data-target="#myModal">Load from My Sites</button>
<div id="myModal">
<button id="dropdownMenu1">Choose Site</button><ul id="siteList"></ul>
<button id="dropdownMenu2">Choose Article</button><ul id="articleList"></ul>
<button onclick="load()">Load</button>
</div>
Note that we've added:
- the button – “Load from my Sites” – to start the process
- the pop-up – showing the list of sites and their articles
7.2. Load the Sites
Loading the sites in the popup is relatively easy with a bit of javascript:
$('#myModal').on('shown.bs.modal', function () {
if($("#siteList").children().length > 0)
return;
$.get("sites", function(data){
$.each(data, function( index, site ) {
$("#siteList").append('<li><a href="#" onclick="loadArticles('+
site.id+',\''+site.name+'\')">'+site.name+'</a></li>')
});
});
});
7.3. Load the Posts of a Site
When the user select a website from the list, we need to show the articles of that site – again with some basic js:
function loadArticles(siteID,siteName){
$("#dropdownMenu1").html(siteName);
$.get("sites/articles?id="+siteID, function(data){
$("#articleList").html('');
$("#dropdownMenu2").html('Choose Article');
$.each(data, function( index, article ) {
$("#articleList").append(
'<li><a href="#" onclick="chooseArticle(\''+article.title+
'\',\''+article.link+'\')"><b>'+article.title+'</b> <small>'+
new Date(article.publishDate).toUTCString()+'</small></li>')
});
}).fail(function(error){
alert(error.responseText);
});
}
This of course hooks into a simple server side operation to load up the articles of a site:
@RequestMapping(value = "/sites/articles")
@ResponseBody
public List<SiteArticle> getSiteArticles(@RequestParam("id") Long siteId) {
return service.getArticlesFromSite(siteId);
}
Finally, we get the article data, fill in the form and schedule the article to go out to Reddit:
var title = "";
var link = "";
function chooseArticle(selectedTitle,selectedLink){
$("#dropdownMenu2").html(selectedTitle);
title=selectedTitle;
link = selectedLink;
}
function load(){
$("input[name='title']").val(title);
$("input[name='url']").val(link);
}
8. Integration Tests
Finally – let's test our SiteService on two different feed formats:
public class SiteIntegrationTest {
private ISiteService service;
@Before
public void init() {
service = new SiteService();
}
@Test
public void whenUsingServiceToReadWordpressFeed_thenCorrect() {
Site site = new Site("/feed/");
List<SiteArticle> articles = service.getArticlesFromSite(site);
assertNotNull(articles);
for (SiteArticle article : articles) {
assertNotNull(article.getTitle());
assertNotNull(article.getLink());
}
}
@Test
public void whenUsingRomeToReadBloggerFeed_thenCorrect() {
Site site = new Site("http://blogname.blogspot.com/feeds/posts/default");
List<SiteArticle> articles = service.getArticlesFromSite(site);
assertNotNull(articles);
for (SiteArticle article : articles) {
assertNotNull(article.getTitle());
assertNotNull(article.getLink());
}
}
}
There's clearly a bit of duplication here, but we can take care of that later.
9. Conclusion
In this installment we focused on a new, small feature – making the scheduling of the post to Reddit – simpler.