Post Nested Object to Spring MVC controller using JSON
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: "[email protected]", flags: 2 },
{ emailAddress: "[email protected]", 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 [[email protected]]
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":"[email protected]"
}
]
}
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).
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.
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.
You can try something like this:
vendor['emails[0].emailAddress'] = "[email protected]";
vendor['emails[0].flags'] = 3;
vendor['emails[1].emailAddress'] = "[email protected]";
vendor['emails[1].flags'] = 3;
:)