<< Posts

Supporting URL parameter binding in Atium routes

Since rewriting my web server from C++ to Java a month or two ago, I’ve been looking for ways to make the user experience of the “framework” simpler and intuitive. One of the features that I love in other web frameworks is how they automatically bind variables in the path specified to variables in the action. I’ve already taken advantage of this feature for this website’s blog post actions. Before, each post had its own bespoke action that would be called for a specific path. Now, using a templated path with a "{postId}" variable, I can have a single blog post loading action that loads the requested post id.

What parameter binding look like in a web framework for URLs?

Here is an example of what I’m talking about using ASP.NET Core’s minimal API.

app.MapGet("/hello/{name}", (string name) => "Hello, " + name);

The idea of path or URL variables is that a client can supply some arbitrary data inside the request target and the web framework tries to match templated path specified in code to the parameters in the action. Without this feature, one would have to write new routes and actions for every expected request target, which can get quite verbose. It also precludes a program’s ability to accept unexpected or entirely client specified content like the simple example above. You couldn't really expect to denote every expected name that a client could request.

Here is an example of using path variables in Atium. It is not as nice as the code in ASP.NET Core or Spring Boot. This is mostly because I decided to do a first pass implementation using no reflection. The path variables are stored in a hash map that each action can use to extract the information they need.

server.registerAction("/hello/{name}", HttpMethod.GET, (ActionContext context) -> {
    var pathArgs = context.getPathArguments();
    return new ActionResponse("Hello, " + pathArgs.get("name"), ActionResponseType.Text);
});

Getting this working required a change in how registered actions are stored as well as how they are looked up when a request comes in. Previously, the registered actions were stored using a map of URL and HTTP method to the desired action. This doesn’t work as well when there are variables in the mix. For example, if our “hello/{name}” is used as the key to the map and a request target of “/hello/Nathan” is supplied, then the keys would not hash to the exact same thing and would result in a 404 being returned. So, there needs to be a more sophisticated lookup strategy when the registered action's URL is templated.

Registered action path matching strategy

The path matching on templated paths can get a little difficult to get right, especially if multiple variables are defined in the templated path. The initial implementation of the matching strategy is posted below. The idea is to loop through every registered action. Yes, registered actions are now in a list instead of a map. This will make lookups slower (constant time versus linear), but it is a tradeoff worth making because of the feature set it enables. If a templated variable is encountered in the registered path, an attempt is made to match part of the request target to that variable. Otherwise, we just make sure that the characters of the path specified and the request target match. When multiple registered actions are encountered that could match the request target, the longest path is selected as the most specific match. That way, paths like "/hello/{name}" and "/hello/{firstName}-{lastName}" can both be registered and a request target of "/hello/Nathan-Wright" would select the latter templated path even though the former also technically matches. Disclaimer: this code is tested enough to work for my current purposes. No promises that it is bug free. If drastic changes need to be made, I expect to make an updated post describing what went wrong.

If this strategy becomes problematic in the future there are ways of making registered action lookup faster. Because I expect most services to register 10s to 100s of actions instead of some larger order of magnitude, optimization seems a bit premature here. However, one way to make this faster would be to keep the original strategy of a map of non-templated paths. Then, the lookup flow would be to first examine the non-templated paths and see if there is a match. Only after this first step fails would a fall through occur to the more involved matching occur. This would preserve the previous performance characteristics of registered actions don't intend to bind any parameters. It would also decrease the number of registered actions that need to be iterated over for path variable matching.

/// File: UrlParser.java
/// Creator: nathan@nathanieljwright.com
/// Copyright (C) 2024 Nathaniel Wright. All Rights Reserved.
public static ActionParsedContent findAction(ArrayList<ActionRegistration> actionRegistrations, String requestTarget) throws InternalServerException {
    ActionParsedContent candidate = null; // The most specific match so far.
    String candidatePath = null; // The most specific match's path.

    for (var actionRegistration : actionRegistrations) {
        var path = actionRegistration.getPath();
        int pathIterator = 0;
        int uriIterator = 0;

        HashMap<String, String> pathVariableMappings = new HashMap<>();
        for (; pathIterator < path.length() && uriIterator < requestTarget.length(); pathIterator++, uriIterator++) {
            if (path.charAt(pathIterator) == '{') {
                // Start of a templated portion of the path.
                pathIterator++;

                // Parse path until the end of the template block is found '}'
                int startOfVariable = pathIterator;
                if (pathIterator >= path.length()) {
                    throw new InternalServerException("Unable to parse path: " + path);
                }

                var c = path.charAt(pathIterator);
                while (c != '}') {
                    pathIterator++;
                    c = path.charAt(pathIterator);
                }

                int endOfVariable = pathIterator;
                pathIterator++;
                var pathVariable = path.substring(startOfVariable, endOfVariable);

                if (pathIterator < path.length()) {
                    // There is still more of the path that can be parsed after this templated variable.
                    // Only match until a separator is encountered on the request target.
                    var separator = path.charAt(pathIterator);
                    if (separator == '{') {
                        throw new InternalServerException(missingSeparatorError + path);
                    }

                    int startOfValue = uriIterator;
                    while (uriIterator < requestTarget.length() && requestTarget.charAt(uriIterator) != separator) {
                        uriIterator++;
                    }

                    int endOfValue = uriIterator;
                    var value = requestTarget.substring(startOfValue, endOfValue);
                    pathVariableMappings.put(pathVariable, value);

                    if (uriIterator == requestTarget.length()) {
                        break;
                    }
                } else {
                    // There isn't anything left in the templated path after this variable.
                    // Greedily grab the rest of the request target as the value.
                    var value = requestTarget.substring(uriIterator);
                    pathVariableMappings.put(pathVariable, value);
                    uriIterator = requestTarget.length();
                    break;
                }
            }

            // No templated path here. Just make sure that the characters match.
            if (requestTarget.charAt(uriIterator) != path.charAt(pathIterator)) {
                break;
            }
        }

        if (uriIterator == requestTarget.length() && pathIterator == path.length()) {
            if (candidatePath != null && candidatePath.length() < path.length()) {
                // We've found a more specific match than the previous candidate.
                candidate = new ActionParsedContent(actionRegistration.getAction(), pathVariableMappings);
                candidatePath = path;
            } else if (candidatePath == null) {
                // First match.
                candidate = new ActionParsedContent(actionRegistration.getAction(), pathVariableMappings);
                candidatePath = path;
            }
        }
    }

    return candidate;
}