Saturday, March 16, 2013

Using Java and the Jersey library to process multipart/form-data with "arrays" of form elements

Yes, that title is a mouthful, but I couldn't think of a more succinct way to describe the problem.  Sometimes, it's very useful to create an HTML form that contains multiple input elements with the same name.  Then, on the server, you would like to be able to treat the values of these elements as an array.  This is generally fairly straightforward in most server-side programming languages, including Java with the Jersey library (a standard way to build RESTful Web services in Java).  However, if your form data are encoded as "multipart/form-data," (the norm for file uploads) then it turns out that achieving the desired functionality using Java and Jersey is not as straightforward as you might think.  Perhaps I didn't search in the right places, but I found relatively little helpful information on the Web (and some folks simply concluded it wasn't possible!).  So, here is one working solution to the problem, and I hope this might save someone else the time that I wasted figuring this out.

To illustrate the problem, suppose you need to process form data that include a file upload along with some other information, perhaps a keyword designation for the file.  Your HTML <form> element might look something like this.

<form id="upload" enctype="multipart/form-data" method="post" action="upload">
    Keyword: <input name="keyword" /><br />
    File:<br/>
    <input type="file" name="file" size="44"/><br/>
    <input type="submit" value="Upload file" /><br />
</form>

And the (simplified) Java code you use to handle the form data might look like this.

@POST
@Path("upload")
@Consumes(MediaType.MULTIPART_FORM_DATA)
public DataFile upload(
    @FormDataParam("keyword") String keyword,
    @FormDataParam("file") InputStream file_in,
    @FormDataParam("file") FormDataContentDisposition contentdisp)
    throws Exception {

    // Do something with the form data... 
}

That should all work fine, but what if you want to let users provide an arbitrary number of keywords, using a separate input box for each keyword?  You could write some javascript to allow the user to click a "more keywords" button that adds more input boxes to the form.  Then, your form would effectively look something like the following.

<form id="upload" enctype="multipart/form-data" method="post" action="upload">
    Keyword: <input name="keywords" /><br />
    Keyword: <input name="keywords" /><br />
    Keyword: <input name="keywords" /><br />
    <!-- Could be any number of keyword elements here... -->
    File:<br/>
    <input type="file" name="file" size="44"/><br/>
    <input type="submit" value="Upload file" /><br />
</form>

How should you handle this on the server side?  The obvious solution is to specify a parameter that is a List, like this.

@POST
@Path("upload")
@Consumes(MediaType.MULTIPART_FORM_DATA)
public DataFile upload(
    @FormDataParam("keywords") List<String> keywords,
    @FormDataParam("file") InputStream file_in,
    @FormDataParam("file") FormDataContentDisposition contentdisp)
    throws Exception {

    // Do something with the form data... 
}

Unfortunately, this straightforward approach doesn't work for MediaType.MULTIPART_FORM_DATA, even though it works fine for other media types.  The trick is to replace the "keywords" parameter with a List of FormDataBodyPart objects.  Then, we can extract the keyword String from each FormDataBodyPart.  In the following example, the extracted keyword strings are placed into another List.

@POST
@Path("upload")
@Consumes(MediaType.MULTIPART_FORM_DATA)
public DataFile upload(
    @FormDataParam("keywords") List<FormDataBodyPart> bparts,
    @FormDataParam("file") InputStream file_in,
    @FormDataParam("file") FormDataContentDisposition contentdisp)
    throws Exception {
    // Get the keyword strings.
    ArrayList<String> keywords = new ArrayList<String>(); 
    for (FormDataBodyPart bpart : keywords)
        keywords.add(bpart.getValueAs(String.class));

    // Do something with the form data... 
}

And that should work.  It's a bit awkward, but still a fairly simple solution.  Finally, I must acknowledge these two threads, which provided the clues I needed to figure this out.