Wednesday, July 10, 2013

JSF 2, display PDF files stored at Amazon S3 for cloud storage

Lately I have been working on a personal project using JSF2. In that project I had to display PDF files to users on demand. I wanted to deploy the project in the cloud and I chose CloudBees. The only problem I encountered with CloudBees is that you cannot upload files via your application to their servers.
The solution was to store my files in an external storage provider. I chose Amazon Simple Storage Service (S3).
Now here is the challenge: I have my files stored at Amazon servers, and I need to remotely access these files and display them back to users via Google Docs Viewer. You may say that I could generate direct URL to these files (as discussed here) and then simply display them. But, what if you don't want them to be publicly accessible, or if you want to make them accessible under some conditions ?
In this article we will see how to do this. But first let's begin with uploading files to Amazon S3 via our JSF2 application.

1) The JetS3t Toolkit

The JetS3t is an open source application suite for Amazon S3, Amazon CloudFront content delivery network and Google storage for developers. It provides a very simple  API for interacting with these storage services. Here is POM dependencies for JetS3t:




 net.java.dev.jets3t
 jets3t
 0.9.0

2) Uploading files in JSF2

To upload files, we will use Primefaces uploader. To use it, you should define PrimeFaces FileUpload Filter in your web.xml descriptor:



 PrimeFaces FileUpload Filter
 org.primefaces.webapp.filter.FileUploadFilter


 PrimeFaces FileUpload Filter
 Faces Servlet
 
 FORWARD

Please notice that we set dispatcher to FORWARD. This because we are using Prettyfaces (it's filter dispatcher is also set to FORWARD). Without doing this, you may encounter some problems.
You also must add dependencies of two additional APIs: commons-io and commons-fileupload. It's not mentioned in Primefaces docs, but without doing this, you will get many ClassNotFoundExceptions related to theses libraries:



 commons-fileupload
 commons-fileupload
 1.3


 commons-io
 commons-io
 2.4

Now everything is set alright. Let's begin with implementation. The JSF upload part is very simple. You simply define a managed bean containing an UploadedFile property and two methods:
package com.raissi.managedbeans;
import java.io.IOException;
import java.io.Serializable;
import javax.faces.event.ActionEvent;
import javax.inject.Inject;
import javax.inject.Named;

import org.primefaces.event.FileUploadEvent;
import org.primefaces.model.UploadedFile;
import org.springframework.context.annotation.Scope;
import org.springframework.stereotype.Component;

import com.raissi.domain.Resume;
import com.raissi.service.ResumeService;

@Component
@Scope("view")
public class HomeManagedBean implements Serializable{
 private static final long serialVersionUID = 5426154702541976181L;
 @Inject
 private ResumeService resumeService;
        private Resume resume = new Resume();
 private UploadedFile file;

 public UploadedFile getFile() {
          return file;
 }

  public void setFile(UploadedFile file) {
          this.file = file;
 }
    
 public void fileUploadListener(FileUploadEvent event){
       file = event.getFile();
 }
        public void upload() {
          if(file != null) {
      try {
       resume.setDocumentName(file.getFileName());
    resumeService.persistCvContent(file.getInputstream(), resume);
    loggedInUser.getUser().setResume(resume);
    FacesMessage msg = new FacesMessage("Succesful", file.getFileName() + " is uploaded.");
             FacesContext.getCurrentInstance().addMessage(null, msg);
  } catch (IOException e) {
    FacesMessage msg = new FacesMessage("File ", file.getFileName() + " couldn't be uploaded. Please contact admins");
             FacesContext.getCurrentInstance().addMessage(null, msg);
    e.printStackTrace();
  }
          }
        }
}
And here is our xhtml code:

      
      
     
      
       
       
       
       
       
        
                
                
                
                
      
     
    
     
The Resume class referenced in the managed bean, is just a domain class that we defined in last article. It will contain data related to user's resume and will be persisted in DB. The key class here is the ResumeService bean which is responsible for Resume persistence in both DB via ResumeDao and in remote Amazon S3 store. Before we go through document persistence in Amazon S3, please make sure you got your your Amazon S3 credentials: AWSAccessKeyId (access key ID) and AWSSecretKey (Secret Key) since we will use them to store/retrieve data from S3.

3) Persisting files to Amazon S3 

Now we go to examine the ResumeService class:

package com.raissi.service.impl;

import java.io.IOException;
import java.io.InputStream;
import java.io.Serializable;
import java.security.NoSuchAlgorithmException;

import javax.annotation.PostConstruct;
import javax.inject.Inject;
import javax.inject.Named;

import org.apache.commons.io.IOUtils;
import org.jets3t.service.S3Service;
import org.jets3t.service.S3ServiceException;
import org.jets3t.service.ServiceException;
import org.jets3t.service.impl.rest.httpclient.RestS3Service;
import org.jets3t.service.model.S3Bucket;
import org.jets3t.service.model.S3Object;
import org.jets3t.service.security.AWSCredentials;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.transaction.annotation.Transactional;

import com.raissi.dao.ResumeDao;
import com.raissi.domain.Resume;
import com.raissi.service.ResumeService;

@Named("resumeService")
@Transactional
public class ResumeServiceImpl implements ResumeService, Serializable{
 private static final long serialVersionUID = 1L;
 
 @Inject
 private ResumeDao resumeDao;
 @Value("${s3.accessKeyId}")
 private String amazonAccessKeyId;
 
 @Value("${s3.secretKey}")
 private String amazonSecretKey;
 
 @Value("${s3.bucketName}")
 private String bucketName;
 
 private S3Service s3Service;
 // To store data in S3 you must first create a bucket, a container for objects.
 private S3Bucket bucket;
 @PostConstruct
 public void init(){
  try {
   //amazon S3 storage credentials:
   AWSCredentials awsCredentials = 
       new AWSCredentials(amazonAccessKeyId, amazonSecretKey);
   //To communicate with S3, create a class that implements an S3Service. 
   //We will use the REST/HTTP implementation based on HttpClient, 
   //as this is the most robust implementation provided with JetS3t.
   s3Service = new RestS3Service(awsCredentials);
   
   bucket = s3Service.getBucket(bucketName);
   if(bucket == null){
    bucket = s3Service.createBucket(bucketName);
   }
  } catch (S3ServiceException e) {
   // TODO Auto-generated catch block
   e.printStackTrace();
  }
 }

        @Override
 public void persistCvContent(InputStream content, Resume resume) {
  long currentTime = System.currentTimeMillis();
  String extension = resume.getDocumentName().substring(resume.getDocumentName().lastIndexOf("."));
  String fileName = currentTime+extension;
  
  try {
   byte[] contentArray = IOUtils.toByteArray(content);
   S3Object cvS3Object = new S3Object(fileName, contentArray);
   cvS3Object.setContentLength(contentArray.length);
   cvS3Object.setContentType("application/pdf");
   s3Service.putObject(bucket, cvS3Object);
   resume.setContentUrl(bucketName+"/"+fileName);
  } catch (S3ServiceException e1) {
   // TODO Auto-generated catch block
   e1.printStackTrace();
  } catch (NoSuchAlgorithmException e) {
   // TODO Auto-generated catch block
   e.printStackTrace();
  } catch (IOException e) {
   // TODO Auto-generated catch block
   e.printStackTrace();
  }
 }
}
A bucket in Amazon S3 is a file container in which every uploaded file will be stored. A bucket name must be unique through all S3 users. Buckets cannot be nested. And, each bucket can contain an unlimited number of files. I decided bucket name to be configurable with accessKeyId and secretKey. That's why I am injecting their values in my ResumeService, so application admin can chose whatever he wants. (Remember the property-placeholder defined in Spring application context in last article, well these String values should be defined in the file referenced by the properties holder).
Also notice that we are saving the distant file name and the bucket name in a persistent entity "Resume". In this way, we are able to use different bucket names in the future.

4) Displaying PDF files in JSF2

To display PDF files, we can simply use 


From Primefaces. But this is for static and public files (accessible by simple URLs). For dynamic files which need some server logic before rendering we can use a custom servlet with the above primefaces component.
Use case: Assume we have a datatable displaying a list of users (User object from last article). For each row (User) we will have a button to view CV. When clicked, the button opens a popup dialog displaying the PDF file. So here is the XHTML code:



     
                 Registered users
                 
               
           
   
           
               
            
           
           
            
               
           
                  
               
              
                        
               
        
       
              
             
As you can see, the command button used to display the dialog has an action listener to call a server side method: generateUserCV(User user); the method code is:
public void generateUserCV(User user){
  setSelectedUser(user);
  if(user != null){
   InputStream file = resumeService.getCvByUser(user.getUserId());
   if(file != null){
    try {
     byte[] bytes = IOUtils.toByteArray(file);
     Map session = FacesContext.getCurrentInstance().getExternalContext().getSessionMap();
     //We are using userId when storing cv content to be possible to display multiple files 
     session.put(ResumeService.ATTR_RESUME+user.getUserId(), bytes);
    } catch (IOException e) {
     // TODO Auto-generated catch block
     e.printStackTrace();
    }
    
   }
  }
}
As for the generateUserCV method, it calls the resumeService to get user's CV file as java.io.InputStream. Then, we use org.apache.commons.io.IOUtils#toByteArray(InputStream input) to convert file content a byte array. After that we save the array in the session map under a constant String (ResumeService.ATTR_RESUME) of our choice concatenated with the user id. This will be used by the mentioned custom servlet later. Now here is the code of resumeService.getCvByUser:
public InputStream getCvByUser(Long userId){
  Resume resume = resumeDao.getResumeByUser(userId);
  String fullS3Name = resume.getContentUrl();
                //Remember, we set the bucket name and file name in the contentUrl property of Resume
  String bucketNameForResume = fullS3Name.substring(0, fullS3Name.indexOf("/"));
  String fileName = fullS3Name.substring(fullS3Name.indexOf("/")+1);
  try {

   S3Object cvS3Object = s3Service.getObject(bucketNameForResume, fileName);
   if(cvS3Object != null){
    InputStream stream = cvS3Object.getDataInputStream();    
    return stream;
   }
  } catch (S3ServiceException e) {
   // TODO Auto-generated catch block
   e.printStackTrace();
  } catch (ServiceException e) {
   // TODO Auto-generated catch block
   e.printStackTrace();
  }
  
  return null;
 }
In the dialog to be shown, you may have noticed that we are using p:media with a stream value: /file/cv?id=#{adminHomeManagedBean.selectedUser.userId}. Well, here we are referring to a GET call to a servlet with a param named "id". And here is the servlet implementation:
package com.raissi.servlet;

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import com.raissi.service.ResumeService;

@WebServlet("/file/cv")
public class ResumeServlet  extends HttpServlet {
 private static final long serialVersionUID = -221600603615879137L;

 @Override
    protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
  String userId = request.getParameter("id");
  if(userId != null && !userId.equals("")){
         byte[] content = (byte[]) request.getSession().getAttribute(ResumeService.ATTR_RESUME+userId);
            if(content != null){
          response.setContentType("application/pdf");
          response.setContentLength(content.length);
          response.getOutputStream().write(content);
            }
  }
    }

}
The code is very obvious. At first, we fetch the user id (user of whom we are displaying CV) from request parameters. After that, we access the HTTPSession to retrieve the stored content and we set it into response output. Finally, we need to clean our session when dialog is closed. Otherwise, session will be encumbered with content. That's why I added a listener to be called on dialog close event: . And here is the listener code:
public void removeUserCVFromSession(CloseEvent event){
  if(selectedUser != null){
   Map session = FacesContext.getCurrentInstance().getExternalContext().getSessionMap();
   session.remove(ResumeService.ATTR_RESUME+selectedUser.getUserId());
  }
 }
And that's all you need.

No comments:

Post a Comment