Pitfalls in RESTful "wizards"
Posted by Chad Pytel
Apr 25
In an application we’re currently building, users go through a wizard to order teams. We implemented this as RESTful controllers – teams, purchases, and orders.
The relevant wizard steps are:
- teams/new
- teams/:id/purchases/new
- orders/new
When each step is submitted, the related create action is called and the user is redirected to the next new action.
Pitfall: the user hits the back button.
On step 3, the user hits the back button. If they re-submit step 2, they will get a validation error because they cannot create two purchases for a team.
There’s a temptation here to stray from RESTful design. Don’t!
When the user hits the back button from step 3, we want to send them to purchases/edit for their newly-created object instead of purchases/new.
Solution: Force the browser to not cache
To ensure that the page isn’t cached by the browser and will always be re-fetched, we add a before_filter to the purchases controller which calls a private method on ApplicationController:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
before_filter :no_cache, :only => [:new] private def no_cache response.headers["Last-Modified"] = Time.now.httpdate response.headers["Expires"] = 0 # HTTP 1.0 response.headers["Pragma"] = "no-cache" # HTTP 1.1 'pre-check=0, post-check=0' (IE specific) response.headers["Cache-Control"] = 'no-store, no-cache, must-revalidate, max-age=0, pre-check=0, post-check=0' end |
Next, if the team already has a purchase, we redirect to the edit action using another before filter on the purchases controller:
1 |
before_filter :redirect_to_edit, :only => [:new], :if => :team_has_registration_purchase? |
#team_has_registration_purchase? is a method on the purchases controller. If the :if syntax is unfamiliar to you, its provided to us by our plugin, when.
Pitfall: ‘not caching’ only works in Firefox.
To the best of our knowledge, setting the HTTP headers should have worked in all browsers, but it did not work in Safari or in IE 6 and 7. A little more research proved that any page with an iframe on it will never be cached, and will always be refetched.
Solution: iframe
So, we add this to views/purchases/new:
<iframe style="height:0px;width:0px;visibility:hidden" src="about:blank">
this frame prevents back forward cache
</iframe>
This works. We now have cross-browser no-caching in a RESTful wizard.
The iframe hack feels a little dirty, though. Does anyone know a better way? Or, do we live with it, similar to using a hidden frame for “Ajax” file uploads with responds_to_parent ?
This concept might be useful outside of these more complex, wizard type controller actions, and might come in handy if you had just a normal restful controller where you wanted the user to get the edit action instead of the new action if they use the back button. Have you done something like this before, or can you think of a different way to accomplish that?
photo courtesy of Michael Porter via Flickr
Comments on this post
Apr 25
AW said,
No session variable + unique wizard ID?
Apr 25
Pat Nakajima said,
Phew. I was nervous from reading this headline that you were going to post one of those “Here’s one case where REST just doesn’t work” when it actually does. Kudos for sticking to good design.
Apr 26
Matthijs Langenberg said,
I actually thought a wizard was used to guide a user through a complex information gathering process. If you need a bunch of data from a user, and it is too complex, so you decide to devide it into three parts, you should only save that data after step three. And you shouldn’t keep that in-between state server-side!
If you take, for example, the checkout procedure for a webshop. That could be done in one step (a page with a giant form). This is usally being split in several steps. In order to keep this thing RESTful, the posted data from the previous step should be included in the post data from the current step. This means a database record is only created if all data is available. If you write an external client for that API (e.g. POST to /orders), it can decide on its own how get the information from the user.
Apr 26
Matt Jankowski said,
@Matthijs – in some apps maybe you could use that approach and either literally only “store” things in POST data passed from page to page, or in the session or something – but if you have a requirement that the wizard may be abandoned at anytime and then resumed later (including say, from a different browser on a different computer) – than you absolutely need to go server side and introduce state into the system so that only some things are “Complete” or “Submitted” or whatever.
You would most likely need to add state-conditional validations to the model to support this, as well.
Apr 28
Piers Cawley said,
A small thing, but that photo’s license is ‘attribution, non-commercial’. You’ve attributed it to Flickr rather than to its author, Michael Porter, which isn’t exactly sporting.
Apr 28
Chad Pytel said,
Thanks Piers, that was a mistake, and I fixed the attribution.
Apr 29
Steve Berryman said,
Great post there. This is the sort of thing i always forget to consider when dealing with complex forms and things.
Apr 30
Fadhli said,
The link to the plugin when leads to a broken link?
Apr 30
Fadhli said,
Anyway, what is the method
:team_has_registration_purchase?
I don’t quite get it.
Apr 30
Chad Pytel said,
@Fadhli, you’re right – I fixed the link to when.
team_has_registration_purchase? is a method on the purchases controller that checks to see whether the team we’re currently working on already has a purchase, returns true if it does, and false if it does now.
May 02
David Berube said,
I find it curious the implied equation of “one resource equals one model.” As far as I know, that is a requirement of REST or of “good design”. For that matter, REST does not need to be backed by a database – static files are virtually always RESTful.
You may wish to consider a wizard as a resource in itself. For that matter, you may also wish to consider the atomicity of a wizard – users will typically assume a wizard makes no permanent change in state until it is finished, and will often restart wizards anew even if the option to continue an old session is present.
While a “this-is-not-a-finalized-record” flag is feasible, it is suboptimal, and conceivably other models could form relationships with non finalized records with nary a peep from the database. Of course, you could attempt to use either validations or database constraints to make this impossible, but would it not be better to structure your database so that each row is an actual conceptual entity and has no possibility of being a “ignore-this” record?
David Berube
May 02
David Berube said,
Typo: Should have read “is not a requirement,” not “is a requirement”.
Take it easy,
Dave
Sorry, comments are closed for this article.
© 2000 - 2009 by thoughtbot, inc.
written by a bushel of tiny robots
Come “ride the toad” on Hoptoad, the app error app.
Thunder Thimble: Brand monitoring for social media.
Widgetfinger: Simple content management for simple websites.
Tee-Bot, funny shirts your friends won't understand!
Umbrella Today: “It’s like totally the simplest weather report ever, Julie.”
Thoughtbot
thoughtbot is a technology consulting firm that provides web application development and design services. We focus on building modern systems, embracing good ideas and delivering elegant solutions.
Interested in learning Rails?
Sign up for our beginning or advanced training.
Archives