J2EE developer with over 7 years of experience in designing and implementing enterprise j2ee solutions based on open source technologies like Tapestry, Hibernate, Spring. Current interests include Tapestry, Plastic, Spock, Scala. Taha is a DZone MVB and is not an employee of DZone and has posted 40 posts at DZone. You can read more from them at their website. View Full User Profile

Ajax Upload for Tapestry

06.27.2011
| 4161 views |
  • submit to reddit

Tapestry has an excellent support for JavaScript and Ajax. It strikes a perfect balance between how much a framework should assist and how much the developer should do. Most component-based frameworks, rather than assisting, supervise and most action-based frameworks leave even the integration to the developer. Tapestry provides you with events which you can easily connect to your JavaScript events/functions and all the rest is, as I keep on saying, magic.

In this post, I will talk about integrating an Ajax based upload library, file-uploader with Tapestry.

Ajax Upload

Ajax based upload is not fully supported by all browsers. So this library falls back to iframe based form submission in case the browser does not support Ajax upload. If you want to know more about Ajax-upload you can read the very well written source code of this library along with this.

Although this library is very good but it lacks a few features which I had to add myself.

  1. Once an attachment is uploaded, there is no way to remove that attachment.
  2. If the form is to be resubmitted, the previous uploaded files should be displayed as well
  3. The text displayed on the buttons is not configurable

With such a nicely written script, it was quite easy to accomplish this. I am not going to discuss the modifications except for those which are directly related to the integration.

Decoding the request

The upload request can come in two forms, either as an Ajax-request with input stream as the uploaded file or as a Multipart request. The latter is already taken care of by tapestry-upload module’s MultipartServletRequestFilter which checks if the request is multipart, and in case it is, then decodes it using MultipartDecoder. The decoder uses the Apache commons upload library to decode the request and stores the uploaded files in the form of UploadedFile. The only thing that compelled me to override MultipartDecoder service is the cleanup it does at the end of the request. As Ajax upload will be using multiple requests(one for each upload and one of form submission), the cleanup has to be prevented, so in the Module class we override the service

@Scope(ScopeConstants.PERTHREAD)
public static MultipartDecoder buildMultipartDecoder2(RegistryShutdownHub shutdownHub,
      @Autobuild MultipartDecoderImpl multipartDecoder)
{

   if (needToAddShutdownListener.getAndSet(false))
   {
      shutdownHub.addRegistryShutdownListener(new RegistryShutdownListener()
      {
         @Override
         public void registryDidShutdown()
         {
            FileCleaner.exitWhenFinished();
         }
      });
   }

   return multipartDecoder;
}

public static void contributeServiceOverride(@InjectService("MultipartDecoder2") MultipartDecoder multipartDecoder,
      @SuppressWarnings("rawtypes") MappedConfiguration<Class, Object> overrides)
{
   overrides.add(MultipartDecoder.class, multipartDecoder);
}

The Ajax upload request is decoded in the same way. There is a AjaxUploadServletRequestFilter which checks if the request is an Ajax upload request and in case it is then passes the request to AjaxUploadDecoder for extracting the uploaded file.

public class AjaxUploadServletRequestFilter implements HttpServletRequestFilter
{

   private AjaxUploadDecoder decoder;

   public AjaxUploadServletRequestFilter(AjaxUploadDecoder decoder)
   {
      this.decoder = decoder;
   }

   @Override
   public boolean service(HttpServletRequest request,
          HttpServletResponse response, HttpServletRequestHandler handler)
         throws IOException
   {
      if (decoder.isAjaxUploadRequest(request))
      {
         decoder.setupUploadedFile(request);
      }

      return handler.service(request, response);
   }

}

The interface and implementation of Ajax upload decoder is below

public interface AjaxUploadDecoder
{

   boolean isAjaxUploadRequest(HttpServletRequest request);

   boolean isAjaxUploadRequest(Request request);

   UploadedFile getFileUpload();

   void setupUploadedFile(HttpServletRequest request);

}

public class AjaxUploadDecoderImpl implements AjaxUploadDecoder
{
   private UploadedFileItem uploadedFile;

   public static final String AJAX_UPLOAD_HEADER = "X-File-Name"; 

   private FileItemFactory fileItemFactory;

   public AjaxUploadDecoderImpl(FileItemFactory fileItemFactory)
   {
      this.fileItemFactory = fileItemFactory;
   }

   @Override
   public boolean isAjaxUploadRequest(HttpServletRequest request)
   {
      return request.getHeader(AJAX_UPLOAD_HEADER) != null;
   }

   @Override
   public boolean isAjaxUploadRequest(Request request)
   {
      return request.isXHR() && request.getHeader(AJAX_UPLOAD_HEADER) != null;
   }

   @Override
   public void setupUploadedFile(HttpServletRequest request)
   {
      String fieldName = request.getHeader(AJAX_UPLOAD_HEADER);
      FileItem item = fileItemFactory.createItem(fieldName,
            request.getContentType(), false,
            request.getParameter(AjaxUploadConstants.FILE_PARAMETER));
      try
      {
         TapestryInternalUtils.copy(request.getInputStream(), item.getOutputStream());
      }
      catch (IOException e)
      {
         throw new RuntimeException("Could not copy request's input stream to file", e);
      }

      uploadedFile = new UploadedFileItem(item);
   }

   @Override
   public UploadedFile getFileUpload()
   {
      return uploadedFile;
   }

}

The main job of creating the upload is delegated to the FileItemFactory of apache commons upload library.

Integration

The upload script requires the following arguments :-

  1. element: Element which will be used for creating the component
  2. action: URL to which the file will be sent. The script expects a JSON in respose with success = true for successful upload. In case of an error, an error field is expected to contain the error message
  3. cancelLink: URL which will be called when upload is cancelled
  4. removeLink: URL which will be called when an uploaded file is to be removed
  5. initializeUploadsLink: URL which will be called when the component is loaded. The script expects a JSON containing a list of initially uploaded files, in case of resubmission
  6. sizeLimit: Maximum size of file allowed to be uploaded
  7. name: Name of the file field created
  8. uploadText: Text to be displayed on the upload button
  9. dropText: Text to be displayed on the drop area, in case of a drag-n-drop

All this is done by the component AjaxUpload

@SupportsInformalParameters
@Import(library = "ajaxupload.js", stylesheet = "ajaxupload.css")
public class AjaxUpload extends AbstractField
{

   public static final String FILE_PARAMETER = "qqfile";
   private static final String STYLE_TO_HIDE_INPUT_TEXT = "display:inline;"
         + "color:transparent;background:transparent;" + "border:0;height:1px;width:1px;";

   @Inject
   private JavaScriptSupport javaScriptSupport;

   @Inject
   private ComponentResources resources;

   @Parameter(required = true, autoconnect = true, principal = true)
   private List<UploadedFile> value;

   @SuppressWarnings("unused")
   @Parameter
   private boolean uploaded;

   @Symbol(UploadSymbols.REQUESTSIZE_MAX)
   @Inject
   private int maxSize;

   @Parameter(value = "1", defaultPrefix = BindingConstants.LITERAL)
   private int maxFiles;

   @Inject
   private Request request;

   @Inject
   private Messages messages;

   @Inject
   private MultipartDecoder multipartDecoder;

   @Inject
   private AjaxUploadDecoder ajaxDecoder;

   /**
    * The object that will perform input validation. The "validate:" binding
    * prefix is generally used to provide this object in a declarative fashion.
    */
   @Parameter(defaultPrefix = BindingConstants.VALIDATE)
   private FieldValidator<Object> validate;

   @Environmental
   private ValidationTracker tracker;

   @SuppressWarnings("unused")
   @Environmental
   private FormSupport formSupport;

   @Inject
   private ComponentDefaultProvider defaultProvider;

   @Inject
   private FieldValidationSupport fieldValidationSupport;

   @SuppressWarnings("unused")
   @Mixin
   private RenderDisabled renderDisabled;

   /**
    * Computes a default value for the "validate" parameter using
    * {@link FieldValidatorDefaultSource}.
    */
   final Binding defaultValidate()
   {
      return defaultProvider.defaultValidatorBinding("value", resources);
   }

   public AjaxUpload()
   {
   }

   // For testing
   AjaxUpload(List<UploadedFile> value, FieldValidator<Object> validate,
         MultipartDecoder multipartDecoder,
         AjaxUploadDecoder ajaxDecoder,
         ValidationTracker tracker, ComponentResources resources,
         FieldValidationSupport fieldValidationSupport,
         JavaScriptSupport javaScriptSupport)
   {
      this.value = value;
      if (validate != null)
         this.validate = validate;
      this.multipartDecoder = multipartDecoder;
      this.tracker = tracker;
      this.resources = resources;
      this.fieldValidationSupport = fieldValidationSupport;
      this.javaScriptSupport = javaScriptSupport;
      this.ajaxDecoder = ajaxDecoder;
      maxFiles = 1;
   }

   void beginRender(MarkupWriter writer)
   {
      writer.element("input", "type", "text", "id",
          getClientId(), "style", STYLE_TO_HIDE_INPUT_TEXT, "name",
            getControlName());
      validate.render(writer);
      decorateInsideField();
   }

   private String getWrapperClientId()
   {
      return getClientId() + "_wrapper";
   }

   private String getFileControlName()
   {
      return getControlName() + "_file";
   }

   void afterRender(final MarkupWriter writer)
   {
      writer.end();
      writer.element("span", "id", getWrapperClientId(), "style", "display:inline-block");
      writer.end();
   }

   @AfterRender
   void addJavaScript()
   {
      JSONObject arguments = fillArguments();
      javaScriptSupport.addScript("new qq.FileUploader(%s);", arguments);
   }

   JSONObject fillArguments()
   {
      JSONObject spec = new JSONObject();
      for (String informalParameter : resources.getInformalParameterNames())
      {
         spec.put(informalParameter,
           resources.getInformalParameter(informalParameter, String.class));
      }

      spec.put("element", getElementId());
      spec.put("sizeField", getControlName());
      spec.put("uploadText", messages.get("ajaxupload.upload-text"));
      spec.put("dropText", messages.get("ajaxupload.drop-text"));
      spec.put("action", getUploadLink());
      spec.put("cancelLink", getCancelLink());
      spec.put("removeLink", getRemoveLink());
      spec.put("initializeUploadsLink", getInitializeUploadsLink());
      spec.put("sizeLimit", maxSize < 0 ? 0 : maxSize);
      spec.put("name", getFileControlName());
      spec.put("id", getFileControlName());
      return spec;
   }

   private Object getElementId()
   {
      return new JSONLiteral("document.getElementById('" + getWrapperClientId() + "')");
   }

   String getUploadLink()
   {
      final Link link = resources.createEventLink("upload");
      return link.toAbsoluteURI();
   }

   String getCancelLink()
   {
      final Link cancelLink = resources.createEventLink("cancelUpload");
      return cancelLink.toAbsoluteURI();
   }

   String getRemoveLink()
   {
      final Link removeLink = resources.createEventLink("removeUpload");
      return removeLink.toAbsoluteURI();
   }

   String getInitializeUploadsLink()
   {
      final Link initializeUploadsLink = resources.createEventLink("initializeUploads");
      return initializeUploadsLink.toAbsoluteURI();
   }

   /**
    * Converts the current list of uploaded files to a JSON object containing a json array
    *  with each element containing the name of the file and a unique key for
    * identification. The unique key is index of the uploaded file in parameter
    * <code>value</code>
    *
    * @return
    */
   JSONObject onInitializeUploads()
   {
      JSONArray array = new JSONArray();
      if (value != null)
      {
         for (int i = 0; i < value.size(); ++i)
         {
            if (value.get(i) == null)
            {
               continue;
            }
            JSONObject indexWithFileName = new JSONObject();
            indexWithFileName.put("serverIndex", i);
            indexWithFileName.put("fileName", value.get(i).getFilePath());
            array.put(indexWithFileName);
         }
      }
      return new JSONObject().put("uploads", array);
   }

   Object onUpload()
   {
      if (hasMaximumFileUploadCountReached())
      {
         return createFailureResponse(messages.format("ajaxupload.maxfiles", maxFiles));
      }

      final UploadedFile uploadedFile;
      if (isAjaxUpload())
      {
         uploadedFile = createUploadedFileFromRequestInputStream();
      }
      else
      {
         uploadedFile = createUploadedFileFromMultipartForm();
      }

      if (value == null)
      {
         value = new ArrayList<UploadedFile>();
      }

      value.add(uploadedFile);

      return createSuccessResponse(value.size() - 1); // Last index
   }

   private boolean hasMaximumFileUploadCountReached()
   {
      if (value == null)
      {
         return maxFiles <= 0;
      }

      // Can't rely on value's size as some of the values can be null
      int size = 0;
      for (UploadedFile uploadedFile : value)
      {
         if (uploadedFile != null)
         {
            size++;
         }
      }
      return this.maxFiles <= size;
   }

   private boolean isAjaxUpload()
   {
      return ajaxDecoder.isAjaxUploadRequest(request);
   }

   private UploadedFile createUploadedFileFromMultipartForm()
   {
      return multipartDecoder.getFileUpload(FILE_PARAMETER);
   }

   private UploadedFile createUploadedFileFromRequestInputStream()
   {
      return ajaxDecoder.getFileUpload();
   }

   private Object createFailureResponse(String errorMessage)
   {
      JSONObject response = new JSONObject();
      response.put("success", false);
      response.put("error", errorMessage);
      if (!request.isXHR())
      {
         return new StatusResponse(response.toString());
      }
      else
      {
         return response;
      }
   }

   private Object createSuccessResponse(int serverIndex)
   {
      JSONObject response = new JSONObject();
      response.put("success", true);
      response.put("serverIndex", serverIndex);
      if (!request.isXHR())
      {
         return new StatusResponse(response.toString());
      }
      else
      {
         return response;
      }
   }

   void onRemoveUpload(int serverIndex)
   {
      // We use index of an uploadedFile in 'value' as a key at
      // the client side and if the uploaded file is removed we cleanup and set
      // the element at that index to null. As the 'value' may contain null, we
      // need to remove those entries in processSubmission()
      if (value != null && serverIndex >= 0 && serverIndex < value.size())
      {
         UploadedFile item = value.get(serverIndex);
         if (item != null && (item instanceof UploadedFileItem))
         {
            ((UploadedFileItem) item).cleanup();
         }
         value.set(serverIndex, null);
      }
   }

   void onCancelUpload(String fileName)
   {
      // TODO: Some how remove the partially uploaded file
   }

   @Override
   protected void processSubmission(String elementName)
   {
      // Nothing to process from current request as the uploads have already
      // been received and stored in value
      if (value != null)
      {
         // Remove any null values in 'value'
         removeNullsFromValue();
      }

      try
      {
         fieldValidationSupport.validate(value, resources, validate);
      }
      catch (ValidationException ex)
      {
         tracker.recordError(this, ex.getMessage());
      }
   }

   private void removeNullsFromValue()
   {
      List<UploadedFile> uploads = new ArrayList<UploadedFile>();
      for (UploadedFile upload : value)
      {
         if (upload != null)
         {
            uploads.add(upload);
         }
      }
      value = uploads;
   }

   public List<UploadedFile> getValue()
   {
      return value;
   }

   @Override
   public boolean isRequired()
   {
      return value != null && value.size() > 0;
   }

   AjaxUpload injectDecorator(ValidationDecorator decorator)
   {
      setDecorator(decorator);

      return this;
   }

   AjaxUpload injectRequest(Request request)
   {
      this.request = request;

      return this;
   }

   AjaxUpload injectFormSupport(FormSupport formSupport)
   {
      // We have our copy ...
      this.formSupport = formSupport;

      // As does AbstractField
      setFormSupport(formSupport);

      return this;
   }

   AjaxUpload injectFieldValidator(FieldValidator<Object> validator)
   {
      this.validate = validator;

      return this;
   }

   void injectResources(ComponentResources resources)
   {
      this.resources = resources;
   }

   void injectValue(List<UploadedFile> value)
   {
      this.value = value;
   }

   void injectMessages(Messages messages)
   {
      this.messages = messages;
   }

   static class StatusResponse implements StreamResponse
   {

      private String text;

      StatusResponse(String text)
      {
         this.text = text;
      }

      @Override
      public String getContentType()
      {
         return "text/html";
      }

      @Override
      public InputStream getStream() throws IOException
      {
         return new ByteArrayInputStream(text.toString().getBytes());
      }

      @Override
      public void prepareResponse(Response response)
      {

      }

      public JSONObject getJSON()
      {
         return new JSONObject(text);
      }
   }

   public void injectFieldValidationSupport(FieldValidationSupport support)
   {
      this.fieldValidationSupport = support;
   }

}

Note, I have embedded a textfield into the component. The reason for doing this is to facilitate client-side validation support, which requires an input(as it uses the form attribute of the component, hidden can’t be used as it is not visible).

In the upload request, we check to see the type of request and then get the uploaded file from the corresponding decoder. We send the index of the uploaded file to the script which is used for uniquely identifying the uploaded file. When the file is to be removed, this index is sent with the request and based on that index, the file is removed.

Usage

It can be used in the template as

<form t:type='form'>
   <input t:type='tawus/ajaxupload' t:id='uploads'/>
</form>

and in the class file 

@Persist
@Property
private List<UploadedFile> uploads;

void onSuccess(){
   //Use uploads
   uploads.clear();
}

The value has to be persisted at the value has to live multiple requests.

You can find the module here

 

From http://tawus.wordpress.com/2011/06/25/ajax-upload-for-tapestry/

Published at DZone with permission of Taha Siddiqi, author and DZone MVB.

(Note: Opinions expressed in this article and its replies are the opinions of their respective authors and not those of DZone, Inc.)

Comments

Sirikant Noori replied on Sun, 2012/01/15 - 12:21pm

Some problems when I use Chrome browser(version 13/14) to upload files. The first time I upload two files to server, but server get only one uploaded file. And after the first time uploading, I try again to upload two files, this time server could get the correct files.

Comment viewing options

Select your preferred way to display the comments and click "Save settings" to activate your changes.