Ruby on Rails | Screencasts | Download | Documentation | Weblog | Community | Source

Ticket #9935 (assigned enhancement)

Opened 9 months ago

Last modified 4 months ago

Accept JSON objects in HTTP request

Reported by: Assaf Assigned to: technoweenie (accepted)
Priority: normal Milestone: 2.x
Component: ActionPack Version: edge
Severity: normal Keywords:
Cc:

Description

Adds parameter parsing for HTTP requests that use the content type application/json, so you can POST, GET and PUT the same content type.

Attachments

json_request_parsing.diff (3.1 kB) - added by Assaf on 10/20/07 01:27:36.
json_request_parsing.2.diff (3.3 kB) - added by Assaf on 10/21/07 01:43:40.
Map JSON object from request to :json parameter
json_request_parsing.3.diff (4.5 kB) - added by Assaf on 10/25/07 18:26:37.
New and improved patch to access parameter based on controller name

Change History

10/20/07 01:27:36 changed by Assaf

  • attachment json_request_parsing.diff added.

10/20/07 04:00:51 changed by technoweenie

  • owner changed from core to technoweenie.
  • status changed from new to assigned.

I would imagine this would cause problems with arrays, since params is supposed to be a hash. Have you seen any issues with this? XML always has a root element so this isn't a problem.

10/20/07 07:36:33 changed by Assaf

I'm undecided. If you always treat JSON as alternative to XML, then you're only interested in a hash, and simply adding .with_indifferent_access removes the possibility of using arrays.

But JSON can also accept arrays, without the need to wrap them in container element, it could just be a parameter called :array. My current use case sends back JSON arrays, but I have none that accepts them (or for that matter XML representing an array).

So would it be easiest to just ignore arrays for now?

10/20/07 07:45:33 changed by technoweenie

XML can handle arrays just fine since there's always a root element.

<foos type="array">
  <foo type="Foo" />
  <foo type="Foo" />
</foos>
# => params[:foos]

I'd say throw the json value in a param element. :array, :data, etc.

10/20/07 22:36:34 changed by Assaf

Sending request to the server:

curl -d title=json+support&priority=3 curl -d "{ title: \"json support\", priority: 3 }" -H "Content-Type: application/json" curl -d "<task><title>json support</title><priority>3</priority></task>" -H "Content-Type: application/xml"

To process the request on the server, here's what I'm doing right now:

@task = Task.create(params)

It treats url-encoded/form-data and JSON as two alternative input formats, let the client pick which one to use. This came from using url-encoded for simple one-line request, and switching to JSON for larger test data. It also shows the error in my reasoning. It doesn't work with XML, because the XML is passed as a document into a single parameter. So for XML it would need to be:

@task = Task.create(params[:task] params)

In which case, the JSON anything-goes equivalent would be:

@task = Task.create(params[:task] params[:json] params)

So I'm leaning towards changing this to stuff the entire request into a single parameter. :json, :value or :data?

10/20/07 23:12:04 changed by technoweenie

We've been discussing this in core, and the general consensus is to only accept Hashes. Anything else raises some exception resulting in a 400. Anything you post as an array can easily be posted as a hash (and you can determine the key in your app). Sound good?

10/20/07 23:24:49 changed by Assaf

This turned out to be more tricky than hashes vs array. How would we handle a hash input like this:

{ foo: 1, bar: 2 }

Option 1. Convert it into parameters, so params[:foo] = 1, params[:bar] = 2.

Option 2. Pass it as a parameter, so params[:json] = { :foo=>1, :bar=>2 }.

I'm now leaning towards option 2.

Next patch will fail on arrays.

10/21/07 00:20:37 changed by technoweenie

I'd like to keep the behavior similar to the xml param parser. Just have to make sure you can't override things like action/controller. I'm idling in #rails-contrib if you wanna discuss in real time.

10/21/07 01:43:40 changed by Assaf

  • attachment json_request_parsing.2.diff added.

Map JSON object from request to :json parameter

10/24/07 00:37:53 changed by Assaf

Couple of thoughts from subsequent playing around.

1. XML does not impost a hash-only policy, in fact it is perfectly plausible to receive arrays:

<items type='array'>
  <item>
    <name>foo</name>
  </item>
</items>

Becomes:

{"items"=>[{"name"=>"foo"}]}

Form fields can also support arrays, so for completeness we should allow arrays in JSON as well.

2. This piece supports XML and hForm:

def update
  Item.update params[:id], params['item']
end

Since there's no wrapping name for a JSON object, you need to be more explicit:

def update
  Item.update params[:id], params['item'] || params['json']
end

Being forgetful, I'm not too kin on requiring params['json'], so looking for alternative solutions.

I'm tossing the idea of taking the controller name and using that as the parameter name for the JSON object, so this example would be in ItemsController, and the parameter name will be 'item'.

*Pro: I only need to look in params['item'] to support XML, JSON, hForm

*Con: Magic relying on controller name may get it wrong for some cases (few? many?)

*Con: The controller name is only available after routing picks the path parameters, after the request parameters are parsed

10/25/07 18:26:37 changed by Assaf

  • attachment json_request_parsing.3.diff added.

New and improved patch to access parameter based on controller name

10/25/07 18:32:41 changed by Assaf

New and improved patch stores the JSON request in the parameter _data, and then renames it based on the controller name. If the data is an array, uses the plural name (e.g. items for ItemsController), otherwise uses the singular name (e.g. item).

So the following will work, whether the controller is responding to XML, JSON or HTML forms:

def create
  Item.create params[:item]
  . . .
end

def upload
  Item.create params[:items]
end

Also, added back arrays, if you can use them from XML (see upload action), why not from JSON?

10/25/07 19:47:32 changed by technoweenie

Well, I guess my thought was that you'd just post a hash and define your own keys:

{'item': {....}}
// or
{'items': [...]}

That's how the xml parsing works, no controller_name magic involved. I'm pretty sure any :controller/:action keys would get clobbered anyway, but I'll have to double check that.

10/25/07 20:58:18 changed by Assaf

The principle I'm going by is, if you can GET one representation from a resource, you should be able to PUT that same representation back into the resource:

document = resource.get
resource.put(document)

(And likewise for POST followed by GET).

In the case of XML, you GET an element, and you can PUT/POST that element. Since there's one document element, we get a nice name for the parameter.

For JSON, we have these possibilities:

1. Return one thing, but accept another. The controller returns:

{ sku: 124 }

But the controller can only accept:

{ item: { sku: 124 } }

This makes life hard for people writing clients, they need to do extra wrapping work between any pair of GET/PUT or POST/GET.

2. Return and accept the same thing, but that same thing is a single-key hash wrapping the actual object.

Unfortunately, your controller would have to look like this:

def show
  item = Item.find(params[:id])
  respond_to do |format|
    format.xml { render :xml=>item }
    format.json { render :json=>{ :item=>item } }
  end
end

def index
  items = Item.find(:all)
  respond_to do |format|
    format.xml { render :xml=>items }
    format.json { render :json=>{ :items=>items.map { |i| { :item=>i } } } }
  end
end

And if we looked at the JavaScript code for this, it would look like:

item = eval(response)
sku = item.item.sku

3. Return and accept the same thing, that thing being the minimal JSON representation of the object (to_json), which simplifies your controller to:

def show
  item = Item.find(params[:id])
  respond_to do |format|
    format.xml  { render :xml=>item }
    format.json { render :json=>item }
  end
end

def index
  items = Item.find(:all)
  respond_to do |format|
    format.xml  { render :xml=>items }
    format.json { render :json=>items }
  end
end

4. Implement wrapping, so effectively:

render :json=>item

Could translate into:

render :text=>item.to_json(:root=>'item'), :type=>Mime::JSON

But now we're taking an XML documents and stripping out the brackets to create JSON (see option 2 for more nuances), which leads to overly verbose JSON.

So option 3 which keeps the JSON in minimal form without complicating clients or controllers is my preferred approach.

10/25/07 21:15:35 changed by technoweenie

I don't think that's overly verbose. Perhaps ActiveRecord::Base#to_json could add that extra hash key around it. I think it's a better alternative than magic based on the controller name.

10/25/07 21:26:54 changed by Assaf

To make sure the GET/PUT representations look alike, and implement XML constraints around JSON, this array:

[ { sku: 123 }, { sku: 456 } ]

Would become this array:

{ items: [ { item: { sku: 123 } }, { item: { sku: 456 } } ] }

And if we look at sample JavaScript code accessing it, this:

items = eval(json);
items.first.sku

becomes this:

item = eval(json);
items.items.first.item.sku

10/25/07 21:36:13 changed by Assaf

Also for consideration: wrapping JSON objects with single-key hashes would also require unwrapping them, so in addition to a change in to_json, we would also need a change in ActiveSupport::JSON.decode to allow ActiveResource to use the wrapped responses.

10/25/07 21:40:32 changed by technoweenie

I think you'd only need the wrapper around the outer element:

{item: {...}}
eval(json).sku

{items: [...]}
eval(json).first().sku

Changing ActiveResource to handle that would be fine. I wouldn't touch the core JSON.decode method though.

10/25/07 22:36:24 changed by Assaf

When we follow the principle of PUT-what-you-GET, then retrieving a list of records, you should be able to pick one of those records (document fragment) and PUT it back, so each record needs to be wrapped.

That again is something XML does, every record in the array can be extracted into a standalone document, so when using JSON as alternate encoding for XML, we need to follow that as well.

03/17/08 17:20:16 changed by bluescreen303

Any updates on this topic?

progress in edge?

I'm currently using the plugin at http://blog.labnotes.org/2007/12/11/json_request-handling-json-request-in-rails-20/ but it won't work with 'extra' stuff like _method and authentication_token that don't belong to the object that gets posted or put.

What would be the way to handle these things? Is there a way to put _method and auth_token in a request header to keep the request body 'clean' with only the json-encoded object?

Any news regarding this would be realy helpful

Thanks, Mathijs

03/17/08 18:26:09 changed by technoweenie

I'm using the plugin too. I'm not completely sold on it though, you can add some useful metadata in the wrapper elements. Notice how CouchDB adds total_rows, offset, etc to the document listing. But, this is simple, so I'll probably be adding the plugin to core (but changing the class method from json_request to something else). Anyone else is welcome to submit a formal tested patch too :)

As for _method, you're expected to be using a non-browser http client, so you can just use the real method. I'm not sure how this works with xmlhttprequest, any ajax wizards want to chime in?

Like XML requests, JSON requests are treated as API requests and don't check for CSRF vulnerabilities. You're expected to authenticate through some other means other than session cookie. I think a separate patch for a csrf header would be good for those times that you're making JSON ajax requests though.

03/17/08 19:16:27 changed by Assaf

Works like a charm with XHR.

The problem is Prototype. It either sends postBody or request parameters. A JSON (or XML for that matter) request uses postBody to carry the document, but a PUT request uses parameters to pass _method=, so you can't have both in the same request.

The quick and easy workaround is to add _method= to the request URL. (And don't forget to set content-type, serialize the object into a JSON string, pass as postBody)

03/17/08 19:49:43 changed by bluescreen303

Thanks Assaf and technoweenie,

It's working now, I'm not using Prototype, I'm using ExtJS, and indeed the problem was in there. I submitted a patch for it and all works like a charm indeed. _method isn't an issue anymore since Ext will now create a native PUT request. Also the auth_token isn't needed anymore, rails was insisting on it, because ext automatically kept supplying the www-formencoded content-type.

So, all works great.

Looking forward to having this included in rails by default.