A WebObjects application's natural URLs are long and opaque: /cgi-bin/WebObjects/MyApp.woa/wo/3.0.1.2 for a component action, /wa/ for a direct action. wonder-slim adds a route table, so an application can answer /shops/, /company/acme or /laws/2026/12/3 with whatever code you like: a page, a JSON document, a redirect. The same routes, written once, serve http://localhost:1200/shops/ during development and https://www.example.com/shops/ in production. This guide shows the API and the shapes it takes in real applications.

1. The route table

Routes are registered at startup, in the Application constructor or in a small class it calls. Each entry maps a path pattern to a handler:

import er.extensions.routes.RouteTable;

public Application() {
    final RouteTable routes = RouteTable.defaultRouteTable();

    routes.map( "/", SMStartPage.class );
    routes.map( "/shops/", SMShopListPage.class );
    routes.map( "/stats/", SMStatisticsPage.class );
    routes.map( "/frettir/*", new NewsRoute() );
    routes.map( "/go/*", new GoRoute() );
}

A pattern is either exact, or a prefix when it ends in *. Matching runs in registration order and the first match wins, so put specific patterns before broad ones. A request that matches nothing gets a 404 with the URL in the body. Every routed request is logged with its IP address and user agent, which doubles as a cheap access log during development.

Note that /frettir/ and /frettir/* are different patterns: the first is the index, the second everything beneath it. Some applications map a path both with and without its trailing slash so either form works; a two-line helper does that.

2. Three shapes of handler

A handler is anything that turns a RouteInvocation into a WOActionResults: a page, a response, or something that yields one. In practice that means one of three things.

A component class, when the URL simply is a page. The route table instantiates it with the request's context:

routes.map( "/rekstur/", HVFinancesOverviewPage.class );

A lambda, when there's a decision or a parameter involved. The invocation gives you the parsed URL, the request and its context:

routes.map( "/", ri -> {
    if( ((Session)ri.context().session()).user() != null ) {
        return pageWithName( HVStartPage.class );
    }

    return pageWithName( HVMainPage.class );
} );

routes.map( "/grein/*", ri -> {
    final String slug = ri.routeURL().getString( 1 );
    final SiteArticleDetailPage page = ERXApplication.erxApplication().pageWithName( SiteArticleDetailPage.class, ri.context() );
    page.setArticle( SiteArticles.bySlug( slug ) );
    return page;
} );

A method reference, when the handler is more than a few lines. The method takes the invocation and returns the result, and the route table stays a readable list:

routes.map( "/laws", Application::laws );
routes.map( "/laws/*", Application::laws );
routes.map( "/kenni", Application::kenni );

private static WOActionResults laws( final RouteInvocation ri ) {
    int year = Year.now().getValue();
    Integer article = null;
    Integer paragraph = null;

    try {
        year = ri.routeURL().getInteger( 1, year );
        article = ri.routeURL().getInteger( 2, null );
        paragraph = ri.routeURL().getInteger( 3, null );
    }
    catch( NumberFormatException e ) {
        return notFound( "Not a valid reference: " + ri.url() );
    }

    …
}

A class implementing RouteHandler, when the handler carries state or is reused across applications. It's a single-method interface, so a nested static class is all it takes:

routes.map( "/go/*", new GoRoute() );

/**
 * /go/<code> redirects to the shop a short code stands for, counting the click on the way
 */
private static class GoRoute implements RouteHandler {

    @Override
    public WOActionResults handle( final RouteInvocation ri ) {
        final String code = ri.routeURL().getString( 1 );
        final Shop shop = Shop.forShortCode( code );

        if( shop == null ) {
            return notFound( "No shop for code " + code );
        }

        shop.countVisit();

        final WOResponse response = new WOResponse();
        response.setStatus( 302 );
        response.setHeader( shop.url(), "location" );
        return response;
    }
}

3. Reading parameters

The invocation's routeURL() splits the path on slashes, leading and trailing ones removed, and hands out the pieces by index. Index 0 is the first path element, which for /laws/2026/12 is laws, so parameters start at 1:

final RouteURL url = ri.routeURL();

url.getString( 1 );          // "2026" — or null if the URL is shorter
url.getString( 1, "none" );  // with a default
url.getInteger( 2, null );   // parsed as an Integer; NumberFormatException if it isn't one
url.length();                // how many elements the path has

Query parameters are not part of the route; the query string is stripped before matching. Read them from the request, as you always have:

routes.map( "/kenni", ri -> {
    final String code = ri.request().stringFormValueForKey( "code" );

    if( code == null ) {
        return badRequest( "Invalid authentication request. Missing code" );
    }

    return finishLogin( code, ri.context() );
} );

This is the shape of an OAuth callback: a fixed path, the interesting part in the query, a decision, a page. Anything on the request is available, ri.request() for headers and form values and ri.context() for the session.

4. What a handler returns

Anything WebObjects can send. Pages are the common case; the rest is WOResponse with the right content and headers:

// A page, with an object set on it
final HVCompanyDetailPage page = ERXApplication.erxApplication().pageWithName( HVCompanyDetailPage.class, ri.context() );
page.setCompany( company );
return page;

// Data: JSON from a route
routes.map( "/company/*", ri -> {
    final CompanyDefinition cd = CompanyDefinition.forName( ri.routeURL().getString( 1 ) );
    final String json = new Gson().toJson( new CompanyClient( cd ).out() );
    final WOResponse response = new WOResponse();
    response.setHeader( "application/json; charset=utf-8", "content-type" );
    response.setContent( json );
    return response;
} );

// A file from the bundle: the favicon browsers ask for regardless of your markup
routes.map( "/favicon.ico", ri -> {
    final byte[] bytes = ERXApplication.erxApplication().resourceManager().bytesForResourceNamed( "favicon.ico", "app", NSArray.emptyArray() );
    final WOResponse response = new WOResponse();
    response.setHeader( "image/x-icon", "content-type" );
    response.setHeader( "public, max-age=3600", "cache-control" );
    response.setContent( new NSData( bytes ) );
    return response;
} );

// A redirect: 302 on purpose, so browsers don't cache it the way they cache a 301
routes.map( "/innskraning", ri -> {
    final WOResponse response = new WOResponse();
    response.setStatus( 302 );
    response.setHeader( "https://app.example.com/", "location" );
    return response;
} );

The machine endpoints a public site needs, /robots.txt and /sitemap.xml, are two more routes returning text. Our sites generate the sitemap from the same page registry the routes are built from, so the two can't drift apart.

5. The same URLs in development and production

This is the part worth understanding, because it's what makes routes pleasant rather than merely possible.

In development the application listens on its own port and wonder-slim registers the routing request handler as the application's default request handler. A request for /shops/ at http://localhost:1200/shops/ is passed to the application as it is and lands in the table. There is no adaptor, no rewrite, nothing to configure: the URL you type is the URL your handler sees.

In production behind modulo, a site maps its hostnames to an application, and modulo forwards the request to an instance unchanged. The application's default request handler receives /shops/ exactly as it did on localhost, so the same handler runs with the same routeURL(). Component actions, direct actions and resource URLs the application generates are adaptor URLs and are routed by modulo the way they always were, so nothing else changes.

Behind Apache with mod_WebObjects only adaptor URLs reach the application by default, so friendly URLs need a RewriteRule in the virtual host that maps them into the application's adaptor URL space. The application code is identical; only the front end differs, and the deployment guides show each setup.

6. In practice

  • Keep the table in one place. A Routes class with a static register() method, called from the Application constructor, reads as the site's map. wonder-slim's AjaxPlayground does exactly this.
  • Map the root. / is a route like any other; it is where the login decision usually lives.
  • Prefer flat, stable paths for pages, and reserve wildcards for things with identifiers: /grein/<slug>, /i/<entity>/<id>.
  • Handle bad parameters yourself. getInteger throws on non-numbers, and a slug that matches nothing returns null from your lookup; answer with a 404 rather than letting an exception page out.
  • Wildcards are prefixes, nothing more. There is no pattern language yet; the matching is deliberately simple, and everything beyond the prefix is yours to parse from routeURL().
  • Resources are routed too. With the app-based resource manager, wonder-slim maps its own resource URL prefix in the same table, which is how a routed page's stylesheets resolve in both environments.

The classes are four: RouteTable, RouteHandler, RouteInvocation and RouteURL, all in er.extensions.routes. The wonder-slim repository has them, and the changelog records how the routing has evolved.