26

I have a controller with the POST handler defined like so:

@RequestMapping(value="/ajax/saveVendor.do", method = RequestMethod.POST)
public @ResponseBody AjaxResponse saveVendor( @Valid UIVendor vendor,
                                              BindingResult result,
                                              Locale currentLocale )

The UIVendor object, when viewed in JSON format, looks like:

var vendor = 
{
  vendorId: 123,
  vendorName: "ABC Company",
  emails : [
             { emailAddress: "abc123@abc.com", flags: 2 },
             { emailAddress: "xyz@abc.com", flags: 3 }
           ]
}

The UIVendor bean has a field called "Emails" of type ArrayList, with appropriate setters and getters (getEmails/setEmails). The NotificationEmail object has the appropriate public setters/getters as well.

When I try to post the object using the following code:

$.post("ajax/saveVendor.do", $.param(vendor), saveEntityCallback, "json" );

I get this error in the logs:

Invalid property 'emails[0][emailAddress]' of bean class [beans.UIVendor]: Property referenced in indexed property path 'emails[0][emailAddress]' is neither an array nor a List nor a Map; returned value was [abc123@abc.com]

How do I correctly post a nested object like this to a Spring controller and have it correctly deserialize into the appropriate object structure.

UPDATE Per Bohzo's request, here is the content of the UIVendor class. This class wraps a web-service-generated bean class, exposing the VendorAttributes as individual fields:

package com.mycompany.beans;

import java.util.*;
import org.apache.commons.lang.*;
import com.mycompany.domain.Vendor;
import com.mycompany.domain.VendorAttributes;
import org.apache.commons.logging.*;
import org.codehaus.jackson.annotate.JsonIgnore;

public class UIVendor
{
  private final Log logger = LogFactory.getLog( this.getClass() );
  private Vendor vendor;
  private boolean ftpFlag;
  private String ftpHost;
  private String ftpPath;
  private String ftpUser;
  private String ftpPassword; 
  private List<UINotificationEmail> emails = null;

  public UIVendor() { this( new Vendor() ); }
  public UIVendor( Vendor vendor )
  {
    this.vendor = vendor;
    loadVendorAttributes();
  }

  private void loadVendorAttributes()
  {
    this.ftpFlag = false;
    this.ftpHost = this.ftpPassword = this.ftpPath = this.ftpUser = "";
    this.emails = null;

    for ( VendorAttributes a : this.vendor.getVendorAttributes() )
    {
      String key = a.getVendorFakey();
      String value = a.getVendorFaValue();
      int flags = a.getFlags();

      if ( StringUtils.isBlank(key) || StringUtils.isBlank(value) ) continue;

      if ( key.equals( "ftpFlag" ) )
      {
        this.ftpFlag = BooleanUtils.toBoolean( value );
      }
      else if ( key.equals( "ftpHost" ) )
      {
        this.ftpHost = value;
      }
      else if ( key.equals("ftpPath") )
      {
        this.ftpPath = value;
      }
      else if ( key.equals("ftpUser") )
      {
        this.ftpUser = value;
      }
      else if ( key.equals("ftpPassword") )
      {
        this.ftpPassword = value;
      }
      else if ( key.equals("email") )
      {
        UINotificationEmail email = new UINotificationEmail(value, flags);
        this.getEmails().add( email );
      }
    }
  }

  private void saveVendorAttributes()
  {
    int id = this.vendor.getVendorId();
    List<VendorAttributes> attrs = this.vendor.getVendorAttributes();
    attrs.clear();

    if ( this.ftpFlag )
    {      
      VendorAttributes flag = new VendorAttributes();
      flag.setVendorId( id );
      flag.setStatus( "A" );
      flag.setVendorFakey( "ftpFlag" );
      flag.setVendorFaValue( BooleanUtils.toStringTrueFalse( this.ftpFlag ) );
      attrs.add( flag );

      if ( StringUtils.isNotBlank( this.ftpHost ) )
      {
        VendorAttributes host = new VendorAttributes();
        host.setVendorId( id );
        host.setStatus( "A" );
        host.setVendorFakey( "ftpHost" );
        host.setVendorFaValue( this.ftpHost );
        attrs.add( host );

        if ( StringUtils.isNotBlank( this.ftpPath ) )
        {
          VendorAttributes path = new VendorAttributes();
          path.setVendorId( id );
          path.setStatus( "A" );
          path.setVendorFakey( "ftpPath" );
          path.setVendorFaValue( this.ftpPath );
          attrs.add( path );
        }

        if ( StringUtils.isNotBlank( this.ftpUser ) )
        {
          VendorAttributes user = new VendorAttributes();
          user.setVendorId( id );
          user.setStatus( "A" );
          user.setVendorFakey( "ftpUser" );
          user.setVendorFaValue( this.ftpUser );
          attrs.add( user );
        }

        if ( StringUtils.isNotBlank( this.ftpPassword ) )
        {
          VendorAttributes password = new VendorAttributes();
          password.setVendorId( id );
          password.setStatus( "A" );
          password.setVendorFakey( "ftpPassword" );
          password.setVendorFaValue( this.ftpPassword ); 
          attrs.add( password );
        }
      }      
    }

    for ( UINotificationEmail e : this.getEmails() )
    {
      logger.debug("Adding email " + e );
      VendorAttributes email = new VendorAttributes();
      email.setStatus( "A" );
      email.setVendorFakey( "email" );
      email.setVendorFaValue( e.getEmailAddress() );
      email.setFlags( e.getFlags() );
      email.setVendorId( id );
      attrs.add( email );
    }
  }

  @JsonIgnore
  public Vendor getVendor()
  {
    saveVendorAttributes();
    return this.vendor;
  }

  public int getVendorId()
  {
    return this.vendor.getVendorId();
  }
  public void setVendorId( int vendorId )
  {
    this.vendor.setVendorId( vendorId );
  }

  public String getVendorType()
  {
    return this.vendor.getVendorType();
  }
  public void setVendorType( String vendorType )
  {
    this.vendor.setVendorType( vendorType );
  }

  public String getVendorName()
  {
    return this.vendor.getVendorName();
  }
  public void setVendorName( String vendorName )
  {
    this.vendor.setVendorName( vendorName );
  }

  public String getStatus()
  {
    return this.vendor.getStatus();
  }
  public void setStatus( String status )
  {
    this.vendor.setStatus( status );
  }

  public boolean isFtpFlag()
  {
    return this.ftpFlag;
  }
  public void setFtpFlag( boolean ftpFlag )
  {
    this.ftpFlag = ftpFlag;
  }

  public String getFtpHost()
  {
    return this.ftpHost;
  }
  public void setFtpHost( String ftpHost )
  {
    this.ftpHost = ftpHost;
  }

  public String getFtpPath()
  {
    return this.ftpPath;
  }
  public void setFtpPath( String ftpPath )
  {
    this.ftpPath = ftpPath;
  }

  public String getFtpUser()
  {
    return this.ftpUser;
  }
  public void setFtpUser( String ftpUser )
  {
    this.ftpUser = ftpUser;
  }

  public String getFtpPassword()
  {
    return this.ftpPassword;
  }
  public void setFtpPassword( String ftpPassword )
  {
    this.ftpPassword = ftpPassword;
  }

  public List<UINotificationEmail> getEmails()
  {
    if ( this.emails == null )
    {
      this.emails = new ArrayList<UINotificationEmail>();
    }
    return emails;
  }

  public void setEmails(List<UINotificationEmail> emails)
  {
    this.emails = emails;
  }
}

UPDATE 2 Here's the output from Jackson.:

{
  "vendorName":"MAIL",
  "vendorId":45,
  "emails":
  [
    {
      "emailAddress":"dfg",
      "success":false,
      "failure":false,
      "flags":0
    }
  ],
  "vendorType":"DFG",
  "ftpFlag":true,
  "ftpHost":"kdsfjng",
  "ftpPath":"dsfg",
  "ftpUser":"sdfg",
  "ftpPassword":"sdfg",
  "status":"A"
}

And here is the structure of the object I'm returning on the POST:

{
  "vendorId":"45",
  "vendorName":"MAIL",
  "vendorType":"DFG",
  "ftpFlag":true,
  "ftpHost":"kdsfjng",
  "ftpUser":"sdfg",
  "ftpPath":"dsfg",
  "ftpPassword":"sdfg",
  "status":"A",
  "emails": 
            [
              {
                "success":"false",
                "failure":"false",
                "emailAddress":"dfg"
              },
              {
                "success":"true",
                "failure":"true",
                "emailAddress":"pfc@sj.org"
              }
            ]
}

I've tried serializing using the JSON library from www.json.org as well, and the result is exactly what you see above. However, when I post that data, all of the fields in the UIVendor object passed to the controller are null (although the object is not).

pconrey
  • 5,805
  • 7
  • 29
  • 38

4 Answers4

33

Update: since Spring 3.1, it's possible to use @Valid On @RequestBody Controller Method Arguments.

@RequestMapping(value="/ajax/saveVendor.do", method = RequestMethod.POST)
public @ResponseBody AjaxResponse saveVendor( @Valid @RequestBody UIVendor vendor,
                                              BindingResult result,
                                              Locale currentLocale )

After much trial and error, I've finally figured out, as well as I can, what the problem is. When using the following controller method signature:

@RequestMapping(value="/ajax/saveVendor.do", method = RequestMethod.POST)
public @ResponseBody AjaxResponse saveVendor( @Valid UIVendor vendor,
                                              BindingResult result,
                                              Locale currentLocale )

The client script has to pass the field in the object in post-data (typically "application/x-www-form-urlencoded") format (i.e., field=value&field2=value2). This is done in jQuery like this:

$.post( "mycontroller.do", $.param(object), callback, "json" )

This works fine for simple POJO objects that don't have child objects or collections, but once you introduce significant complexity to the object being passed, the notation used by jQuery to serialize the object data is not recognized by Spring's mapping logic:

object[0][field]

The way that I solved this problem was to change the method signature in the controller to:

@RequestMapping(value="/ajax/saveVendor.do", method = RequestMethod.POST)
public @ResponseBody AjaxResponse saveVendor( @RequestBody UIVendor vendor,
                                              Locale currentLocale )

And change the call from client to:

    $.ajax(
            {
              url:"ajax/mycontroller.do", 
              type: "POST", 
              data: JSON.stringify( objecdt ), 
              success: callback, 
              dataType: "json",
              contentType: "application/json"
            } );    

This requires the use of the JSON javascript library. It also forces the contentType to "application/json", which is what Spring expects when using the @RequestBody annotation, and serializes the object to a format that Jackson can deserialize into a valid object structure.

The only side effect is that now I have to handle my own object validation inside of the controller method, but that's relatively simple:

BindingResult result = new BeanPropertyBindingResult( object, "MyObject" );
Validator validator = new MyObjectValidator();
validator.validate( object, result );

If anyone has any suggestions to improve upon this process, I'm all ears.

user1766760
  • 631
  • 2
  • 8
  • 26
pconrey
  • 5,805
  • 7
  • 29
  • 38
  • I am working on the same issues. did you think of anything better for the validator – SJS Jun 01 '12 at 19:30
  • As an aside: I don't think *"[...] "application/json", which is what Spring expects when using the @RequestBody annotation"* is true. For me, `@RequestBody MultiValueMap body` works fine with `application/x-www-form-urlencoded` too. But it needs something like `` in the definition of the available message converters. – Arjan Nov 08 '12 at 10:17
  • 1
    *TL;DR* - set `contentType: "application/json"` and `data: JSON.stringify(obj)` – Madbreaks Sep 27 '19 at 17:41
24

first, sorry for my poor english

in spring, if the param name is like object[0][field], they will consider it as a class type like sub

public class Test {

    private List<Map> field;

    /**
     * @return the field
     */
    public List<Map> getField() {
        return field;
    }

    /**
     * @param field the field to set
     */
    public void setField(List<Map> field) {
        this.field = field;
    }
}

that's why the spring will throw an exception said something "is neither an array nor a List nor a Map".

only when the param name is object[0].field, spring will treat it as a class's field.

you could find the constants def in org.springframework.beans.PropertyAccessor

so my solution is write a new param plugin for jquery, like below:

(function($) {
  // copy from jquery.js
  var r20 = /%20/g,
  rbracket = /\[\]$/;

  $.extend({
    customParam: function( a ) {
      var s = [],
        add = function( key, value ) {
          // If value is a function, invoke it and return its value
          value = jQuery.isFunction( value ) ? value() : value;
          s[ s.length ] = encodeURIComponent( key ) + "=" + encodeURIComponent( value );
        };

      // If an array was passed in, assume that it is an array of form elements.
      if ( jQuery.isArray( a ) || ( a.jquery && !jQuery.isPlainObject( a ) ) ) {
        // Serialize the form elements
        jQuery.each( a, function() {
          add( this.name, this.value );
        });

      } else {
        for ( var prefix in a ) {
          buildParams( prefix, a[ prefix ], add );
        }
      }

      // Return the resulting serialization
      return s.join( "&" ).replace( r20, "+" );
    }
  });

/* private method*/
function buildParams( prefix, obj, add ) {
  if ( jQuery.isArray( obj ) ) {
    // Serialize array item.
    jQuery.each( obj, function( i, v ) {
      if (rbracket.test( prefix ) ) {
        // Treat each array item as a scalar.
        add( prefix, v );

      } else {
        buildParams( prefix + "[" + ( typeof v === "object" || jQuery.isArray(v) ? i : "" ) + "]", v, add );
      }
    });

  } else if (obj != null && typeof obj === "object" ) {
    // Serialize object item.
    for ( var name in obj ) {
      buildParams( prefix + "." + name, obj[ name ], add );
    }

  } else {
    // Serialize scalar item.
    add( prefix, obj );
  }
};
})(jQuery);

actual I just change the code from

buildParams( prefix + "[" + name + "]", obj[ name ], traditional, add );

to

buildParams( prefix + "." + name, obj[ name ], add );

and use $.customParam instead of $.param when do ajax request.

keshin
  • 534
  • 4
  • 8
  • 2
    This one is the right direct answer, the accepted one is a workaround. – Vitali Carbivnicii Sep 16 '14 at 21:38
  • 2
    Spot on, but a bit wordy! To summarise: replace `emails[0][emailAddress]` with `emails[0].emailAddress` – randomsimon Sep 29 '16 at 15:42
  • @keshin , object in my class is not list , not array and not a map. Its just another object. Is it possible to auto databinding , like email.emailAddress or email[].emailAddress, i tried email[].emailAddress and email[0].emailAddress both are not working. – anavaras lamurep Jul 08 '21 at 18:24
  • @anavaraslamurep, It has been almost ten years for this answering.... If I understand correctly, email is a field of your class, and emailAdress is a field for email which defined in email class. if that's the case, I think it should be email.emailAddress. in the form/url to bind your attributes, but I didn't try that. – keshin Jul 26 '21 at 15:37
  • @keshin , yes i tried that also , it didn't work out , finally created separate pojo , to send to backend as simple object instead object inside object. thanks for replying after so many days. it means a lot.Respect u. – anavaras lamurep Jul 28 '21 at 10:42
  • @anavaraslamurep, I just tried a controller with latest spring boot, and it works. I'm not sure what's difference between mine and yours: https://gist.github.com/keshin/1412dc1f30cad163c312a2abce3a729d – keshin Jul 28 '21 at 13:43
  • "only when the param name is object[0].field, spring will treat it as a class's field" exactly what i needed, thanks. – Jean Willian S. J. Aug 31 '21 at 22:23
5

You can try something like this:

vendor['emails[0].emailAddress'] = "abc123@abc.com";
vendor['emails[0].flags'] = 3;
vendor['emails[1].emailAddress'] = "xyz@abc.com";
vendor['emails[1].flags'] = 3;

:)

Wouter Dorgelo
  • 11,770
  • 11
  • 62
  • 80
Miro
  • 91
  • 1
  • 2
1

Define the field to be List (interface), not ArrayList (concrete type):

private List emailAddresses = new ArrayList();
Bozho
  • 588,226
  • 146
  • 1,060
  • 1,140
  • I wish that had worked, but it didn't. I'm still getting the same error. – pconrey May 05 '11 at 18:46
  • can you try the opposite - create a new vendor object and serialize it to JSON, and see what it looks like. – Bozho May 05 '11 at 19:04
  • Incidentally, this is the output from $.param(vendor) (URL decoded). I'm wondering if this format won't work with Spring: vendorId=45&vendorName=MAIL&vendorType=DFG&ftpFlag=true&ftpHost=kdsfjng&ftpUser=sdfg&ftpPath=dsfg&ftpPassword=sdfg&status=A&emails[0][success]=false&emails[0][failure]=false&emails[0][emailAddress]=dfg&emails[1][success]=true&emails[1][failure]=true&emails[1][emailAddress]=pfc@sj.org – pconrey May 05 '11 at 19:06
  • might work. But I'd prefer the JSON version. Use Jackson to transform an instance of UIVendor to JSON and see how it differs from yours. – Bozho May 05 '11 at 19:16
  • I'm a bit busy now to get into more details, but make sure your serialized output is exactly the same as the json input you are getting. And also, try to manually deserialize the receveid json. And you can add some `@JsonIgnore` to the list of attributes – Bozho May 05 '11 at 19:53
  • @Bozho I wanted to support `@Valid` with `@RequestBody` as well, so I've extended `AnnotationMethodHandlerAdapter`. In the `} else if (requestBodyFound) {` block, you'll want to basically copy the validation code applied elsewhere. It's so nice to not have those 3 lines of code scattered throughout your controller methods and worth the effort. – Steven Francolla Mar 03 '12 at 20:11
  • 1
    @StevenFrancolla Just as an FYI, Spring 3.1 supports placing `@Valid` alongside the `@RequestBody` annotations. – Ittai May 21 '12 at 18:35