Wednesday, October 15, 2008

Wicket extreme consistent URLs

Setting up a page to be behind a particular URL (aka mounting) in Wicket is fairly easy. Daan recently wrote REST like URLs for Wicket in which this is nicely explained. However, consistently keeping nice URLs, for example after a form submit, is a whole lot harder. So hard in fact, that even Wicket champion Igor believes it is currently not possible (it is an old post). His reasoning is of course completely correct, but he forgot one thing. Let me explain.

The problem
Many web applications have a login page. On it there is a form where you fill in your username and password. Suppose the page is mounted to:

http://example.com/login
with code like this in the application's init method:
mountBookmarkablePage("login", LoginPage.class);
When the user enters a wrong password, form validation (I assume you have a password validator) will fail and Wicket will redirect the user to a URL that points to the second version of the login page (the one with the error message). These URLs typically look like:
http://example.com/?wicket:interface=:0:::
For many applications this is just fine, in some its just too ugly.

A non solution
One of the proposed solutions is that you redirect to another page in the onError method of the form. E.g.

add(new Form(...) { @Override protected void onError() { setResponsePage(LoginPage.class); } }

The redirection will happen, and the URL is indeed that of the login page, but you will have no error messages. Instead of going to the second version of the login page, you have created a new instance of the login page!

Another attempt
We could pass the error code in the URL:

add(new Form(...) { @Override protected void onError() { PageParameters pp = new PageParameters(); pp.setString("error", "wronglogin"); setResponsePage(new LoginPage(pp)); } }

Unfortunately, with the default URL encoding stratey the URL will now be:
http://example.com/login/error/wronglogin
Not at all attractive.

Getting close
A fairly recent addition to Wicket is the HybridUrlCodingStrategy (and subclasses). Let's mount the login page with one of these:

mount(new HybridUrlCodingStrategy("login", LoginPage.class));

If a user now enters wrong credentials, Wicket will redirect you to
http://example.com/login.2
The .2 means the second version of the login page for the current session. If the user would delete the .2 from the URL, the HybridUrlCodingStrategy will find the latest available version of the page and serve that. A whole lot nicer but still not perfect.

The solution
If only if we could force Wicket to not display the version number. Well... we can! We'll have to do some coding first:

// First attempt, DO NOT USE public class NonVersionedHybridUrlCodingStrategy extends HybridUrlCodingStrategy { // ... trivial ctor @Override protected String addPageInfo( String url, PageInfo pageInfo) { // Do not add the version number as // super.addPageInfo would do. return url; } }

And now mount with:
mount(new NonVersionedHybridUrlCodingStrategy( "login", LoginPage.class));
Indeed, the version number is gone! However, there is still a tweak to be done. In some circumstance Wicket will redirect you to the latest version of the page, but now that redirect will be to the same URL. This is no problem for Firefox and IE, but Safari, Opera and many tools do not allow this. Here is the final version that does not have the problem:
/** * UrlCodingStrategy that will give the same * URL for every version of a page. * @author Erik van Oosten */ public class NonVersionedHybridUrlCodingStrategy extends HybridUrlCodingStrategy { public NonVersionedHybridUrlCodingStrategy( String mountPath, Class pageClass) { super(mountPath, pageClass, false); } @Override protected String addPageInfo( String url, PageInfo pageInfo) { // Do not add the version number as // super.addPageInfo would do. return url; } }

I named the URL strategy 'non versioned' as it no longer makes sense to have multiple versions of a page, just the last one will do. You should therefore also add the following fragment to each page constructor:
setVersioned(false);

Proof
Try it out! Go to tipSpot.com and try to login.

15 comments:

  1. Hi Erik,

    Looks nice! We still have an open ticket to implement RESTful urls in our project. When we get to it, we'll definitely use this. Thanks for the writeup!

    ReplyDelete
  2. Hi Erik,

    After reading your post I played around with your ideas a bit - and implemented some of my thoughts. Changing the UrlCodingStrategy is easy for a LoginPage but more difficult for any page with parameters where other UrlCodingStrategies are used. Currently, FeedbackMessages are stored in the session, so it should be somehow possible to preserve them when redirecting to a bookmarkable page. After looking at the code, I'd say it's a *major* hack though.

    However, it would definitely be nice to have out of the box. Maybe a wish for Wicket 1.5?

    Best regards, Stefan

    ReplyDelete
  3. Stefan,

    It is not so difficult. The key is that you override the addPageInfo method and return the bare URL it is given. This works for any subclass of HybridUrlCodingStrategy. For example, I have a MixedParamHybridUrlCodingStrategy implementation which I extended in exactly the same way.

    But you are right that it would be nice to have this in Wicket already. For example a flag in the constructor of HybridUrlCodingStrategy directly.

    ReplyDelete
  4. Great, finally a solution for consistent user-friendly bookmarkable URLs! Sounds like a new url encoding strategy for the next release to me :-)

    ReplyDelete
  5. Igor Vaynberg wrote:
    while this might work for your usecase this will pretty much break things. the version number is in the url for a reason.

    1) it completely kills the backbutton for that page. since the url remains the same the browser wont record your actions in the history. based on what you are trying to do this may or may not be a bad thing.

    2) even if you manage to get the back button working this will completely kill applications that use any kind of panel replacement because you no longer have the version information in the url. you have a page with panel A, you click a link and it is swapped with panel B. go back, click a link on A and you are hosed because wicket will look for the component you clicked on panel B instead of A.

    in all the applications ive written there was at least a moderate amount of panel replacement going on. one of the applications i worked on had the majority of its navigation consist of panel replacement. so i dont think this is a good idea.

    -igor

    ReplyDelete
  6. We are building a mostly stateless app with Wicket (as far as possible) so this fits in really nice.

    I also added an annotation for the non versioned strategy:

    @Target({ ElementType.TYPE })
    @Retention(RetentionPolicy.RUNTIME)
    @MountDefinition(strategyClass = NonVersionedHybridUrlCodingStrategy.class, argOrder = {})
    @Inherited
    @Documented
    public @interface MountNonVersionedHybrid {}

    ReplyDelete
  7. I've also added another subclass for mixed params: http://pastebin.com/m344b370b

    ReplyDelete
  8. If the URL ends on a /, it should be remove otherwise you get an exception that there are too many path parts.

    ReplyDelete
  9. I've tried with a form that was a StatelessForm and it doesn't work.

    I'm still having an unconsistent url : login/wicket:interface/:0:form$cnx:form$authent::IFormSubmitListener::

    Does someone know where is the problem ?

    ReplyDelete
  10. I have problem with this solution. If I open two tabs/windows on two URLs mounted with this strategy, wicket goes in an endless 302/200 loop. Have you experienced this?

    ReplyDelete
  11. @ildella, no I have not seen this kind of behavior.

    ReplyDelete
  12. Hi Erik, I use wicket-1.5-M2.1.
    There are no more URL coding strategies.
    Can you provide an update ?

    Thanks

    ReplyDelete
  13. I have the same problem as ildella using GAE. It doesn't happen when I run it locally with Jetty but when I deploy the app to app engine I get an infinite loop :(

    I enabled all logs but I don't see any exception I don't understand why the page gets reloaded

    ReplyDelete
  14. @Guillaume, the mounting architecture changed in 1.5, check the migration guide from 1.4 to 1.5 in their wiki.

    ReplyDelete
  15. @ZeoS and @Ildella:
    That problem arises when you use "setAutomaticMultiWindowSupport(true)" since the strategy are not attaching any pagemap to each page. Unfortunately, I have not found a good solution for this.

    ReplyDelete