Thursday, May 5, 2011

A custom JSR-303 compliant validator for apache CommonsMultipartFile

Recently on my Spring 3 MVC project, I needed to implement file upload/download capability - nothing too exciting here, but I wanted to use JSR-303 and have my controller to cleanly check for a valid file type and size (my project only allows for uploading PDF files with 30Mb max), that is to move that constraint logic away from the controller into a JSR-303 validator. Since I'm a nice guy, I'm sharing the code here :) here is an excerpt of my controller code:
1:  import org.slf4j.Logger;  
2:  import org.springframework.beans.factory.annotation.Autowired;  
3:  import org.springframework.security.core.context.SecurityContextHolder;  
4:  import org.springframework.stereotype.Controller;  
5:  import org.springframework.ui.Model;  
6:  import org.springframework.validation.BindingResult;  
7:  import org.springframework.web.bind.annotation.ExceptionHandler;  
8:  import org.springframework.web.bind.annotation.RequestMapping;  
9:  import org.springframework.web.bind.annotation.RequestMethod;  
10:  import javax.validation.Valid;  
11:  import static org.slf4j.LoggerFactory.getLogger; 
12:  @Controller  
13:  @RequestMapping(value = "/upload")  
14:  public class UploadController  
15:  {  
16:    private static final Logger LOGGER = getLogger(UploadController.class);  
17:    @Autowired  
18:    private ProjectManagementService projectManagementService;  
19:    @RequestMapping(method = RequestMethod.GET)  
20:    public String getUploadForm(Model model)  
21:    {  
22:      String viewName = "upload/uploadForm";  
23:      User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();  
24:      if (user.getAuthorities().contains(Authority.ROLE_ADMIN))  
25:      {  
26:        viewName = "redirect:admin/adminAccessList";  
27:      }  
28:      else if (user.getAuthorities().contains(Authority.ROLE_USER))  
29:      {  
30:        model.addAttribute(new ProjectUploadCommand());  
31:      }  
32:      else  
33:      {  
34:        throw new IllegalStateException("Unauthorized access - should never happen.");  
35:      }  
36:      return viewName;  
37:    }  
38:    @RequestMapping(method = RequestMethod.POST)  
39:    public String upload(@Valid ProjectUploadCommand projectUpload, BindingResult result)  
40:    {  
41:      String viewName = "upload/uploadForm";  
42:      if (!result.hasErrors())  
43:      {  
44:        final User user = (User) SecurityContextHolder.getContext().getAuthentication().getPrincipal();  
45:        Project project = new Project(user,  
46:            projectUpload.getProjectIdeaTitle(),  
47:            projectUpload.getDescription(),  
48:            projectUpload.getEstimatedLaunchDate(),  
49:            new ProjectContent(projectUpload.getFileData().getOriginalFilename(), projectUpload.getFileData().getBytes()));  
50:        projectManagementService.addProject(user, project);  
51:        viewName = "upload/thankYou";  
52:      }  
53:      return viewName;  
54:    }  
55:    @ExceptionHandler  
56:    public String catchAllHandler(Exception ex)  
57:    {  
58:      LOGGER.error("Unexpected Exception at UploadController", ex);  
59:      return "errorPage";  
60:    }  
61:  }  

Note the @Valid annotation in front of ProjectUploadCommand object. (I know, a better name for it would be ProjectUploadForm since its not a real command in the GOF sense, but its how some Spring folks call these objects) here is the "command" object: CommonsMultipartFileValid.java
1:   import java.lang.annotation.Documented;  
2:   import java.lang.annotation.Retention;  
3:   import java.lang.annotation.RetentionPolicy;  
4:   import java.lang.annotation.Target;  
5:   import javax.validation.Constraint;  
6:   import javax.validation.Payload;  
7:   import static java.lang.annotation.ElementType.ANNOTATION_TYPE;  
8:   import static java.lang.annotation.ElementType.CONSTRUCTOR;  
9:   import static java.lang.annotation.ElementType.FIELD;  
10:  import static java.lang.annotation.ElementType.METHOD;  
11:  import static java.lang.annotation.ElementType.PARAMETER;
  
12:   @SuppressWarnings({"UnusedDeclaration"})  
13:   @Target({ANNOTATION_TYPE, METHOD, FIELD, PARAMETER, CONSTRUCTOR})  
14:   @Retention(RetentionPolicy.RUNTIME)  
15:   @Constraint(validatedBy = CommonsMultipartFileValidator.class)  
16:   @Documented  
17:   public @interface CommonsMultipartFileValid  
18:   {  
19:     String fileSize() default "31457280";  
20:     String[] supportedFileTypes() default {"pdf"};  
21:     String message() default "File must be a PDF no larger then 30M bytes";  
22:     Class[] groups() default {};  
23:     Class[] payload() default {};  
24:   }  
and here is the implementation: (left a TO DO for you !) CommonsMultipartFileValidator.java
1:   import java.util.Arrays;  
2:   import java.util.List;  
3:   import javax.validation.ConstraintValidator;  
4:   import javax.validation.ConstraintValidatorContext;  
5:   import org.springframework.web.multipart.commons.CommonsMultipartFile;  
6:   /**  
7:    * A custom JSR-303 validator for CommonsMultipartFile objects.  
8:    * validates the file extension and file size.  
9:    *  
10:   * @author Paulo Avelar  
11:   */  
12:   public class CommonsMultipartFileValidator implements ConstraintValidator<commonsmultipartfilevalid commonsmultipartfile="commonsmultipartfile">  
13:   {  
14:     private String fileSize = "31457280";    //30M-bytes  
15:     private String[] supportedFileTypes = {"PDF"};  
16:     @Override  
17:     public void initialize(CommonsMultipartFileValid constraintAnnotation)  
18:     {  
19:       fileSize = constraintAnnotation.fileSize();  
20:       supportedFileTypes = constraintAnnotation.supportedFileTypes();  
21:     }  
22:     @Override  
23:     public boolean isValid(CommonsMultipartFile value, ConstraintValidatorContext context)  
24:     {  
25:       boolean result = false;  
26:       try  
27:       {  
28:         if (value != null)  
29:         {  
30:           result = validateFileSize(value);  
31:           //two step process because MIME type identification can be costly  
32:           if (result)  
33:           {  
34:             result = validateExtensionType(value);  
35:           }  
36:         }  
37:       }  
38:       catch (Exception e)  
39:       {  
40:         throw new RuntimeException(e);  
41:       }  
42:       return result;  
43:     }  
44:     //  TODO: use mime type identifier instead of silly extension name  
45:     private boolean validateExtensionType(CommonsMultipartFile value)  
46:     {  
47:       int dotPos = value.getOriginalFilename().lastIndexOf(".");  
48:       boolean result = false;  
49:       if (dotPos != -1)  
50:       {  
51:         String extension = value.getOriginalFilename().substring(dotPos + 1);  
52:         final List<string> supportedExtensions = Arrays.asList(supportedFileTypes);  
53:         for (String supportedExtension : supportedExtensions)  
54:         {  
55:           if (extension.equalsIgnoreCase(supportedExtension))  
56:           {  
57:             result = true;  
58:             break;  
59:           }  
60:         }  
61:       }  
62:       return result;  
63:     }  
64:     private boolean validateFileSize(CommonsMultipartFile value)  
65:     {  
66:       boolean result = false;  
67:       if (value != null)  
68:       {  
69:         if (value.getSize() != 0 &amp;&amp; value.getSize() &lt;= Long.valueOf(fileSize))  
70:         {  
71:           result = true;  
72:         }  
73:       }  
74:       return result;  
75:     }  
76:    }  
Almost forgot, here is the Unit Test for it: CommonsMultipartValidatorTest.java
1:   import java.io.File;  
2:   import java.io.IOException;  
3:   import java.io.InputStream;  
4:   import java.io.OutputStream;  
5:   import java.io.UnsupportedEncodingException;  
6:   import java.lang.annotation.Annotation;  
7:   import javax.validation.Payload;  
8:   import org.apache.commons.fileupload.FileItem;  
9:   import org.hamcrest.Matchers;  
10:   import org.junit.Test;  
11:   import org.springframework.web.multipart.commons.CommonsMultipartFile;  
12:   import static org.hamcrest.MatcherAssert.assertThat; 
 
13:   public class CommonsMultipartValidatorTest  
14:   {  
15:     private CommonsMultipartFileValid commonsMultipartFileValid = new CommonsMultipartFileValid()  
16:     {  
17:       @Override  
18:       public String fileSize()  
19:       {  
20:         return "10000";  
21:       }  
22:       @Override  
23:       public String[] supportedFileTypes()  
24:       {  
25:         return new String[]{"PDF", "DOC"};  
26:       }  
27:       @Override  
28:       public String message()  
29:       {  
30:         return "whatever message";  
31:       }  
32:       @Override  
33:       public Class[] groups()  
34:       {  
35:         return null;  
36:       }  
37:       @Override  
38:       public Class[] payload()  
39:       {  
40:         return null;  
41:       }  
42:       @Override  
43:       public Class annotationType()  
44:       {  
45:         return null;  
46:       }      
47:     };      
48:     @Test  
49:     public void validateCommonsMultipartValidator()  
50:     {  
51:       CommonsMultipartFileValidator validator = new CommonsMultipartFileValidator();  
52:       validator.initialize(commonsMultipartFileValid);  
53:       assertThat(validator.isValid(new CommonsMultipartFile(new ValidFileItem()), null), Matchers.is(true));  
54:       assertThat(validator.isValid(new CommonsMultipartFile(new InvalidFileItem()), null), Matchers.is(false));  
55:       assertThat(validator.isValid(new CommonsMultipartFile(new InvalidFileItemSize()), null), Matchers.is(false));  
56:     }  
57:     private class ValidFileItem implements FileItem  
58:     {  
59:       @Override  
60:       public InputStream getInputStream() throws IOException  
61:       {  
62:         return null;  
63:       }  
64:       @Override  
65:       public String getContentType()  
66:       {  
67:         return null;  
68:       }  
69:       @Override  
70:       public String getName()  
71:       {  
72:         return "test.pdf";  
73:       }  
74:       @Override  
75:       public boolean isInMemory()  
76:       {  
77:         return false;  
78:       }  
79:       @Override  
80:       public long getSize()  
81:       {  
82:         return 450;  
83:       }  
84:       @Override  
85:       public byte[] get()  
86:       {  
87:         return new byte[0];  
88:       }  
89:       @Override  
90:       public String getString(String encoding) throws UnsupportedEncodingException  
91:       {  
92:         return null;  
93:       }  
94:       @Override  
95:       public String getString()  
96:       {  
97:         return null;  
98:       }  
99:       @Override  
100:       public void write(File file) throws Exception  
101:       {  
102:       }  
103:       @Override  
104:       public void delete()  
105:       {  
106:       }  
107:       @Override  
108:       public String getFieldName()  
109:       {  
110:         return null;  
111:       }  
112:       @Override  
113:       public void setFieldName(String name)  
114:       {  
115:       }  
116:       @Override  
117:       public boolean isFormField()  
118:       {  
119:         return false;  
120:       }  
121:       @Override  
122:       public void setFormField(boolean state)  
123:       {  
124:       }  
125:       @Override  
126:       public OutputStream getOutputStream() throws IOException  
127:       {  
128:         return null;  
129:       }  
130:     }  
131:     private class InvalidFileItem extends ValidFileItem  
132:     {  
133:       @Override  
134:       public String getName()  
135:       {  
136:         return "test.XXX";  
137:       }  
138:     }  
139:     private class InvalidFileItemSize extends ValidFileItem  
140:     {  
141:       @Override  
142:       public long getSize()  
143:       {  
144:         return 9999999;  
145:       }  
146:     }  
147:   }  

2 comments:

  1. This comment has been removed by a blog administrator.

    ReplyDelete
  2. This comment has been removed by a blog administrator.

    ReplyDelete