A WebObjects application's URLs are usually long and opaque, like /cgi-bin/WebObjects/MyApp.woa/wo/3.0.1.2. That's fine for a page you reach by clicking, but not for one you want to bookmark, share or have a search engine find.

wonder-slim's router lets an application answer URLs like /books/, /books/7 or /clubs/acme/members/hugi. You declare each URL once, with what answers it, and then link to it by name. The router builds the URL for every link, so a link can't point at a page that isn't there, and changing a URL is changing one line.

This guide starts from nothing and builds up. It describes the router of wonder-slim 8.1.1 and later.

1. Your first route

Routes are declared in the Application's constructor:

import er.extensions.routing.ERXRouter;

public Application() {
    ERXRouter.declare( routes -> {
        routes.map( "/", Main.class );
        routes.map( "/about", AboutPage.class );
    } );
}

That's a working application: http://localhost:1200/ shows Main, and /about shows AboutPage. A URL that isn't declared gets a 404. In development, that 404 is a page listing the routes you have and how to add one, so you can't get lost.

The order you declare routes in doesn't matter. When several could answer a URL, the most specific one does: /books/new before /books/{book}, whichever was declared first.

While you're developing, you don't need to restart to see a change to your routes. Edit the declaration, save, and the next request has the new routes. That holds for the declarations the application made when it started: a new ERXRouter.declare call takes a restart.

2. Parameters

Most URLs carry something: a book's number, a club's name. A part of the pattern in braces is a parameter:

routes.map( "/books/{book}", BookPage.class );

/books/7 now shows a BookPage, with 7 set on it as its book. The page says what it wants by having a public field or setter of that name:

public class BookPage extends ERXComponent {

    public Book book;     // set from the URL: /books/7 gives the book numbered 7

    …
}

The page asks for a Book, not a number. The router turns "7" into a book with a converter, which you register once, in the declaration. It works in both directions: from the URL's text to the object, and from the object to text when a link is built.

routes.converters().register( Book.class, Converter.of(
        text -> Book.withNumber( Integer.parseInt( text ) ),   // "7" → the book
        book -> String.valueOf( book.number() ) ) );           // the book → "7"

Text, numbers, dates, UUIDs and enums are converted without registering anything. What happens to a bad value is the nice part. For /books/abc, or /books/999 when there's no such book, the converter answers null or fails, and the route simply doesn't answer. The URL gets a 404, and you never write a null check for it.

A last parameter can be optional. One route then answers both /search and /search/dickens:

routes.map( "/search/{text?}", SearchPage.class );

Without it, the page's text is left as it was, and a link whose value is null leaves it out. With it, it's a value like any other: one that doesn't convert is a 404, not the shorter page.

A pattern can also take everything beneath a path, for serving files or forwarding to something else:

routes.map( "/files/{path*}", Application::file );     // /files/minutes/2026.txt gives path "minutes/2026.txt"

To link to a route, give it a name. Names are constants on an interface your pages implement:

public interface Routes {

    // So templates can say $routes.book
    default Object routes() {
        return RouteKeys.of( Routes.class );
    }

    PlainRoute home = Route.plain();
    PlainRoute book = Route.plain();
}

The declaration then gives each name its URL, with the same map as before and the name added:

routes.map( "/", Routes.home, Main.class );
routes.map( "/books/{book}", Routes.book, BookPage.class );

Have your pages implement Routes (a common base class does it for all of them), and link with <wo:route>. Each : attribute is one of the route's parameters:

<wo:route to="$routes.book" :book="$currentBook"><wo:str value="$currentBook.title" /></wo:route>

That renders <a href="/books/7">. From Java, it's Routes.book.url( Map.of( "book", book ) ), and Routes.book.redirect( … ) answers with a redirect to it.

Because every link is built from the route, two things can't happen. A link can't point at a URL that doesn't exist, and a link can't be missing a parameter: an application that forgot one fails while the page renders, naming the route, rather than handing out a broken URL. Change "/books/{book}" to "/library/{book}", and every link follows.

If you forget to give a name its URL in the declaration, the application doesn't start, and it tells you which name.

Routes nothing links to (an API, a webhook, a redirect from an old address) don't need a name: the first form, map( "/about", AboutPage.class ), is all they take.

4. Answering with something other than a page

Not every URL is a page. A handler answers with anything: a redirect, a JSON document, a file. It's a lambda or a method reference taking the request's RouteInvocation:

routes.map( "/books/{book}.json", ri -> json( ri.parameter( "book", Book.class ) ) );
routes.map( "/go/{key}", Application::go );

private static WOActionResults go( final RouteInvocation ri ) {
    final String target = SHORT_LINKS.get( ri.parameter( "key" ) );

    if( target == null ) {
        return RouteHandler.DECLINED;     // not ours: the URL gets a 404
    }

    return redirectTo( target );
}

The invocation has what the handler needs:

  • ri.parameter( "key" ): a parameter's text, and ri.parameter( "book", Book.class ) its object, converted.
  • ri.query( "page", Integer.class ): a query parameter or form field, null when it isn't there.
  • ri.request() and ri.context(): the request, and the context for the session and for making pages.

Declining. A handler with nothing to say about a URL returns RouteHandler.DECLINED, and the URL goes on to the next route that matches it, and in the end to the 404. That's better than building an error page of your own, because every unknown URL then gets the same answer, including the helpful one in development. In development, the 404 page also lists each route that declined and why.

Making pages in a handler. Make them in the request's context: pageWithName( name, ri.context() ), or ri.page( BookPage.class ). Don't construct a direct action in a handler (new MyAction( ri.request() )) to make pages for you: an action makes a context of its own, the session it creates is never saved, and the component actions on its pages find no session.

5. Query parameters and forms

A route's parameters are the ones in its path. For a list that can be sorted and paged, /books/?sort=author&page=2, the options belong in the query, and a record describes them:

public record Books( Sort sort, Integer page ) {}

Route<Books> books = Route.of( Books.class );                    // in Routes

routes.map( "/books/", Routes.books, BookListPage.class );      // in the declaration

Each component of the record is a query parameter, converted to its type. The page gets them by name, like path parameters. One that isn't in the URL isn't set, so the page keeps its own default (public Sort sort = Sort.title;). Links give them the same way: <wo:route to="$routes.books" :sort="author">, which is checked: author must be one of Sort's values.

A form posting to a route works the same way. The record's components are the form's fields, and a handler gets the record:

public record CreateBook( String title, String author ) {}

routes.map( "/books", Routes.createBook, Application::createBook, Method.POST );
<wo:routeForm to="$routes.createBook">
    <input name="title"> <input name="author"> <button>Add</button>
</wo:routeForm>

Method.POST means only posts reach it. Anything else at /books gets 405 Method Not Allowed, so a link, a crawler or a prefetch can't add a book. Editing forms built from WebObjects' bound elements stay component actions; routes are for what has an address.

A field that doesn't convert (a date that isn't one, a number that isn't) makes the request a 404, as a wrong URL does. A form that should be shown again with what's wrong says so with Fields.REPORTED: the record is built anyway, the bad field null, and ri.conversionErrors() lists what was given. A route can instead answer bad input itself with .whenInvalid( ( ri, reason ) -> … ), which also catches what the record's constructor refuses.

routes.map( "/books", Routes.createBook, Application::createBook, Method.POST, Fields.REPORTED );

6. Groups

Routes that share the start of their URLs form a group. The group's part is written once:

routes.group( "/admin", admin -> {
    admin.wrap( Application::requireAdmin );     // runs before every route in the group
    admin.map( "/", AdminPage.class );           // /admin/
    admin.map( "/users", AdminUsersPage.class ); // /admin/users
} );

private static WOActionResults requireAdmin( final RouteInvocation ri, final RouteHandler next ) {
    return isAdmin( ri.context() ) ? next.handle( ri ) : forbidden();
}

A filter (wrap) runs before every route of the group, and decides whether the request goes on. That's where access control belongs: one place, rather than a check at the top of every page. A group can also have parameters of its own, like routes.group( "/clubs/{club}" ), which every route in it then has.

7. Another host

Most applications answer one host, and their routes never name it. A route can belong to a host of its own, for an administrators' site, say:

routes.map( "/", Routes.adminHome, AdminPage.class, Host.of( "admin.@" ) );

@ stands for the application's own domain. In development that's localhost, and every name ending in .localhost is your own machine, so http://admin.localhost:1200/ works with nothing to set up. Deployed, the domain comes from the public address:

er.routing.publicAddress=https://www.example.com

The route then answers admin.example.com. Without a public address, a deployed application with such a route doesn't start, and says why. A request to another host goes on to the next route, so / can be the admin page on one host and the front page on the other. A link to a route on another host is complete (https://admin.example.com/), and a group can put all its routes on a host at once: routes.group( "", admin -> { … }, Host.of( "admin.@" ) ).

The public address is also what complete URLs use, such as the links in an email.

8. When nothing answers

A URL goes to the routes that match it, most specific first, and the first one that doesn't decline answers. If none does, it goes to the application's fallback, and then to not found. Both are set in the declaration:

ERXRouter.declare( routes -> {
    …
    routes.fallback( new ERXPublicResources() );
    routes.notFound( ri -> NotFoundPage.create( ri.context() ) );
} );

The fallback is usually the public resources. The files in the public folder of your web server resources are served at the root: public/favicon.ico at /favicon.ico, public/robots.txt at /robots.txt. A route always wins over a file.

Not found answers whatever's left. Without one of your own, development shows its pages (the welcome page at / until you've mapped it, and the 404 that explains itself), and a deployed application answers a plain 404. A not found handler that declines passes the request on to the next handler in the server, for an application sharing its Jetty server with an ng-objects application.

9. The same URLs in development and production

In development the application listens on its own port, and the URL you type is the URL the routes see: http://localhost:1200/books/7 arrives as /books/7. There's no adaptor, no rewrite, nothing to configure. The application's own URLs are short too, /wo/…, /wa/…, /res/…, and the long form (/cgi-bin/WebObjects/MyApp.woa/wa/…) keeps working, so existing links and bookmarks don't break.

In production behind modulo, a site maps its hostnames to an application, and modulo forwards each request unchanged. The application sees /books/7 exactly as it did on localhost.

Behind Apache with mod_WebObjects, only adaptor URLs reach the application, so a rewrite rule in the virtual host forwards everything else into the application's adaptor URL space, under the _erxroute_ request handler key (route before wonder-slim 8.1.2). With the adaptor at /Apps/WebObjects:

RewriteEngine on
RewriteRule ^/$ /Apps/WebObjects/MyApp.woa/_erxroute_/ [L,PT]
RewriteCond %{REQUEST_URI} !^/Apps/WebObjects/
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} !-f
RewriteCond %{DOCUMENT_ROOT}%{REQUEST_URI} !-d
RewriteRule ^/(.*)$ /Apps/WebObjects/MyApp.woa/_erxroute_/$1 [L,PT]

The first rule forwards the root, and the second everything else that isn't already an adaptor URL or a file Apache serves itself. The condition on the adaptor path matters: without it, the application's own long URLs would be rewritten a second time. Forwarding under the key, rather than the bare path, matters too: mod_WebObjects reads a number right after .woa as an instance number, so a route like /2026 forwarded bare would never arrive. The key never appears in a URL anyone sees. The deployment guides show each setup.

10. Good to know

  • Request handler URLs come first. /wa/…, /wo/…, /res/… and the application's other request handler keys go to their request handlers before the router sees anything. A route starting with one of them could never be reached, so declaring one fails.
  • Posts from other sites are refused. A page on another site can't post a form to your routes with your users' cookies: the router answers 403. A route that should take posts from elsewhere, like an API, a webhook or a partner's form, says so: map( "/api/orders", …, CrossSite.ALLOWED ).
  • Trailing slashes. /books/ and /books are the same route unless you say otherwise. TrailingSlash.REDIRECT sends the other form to the declared one, so each page has one address. It can be set on a route, on a group, or for every route at once: ERXRouter.declare( routes -> { … }, TrailingSlash.REDIRECT ).
  • Moving a page. routes.redirect( "/book/{book}", Routes.book ) answers the old address with a permanent redirect to the new one, so bookmarks and search engines follow.
  • Some characters in a path. A value with % or \ in it is encoded into the URL, but some front ends and adaptors refuse those in a path, the Jetty adaptor among them. Free text, a search for example, is better off in the query (?q=…) than in the path.
  • Seeing your routes. The control panel at /wonder/admin/routes lists every route the application has, in the order they're tried, with what each answers with and the parameters it takes.
  • Frameworks bring their own routes. A framework declares its routes in a table of its own, through its plugin, and your application's routes win over them. helium's object pages (/i/…) and the control panel (/wonder/admin) arrive that way, with nothing to set up.

11. Where things are

The classes are in er.extensions.routing, with the conditions and options (Method, TrailingSlash, CrossSite) in er.routing.options and converters in er.routing.conversion. The wonder-slim repository's docs/ROUTING.md is the complete reference, and its Bookclubs example application uses every feature.