Dynamic Path Matching with Go and mux

Deepjyoti Barman @deepjyoti30
Jul 16, 2023 1:16 PM UTC
Post cover

Go is by far one of the most interesting languages that I have worked with and I have enjoyed working with it almost everytime (except when functions do not support default values). Recently, I came across this problem where I had a use-case to support user defined routes for an API. Problem was supporting these user defined URL's in real time and actually triggerring a handler based on them.

More details of the problem

The use-case I had was that the users will be allowed to define any custom route path and some handler logics that should be executed based on that. It's easy to handle the part of triggering the user defined logic based on the route matched. Hard part is to check whether an incoming request is supposed to be matched against an user-defined route or a system route.

Dynamic URL Matching Problem

Naive Approach that was considered

I considered this naive approach where I would restart the router (using Mux) everytime an user defined route is added/deleted/updated and the router will know whether the incoming request is for an user defined route or system.

This approach is simple enough but is a very bad practice. Imagine thousands of users updating their routes every now and then and the whole router restarts and the API (as a whole) goes down for a few seconds. This can be a major problem for a production API that is customer facing.

Thus, there was need for figuring out an alternate approach to this problem that would be efficient as well.

How it's Solved

Just a heads up, before I dive into details, the problem was solved by using mux and it's useful functions (thanks to the developers for considering a scenario like this while developing).

In order to understand the solution to the problem, we will have to understand how mux handles an incoming request. The flow is important here in order to solve this problem in a very efficient way.

In mux, every route has two properties (or functions) that can be defined against it. One of them is a matcher which can be defined optionally but not necessarily required. The other is a handler which is the actual function that will be trigerred if the route is matched.

Mux Route Matching Flow

Matchers

mux uses matchers to determine whether a route should match. matchers are just mere functions which return a boolean value. This value indicates whether the route is matched or not matched.

  • matcher returns true: route is matched and handler should be called.
  • matcher returns false: route is not matched

NOTE: One thing to know here is that mux goes through each route that is defined in the router until a matcher returns true or the end of routes is reached. When end of routes is reached, a 404 is returned with an error indicating the page is not found.

Matchers are a boon in this particular scenario as this makes solving dynamic user defined routes really simple.

Here's an example matcher that matches the incoming route if it is /hello:

func getHelloMatcher() mux.MatcherFunc {
    return func(req *http.Request, rm *mux.RouteMatch) bool {
        return req.URL.Path == "/hello"
    }
}

Above is a very simple example of how a custom matcher can be defined but in order to match against a dynamic route, it is better to use mux's own functions. mux internally uses a Match function that checks if a route matches the incoming requests path and accordingly returns true or false. It is ideal for us to use this function as they use some complex regex to match the path.

Following code shows how to use mux's internal Match function:

func getHelloMatcher() mux.MatcherFunc {
    return func(req *http.Request, rm *mux.RouteMatch) bool {
        // In order to use a internal `mux` function we will need to
        // define a dummy router that can provide the function.
        copyMuxRouter := mux.NewRouter().StrictSlash(true)

        return copyMuxRouter.Methods(http.MethodGet, http.MethodPost).
                Name("dynamic router").
                Path("/hello").
                Match(req, rm)
    }
}

As explained, above code uses an internal function to match the incoming route against a route /hello. This part of the code can be made dynamic based on user defined route that can be fetched accordigly.

Handlers

After a matcher returns true, the route's handler is called. In mux, all routes will absolutely need a handler defined against it or else mux will not start the router and throw an error at startup.

Pretty much anyone who has used mux is aware of how to define a handler so I will not go over the steps on that. Here's a full example from mux docs on how to define a handler for a route.

Enhancements to the above

The problem that I initially mentioned can be solved by using matchers as I have explained. However, there are certain places where the code can be made efficient. The matcher function that mux supports, uses a RouteMatch type. This is a custom type inside of which there's a Vars key which maps to a map type. This Vars can be used to pass details from the matcher to the handler in order to reduce some redundant steps.

As an example, say the user defined route is stored in a database and the user can also define different methods for the route. These details might come handy in the handler. In our scenario, these details are definitely required in the matcher phase of the code where a database call can be made to get the details for a route.

In the handler phase of the flow, instead of making another database call to get the details of the matched route, those details can simply be passed from the matcher to the handler by using the Vars of the RouteMatch object.

I am not sure if RouteMatch.Vars was intentionally designed to do that but it is definitely a good hack (if you will) to make the code efficient and relatively faster (database network calls are expensive).

Following example shows how some details are passed from the matcher to the handler:

func getHelloMatcher() mux.MatcherFunc {
    return func(req *http.Request, rm *mux.RouteMatch) bool {
        // In order to use a internal `mux` function we will need to
        // define a dummy router that can provide the function.
        copyMuxRouter := mux.NewRouter().StrictSlash(true)

        methods := [http.MethodGet, http.MethodPost]
        isMatched := copyMuxRouter.Methods(methods...).
                Name("dynamic router").
                Path("/hello").
                Match(req, rm)

        if isMatched {
            rm.Vars["MATCHED_ROUTE_METHODS"] = strings.Join(methods, "-")
        }

        return isMatched
    }
}

Following is how to use the MATCHED_ROUTE_METHODS in the handler:

func getHelloHandler() mux.HandlerFunc {
    return func(rw http.ResponseWriter, req *http.Request) {
        // Read the vars
        vars := mux.Vars(req)

        // Get the methods.
        methods := vars["MATCHED_ROUTE_METHODS"]
        fmt.Println("Matched methods are: ", methods)
    }
}

GoLang is becoming one of my favorite languages to work on and I am very close to even replacing Python with GoLang in my next project. I think everyone should give it a try to find out the beauty of it.

gorrila/mux team has been doing a great job building this awesome router. Go give it a star at gorilla/mux.

Discussion