DEV Community

Kasey Speakman
Kasey Speakman

Posted on

ClojureScript MVU routing

Routing had some hurdles to overcome.

This adventure in ClojureScript MVU has me wrestling with the bogeyman that is URL-based routing. (Last time I turned the MVU loop asynchronous.) I used extra parenthesis there in honor of Lisp.

So I invoked the bogeyman before, but my solution ultimately was less than 40 lines of code. This includes doing my own type conversions, which I was not expecting.

It's different here

I chose reitit as my routing library, as it seemed popular and high performance in my research.

Fun fact: "reitit" is the Finnish word for "routes". It is pronounced like "rate it".

Hurdle 1: route conflicts

I am accustomed to routes being checked in order. So I was able to specify routes like this:

/user/add
/user/{id}

But with reitit this will not compile by default. It considers that "add" can be substituted for {id}, and it therefore does not know which route to pick. Aside from being order dependent, I found this a bit weird for reasons I could not immediately articulate. I will cover that in the "coercion" hurdle.

Ultimately, this behavior makes sense considering that reitit uses a hash lookup for matching routes for performance reasons. The other routers I used (and wrote) before were doing linear lookups, which means every route will be checked until a matching one is found. Reitit has an optional linear-router. But instead I reorganized my routes to avoid conflicts to keep the better-performing default router.

Hurdle 2: route conflicts by name

After I fixed the path conflicts, I still kept getting route conflicts. Once I paid a little more attention to the error message, it turned out that it was naming conflicts this time.

I have a form that handles multiple urls, but different urls initialize it in different ways. For example, add or edit mode. I had wrongly assumed that "names" were not unique. So I was using the same name in multiple places but with different route data. But in fact the route's name is actually used to look it up, so semantically it is acts more like a route ID and must be unique.

I didn't try to set the route name to a hash-map. That could be an interesting experiment.

So next I tried to create them without names (which is oddly valid), with only route data in the form my app needs it. This works, but only when converting the url to a route match. There is no facility to go the other way. The built-in reverse lookup is by name only. I thought to make my own reverse lookup by route data. But this data is not available when you list routes. So this was unfortunately a dead end. And I had to give routes arbitrary names. And I will have to map those names to the data that the app needs.

Hurdle 3: missing routes

I was fundamentally misunderstanding how routes were evaluated in reitit. My route tree looked like this for my little lazy finance forecaster:

(def router
  (r/router
   [["/money"]
    ["/forecast"]
    ["/form"
     ["/income" :income-add
      ["/{id}" :income-edit]]
     ["/payment"
      ["/{id}"]]]]))

I thought I could tag any level of this thing with a name like :income-add above. But no. These only become actual routes when they "bottom out". So "/form/income/{id}" will be a matchable route, but "/form/income" will not be, regardless of the fact that I gave it a name above. There is a work-around -- use an empty string to make it a bottom route.

    ["/form"
     ["/income"
      ["" :income-add]
      ["/{id}" :income-edit]]

Hurdle 4: "coercion"

So I've got it working and am playing with it in the REPL. I notice after matching, the :id parameter value still a string. Makes sense... I never told it what type the url parameter should be. So I find how to do this. The community seems to call this coercion - bahm bahm baaaAAA - and apparently it is a big deal. Here are the highlights.

  • Must pull in an external lib, create a spec for coercion
  • Coercion is not automatically run on route matching
    • Must be run as an manual separate step
  • Coercion failure throws an exception

To be fair to reitit, coercion can be used for non-trivial conversions of server side data, and is designed to plug into something called "ring". But for URL path parameters, this is way too much complication.

It was also very surprising that coercion failure throws. This implies I am supposed to try/catch in non-IO/non-side-effect usage of a Clojure lib. Which I find quite strange. What happened to primacy of data? And it has further implications.

When the URL parameter does not match the type, I expected that it would not match the route. This is also why I was confused by path conflicts in Hurdle 1. If this was the behavior, then add could not have been substituted for {id} (a uuid), and there would be no route conflict.

So anyway, I didn't do any of that coercion nonsense. I wrote ~10 lines of code to convert the types myself.

The result

(ns routes
  (:require [reitit.core :as r])
  (:require [clojure.string :refer [split]])
  (:require ["uuid" :as u]))

(def router
  (r/router
   [["/money" :money]
    ["/forecast" :forecast]
    ["/form"
     ["/income"
      ["" :income-add]
      ["/{id}" {:name :income-edit :param-types {:id :uuid}}]]
     ["/payment"
      ["" :payment-add]
      ["/{id}" {:name :payment-edit :param-types {:id :uuid}}]]]]))

(def converters
  {:uuid (fn [s] (when (u/validate s) (uuid s)))})

(defn convert [params [param-key type-key]]
  (when (some? params)
    (when-let [convert (type-key converters)]
      (when-let [new-value (convert (param-key params))]
        (assoc params param-key new-value)))))

(defn fromUrl [url]
  (let [hash (peek (split url #"#"))
        {{name :name
          param-types :param-types} :data
         path-params :path-params} (r/match-by-path router hash)]
    (when (some? name)
      (if-let [params (not-empty path-params)]
        (when-let [converted (reduce convert params param-types)]
          [name converted])
        [name]))))

(defn toUrl [& args]
  (let [{path :path} (apply r/match-by-name router args)]
    (if (some? path) (str "#" path) "")))

Above I added route data to specify the type of the parameters. And once I get reitit's route match, I use that to convert the parameters myself. Converters are in a map, so it will be easy to add more. I also made the behavior like I originally expected -- when the parameter cannot be converted to the target type, the route does not match.

Being able to add these behaviors myself says good things about reitit's API flexibility.

I am using uuid.js for creating/validating UUIDs. I could not find a CLJS function that can do a UUID conversion. uuid does no validation -- uuid "blah" works. uuid? does a type check, not a string validation. #uuid is only for literals. And Java's UUID.fromString is not available in CLJS.

A while ago I was bitten by JS Math.random's sometimes broken or barely random browser implementations. And Clojure's uuid creation function random-uuid uses that. Maybe it's fine in all browser versions I am targeting. But, you know, trust is hard to win back. The uuid.js library uses cryto APIs for randomness when available.

Conclusion

If you look at the code result of this adventure, it is pretty succinct. But it took some wrestling to get it there.

Maybe I am just seeing what I expect to see, but it is interesting to observe how complicated one style of language can make things that are commonplace in another style (and vice versa).

Top comments (0)