2011-12-09

Stripes : store action bean field value in session

When storing a complex object in session, it becomes a little bit tricky to control binding and validation.
For simple objects, the wizard fields is sufficient, but I sometimes have to deal with an object which is too complex to keep it around using wizard fields.

If the object is stored in database, the common approach for editing POJO is to pre-polulate the object before binding as explained in the official Best Practices page.
It would look as follows:


public class EditActionBean implements ActionBean {
  private ComplexObj obj;
  
  @Before(LifecycleStage.BindingAndValidation)
  public void preLoad() {
    obj = retrieveFromDb();
  }

  @HandlesEvent("edit")
  public Resolution edit() {
    // there's no validation error
    // let's update database
    updateDB(obj);
    return ...;
  }
}

It seems to be easy to use the same approach for session-stored object.
Here's how it would look like (if you wonder why I call methods of ActionBeanContext to get/set the session attribute, it is also explained in the official Best Practices).


public class EditActionBean implements ActionBean {
  private ComplexObj obj;
  
  @Before(LifecycleStage.BindingAndValidation)
  public void preLoad() {
    obj = getContext().getObjInSession();
  }

  @HandlesEvent("edit")
  public Resolution edit() {
    // there's no validation error
    // let's replace the object in session
    getContext().setObjInSession(obj);
    return ...;
  }
}

But this works only when there is no validation errors.
When there is a validation error, the source page (i.e. the page that the request is submitted from) is displayed with an error message.
Although it looks fine, your object in session could be modified at this point.
This is because binding occurs in prior to validation process and it is performed over the pre-populated object which is just a reference to the actual object in session.

I had tried several different approaches to solve this issue and found a reasonable solution.
It needs an annotation and an interceptor.
I will put the code for these classes later, but here's how the action bean would look like.


public class EditActionBean implements ActionBean {
  @SessionField("attrName")
  private ComplexObj obj;

  @HandlesEvent("edit")
  public Resolution edit() {
    return ...;
  }
}

The field is annotated with @SessionField with the name of the session attribute as its parameter.
And that's it.
No code for pre-population or update is necessary.

The interceptor basically does the same thing: pre-populating the field value before binding and update the session attribute if there is no validation error.
The only difference is that the interceptor creates a deep copy of the object when pre-popluating the field value.
This way, the actual object in session won't be modified when validation failed.

Note that the object stored in session and its child objects must implements java.io.Serializable.

SessionField.java (Annotation)

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface SessionField
{
  /**
   * The name of the session attribute.
   */
  String value();
}
SessionFieldInterceptor.java

@Intercepts({
  LifecycleStage.ActionBeanResolution, LifecycleStage.EventHandling
})
public class SessionFieldInterceptor implements Interceptor
{

  /** Lazily filled in map of Class to methods annotated with SpringBean. */
  private static Map<Class<?>, Collection<Field>> fieldMap = new ConcurrentHashMap<Class<?>, Collection<Field>>();

  @Override
  public Resolution intercept(ExecutionContext executionContext) throws Exception
  {
    Resolution resolution = executionContext.proceed();
    LifecycleStage stage = executionContext.getLifecycleStage();
    ActionBean actionBean = executionContext.getActionBean();
    Class<? extends ActionBean> clazz = actionBean.getClass();

    if (LifecycleStage.ActionBeanResolution.equals(stage))
    {
      loadFromSession(executionContext, actionBean, getSessionFields(clazz));
    }
    else if (LifecycleStage.EventHandling.equals(stage)
      && executionContext.getActionBeanContext().getValidationErrors().isEmpty())
    {
      // Save only when there is no validation error.
      saveToSession(executionContext, actionBean, getSessionFields(clazz));
    }
    return resolution;
  }

  /** Save the field value as a session attribute */
  protected void saveToSession(ExecutionContext executionContext, ActionBean actionBean,
    Collection<Field> fields) throws IllegalAccessException
  {
    for (Field field : fields)
    {
      // Get the field value from the action bean.
      Object newValue = field.get(actionBean);
      HttpSession session = executionContext.getActionBeanContext()
        .getRequest()
        .getSession(true);
      session.setAttribute(getSessionAttrName(field), newValue);
    }
  }

  /** Pre-populating the field value with the object stored in session */
  protected void loadFromSession(ExecutionContext executionContext, ActionBean actionBean,
    Collection<Field> fields) throws IllegalAccessException, IOException,
    ClassNotFoundException
  {
    for (Field field : fields)
    {
      Object fieldValue = getSessionAttribute(executionContext, field);
      if (fieldValue != null)
      {
        field.set(actionBean, deepCopy(fieldValue));
      }
    }
  }

  /** Tries retrieving the object attached to session. Returns null if not found. */
  protected Object getSessionAttribute(ExecutionContext executionContext, Field field)
  {
    String attrName = getSessionAttrName(field);
    HttpSession session = executionContext.getActionBeanContext()
      .getRequest()
      .getSession(false);
    if (session != null)
    {
      return session.getAttribute(attrName);
    }
    return null;
  }

  /** Get the session attribute name from the annotation parameter. */
  protected String getSessionAttrName(Field field)
  {
    SessionField sessionField = field.getAnnotation(SessionField.class);
    return sessionField.value();
  }

  /** Returns a list of fields which are annotated with @SessionField */
  protected Collection<Field> getSessionFields(Class<? extends ActionBean> clazz)
  {
    Collection<Field> fields = fieldMap.get(clazz);
    if (fields == null)
    {
      fields = ReflectUtil.getFields(clazz);
      Iterator<Field> iterator = fields.iterator();
      while (iterator.hasNext())
      {
        Field field = iterator.next();
        if (!field.isAnnotationPresent(SessionField.class))
        {
          iterator.remove();
        }
        else if (!field.isAccessible())
        {
          // If the field isn't public, try to make it accessible
          try
          {
            field.setAccessible(true);
          }
          catch (SecurityException se)
          {
            throw new StripesRuntimeException(
              "Field "
                + clazz.getName()
                + "."
                + field.getName()
                + "is marked "
                + "with @SessionField and is not public. An attempt to call "
                + "setAccessible(true) resulted in a SecurityException. Please "
                + "either make the field public, annotate a public setter instead "
                + "or modify your JVM security policy to allow SessionFieldInterceptor to "
                + "setAccessible(true).", se);
          }
        }
      }
      fieldMap.put(clazz, fields);
    }
    return fields;
  }

  /** Returns a deep copy of the passed object */
  public static Object deepCopy(Object src) throws IOException, ClassNotFoundException
  {
    Object obj = null;
    ByteArrayOutputStream bos = new ByteArrayOutputStream();
    ObjectOutputStream out = new ObjectOutputStream(bos);
    out.writeObject(src);
    out.flush();
    out.close();

    ObjectInputStream in = new ObjectInputStream(
      new ByteArrayInputStream(bos.toByteArray()));
    obj = in.readObject();

    return obj;
  }
}

Edited on 2012-04-16
The @Session annotation in Stripes-Stuff works mostly the same way as the above @SessionField.
But it does not create a diffensive copy of the object being edited, so when the property is a complex java type, some fields of the instance may be modified even when validation fails.
Thanks to Christian Poitras (one of the authors of Stripes Stuff) for providing the spec info about @Session annotation!