Liking cljdoc? Tell your friends :D

Endpoint Routes and Handlers

This doc explains how to write endpoint routes and handlers in a Sweet Tooth app.

Routes have two main purposes:

  • Conveying requests to a handler

  • Generating paths using a route name and parameters

For a more detailed explanation of routes and routing, see Routes and Routing Explained.

Sweet Tooth relies on the reitit routing library, which represents routes as two-element vectors that associate URL patterns like /users/{id} with a map containing the route’s name, handler, and metadata. For example:

route example
["/users/{id}" {:name    :users
                :handler (fn [req] {:body "response"})}]

Handlers are functions that take a request as an argument and return a response.

Sweet Tooth introduces conventions for associating route paths, route names, and the namespaces that hold handler definitions. This section is a practical tutorial on what steps to take to get things working. Further sections go into more detail about the implementation so that you’ll understand how to customize the system to your particular needs, if you need to.

These docs assume you’re working within the the Sweet Tooth To-Do List Example project.

Basic Routes and Handlers

Sweet Tooth is oriented around RESTful APIs. You send GET, POST, PUT, and DELETE requests to paths like /todo-list and /todo-list/1 in order to perform CRUD operations. The corresponding request handlers are located in a namespace like sweet-tooth.todo-example.backend.endpoint.todo-list.

The sweet-tooth.todo-example.cross.endpoint-routes/expand-routes function lets you specify the names of namespaces that contain handlers and uses those to generate routes. Let’s generate some simple routes in a REPL and work our way up to more complex ones:

basic namespace route
(require '[sweet-tooth.endpoint.routes.reitit :as serr])
(serr/expand-routes
 [[:sweet-tooth.todo-example.backend.endpoint.todo-list]])

;; =>
[["/todo-list"
  {::serr/ns   :sweet-tooth.todo-example.backend.endpoint.todo-list
   ::serr/type :collection
   :name       :todo-lists}]
 ["/todo-list/{id}"
  {::serr/ns   :sweet-tooth.todo-example.backend.endpoint.todo-list
   ::serr/type :member
   :name       :todo-list
   :id-key     :id}]]

The function took a single keyword corresponding to a namespace’s name and generated two routes for it, one with the path "/todo-list" named :todo-lists and one with the path "/todo-list/{id}" `named `:todo-list. These paths and names are derived from the namespace name, with endpoint. as the default delimiter.

Routes are supposed to convey a request to a handler, and with reitit routes you designate a handler with the :handler key. The :handler key is conspicuously missing from the above routes. So how does this work?

The Sweet Tooth module :sweet-tooth.endpoint.module/liberator-reitit-router adds the :handler key to routes. It uses the metadata keys ::serr/ns and ::serr/type to construct a liberator request handler using a liberator decision map.

::serr/ns is used to find a liberator decision map. By default, these are defined in a var named decisions. If you look at sweet-tooth.todo-example.backend.endpoint.todo-list namespace you’ll see something like this (I’ve elided irrelevant code):

decisions
(def decisions
  {:collection
   {:get  {:handle-ok (comp tl/todo-lists ed/db)}
    :post {:post!          ed/create->:result
           :handle-created ed/created-pull}}

   :member
   {:get {:handle-ok (fn [ctx])}
    :put {:put!      ed/update->:result
          :handle-ok ed/updated-pull}

    :delete {:delete!   (fn [ctx])
             :handle-ok []}}})

decisions is a map whose keys correspond to ::serr/type in the routes above: if a request for "/todo-list" is received, the ::serr/type value of :collection is used to look up the map of handlers under :collection in the decisions var. The request method (:get, :post, :put etc) is then used to look up the decision map for that method. The decision map is passed to a liberator function that returns a request handler. (If you’re unfamiliar with liberator this probably looks weird as all get out. I’ll explain liberator elsewhere, for now we’re just focusing on the relationship between routes and handlers.)

To summarize:

  • There is a relationship between route paths, route names, namespaces, and handlers

  • Handlers are constructed from liberator decision maps

  • Those decision maps live in a var named decisions

  • decisions is a map keyed by route type (:collection, :member) and request method (:get, :post etc)

  • You can generate routes for an endpoint namespace using sweet-tooth.endpoint.routes.reitit/expand-routes. Route paths and names are derived from namespace names.

Route Expanders

What if you only want to generate a :collection route or only want to generate a :member route? Routes can take an option map, and you can specify which routes to generate with the key ::serr/expand-with:

route generators
(serr/expand-routes
 [[:project.endpoint.todo-list {::serr/expand-with [:collection]}]])
;; =>
[["/todo-list"
  {::serr/ns   :project.endpoint.todo-list
   ::serr/type :collection
   :name       :todo-lists}]]

(I’ve switched from :sweet-tooth.todo-example.backend.endpoint.todo-list to :project.endpoint.todo-list because the latter is much shorter, and to show reinforce that everything up to endpoint. is ignored when generating paths and route names.)

Notice that the value of for ::serr/expand-with is [:collection] and only a :collection route was generated. You can try this with [:member] to see what happens. The default value for ::serr/expand-with is [:collection :member].

In this context, :collection and :member are names of route types. Each route type has an expansion strategy associated with. The expansion strategy includes:

  • A rule for deriving the route’s name from the namespace’s name. The :collection strategy produces a route named :todo-lists when given a namespace name :x.endpoint.todo-list; :member produces a route named :todo-list.

  • A rule for deriving the route’s path from the namespace’s name. The :collection strategy generates the path /todo-list and :member generates /todo-list/{id}.

In later sections you’ll see how to work with additional kinds of route types, include :singleton, :member children, and arbitrary types.

Custom Route Paths and Names

What if you want to create routes that match paths like the following?

  • /api/v1/todo-list

  • /todo-lists

  • /todo-list/{id}/todo-items

  • /admin/todo-list

Custom Route Paths: prefixes and suffixes

You can specify paths with the keys ::serr/path-prefix and :serr/path-suffix:

path prefixes
(serr/expand-routes
 [[:project.endpoint.todo-list {::serr/path-prefix "/api/v1"}]])
;; =>
[["/api/v1/todo-list"
  {::serr/ns   :project.endpoint.todo-list
   ::serr/type :collection
   :name       :todo-lists}]
 ["/api/v1/todo-list/{id}"
  {::serr/ns   :project.endpoint.todo-list
   ::serr/type :member
   :name       :todo-list
   :id-key     :id}]]

Custom Route Paths per route type

::serr/path-prefix was applied to both of the generated routes, but what if you need to modify the path for just one route type?

path prefixes
(serr/expand-routes
 [[:project.endpoint.todo-list {::serr/expand-with [[:collection {::serr/path-prefix "/api/v1"}]
                                                                        :member]}]])
;; =>
[["/api/v1/todo-list"
  {::serr/ns   :project.endpoint.todo-list
   ::serr/type :collection
   :name       :todo-lists}]
 ["/todo-list/{id}"
  {::serr/ns   :project.endpoint.todo-list
   ::serr/type :member
   :name       :todo-list
   :id-key     :id}]]

You can specify options for each route type under ::serr/expand-with by adding a pair, [:route-type options-map].

::serr/path lets you specify a replacement for just the part of the path that’s generated by the route type. Here’s how you could generate /todo-lists and /api/v1/todo-lists:

per-route-type paths
(serr/expand-routes
 [[:project.endpoint.todo-list {::serr/expand-with [[:collection {::serr/path "/todos"}]]}]])
;; =>
[["/todos"
  {::serr/ns   :project.endpoint.todo-list
   ::serr/type :collection
   :name       :todo-lists}]]

(serr/expand-routes
 [[:project.endpoint.todo-list {::serr/expand-with [[:collection {::serr/path-prefix "/api/v1"
                                                                  ::serr/path "/todos"}]]}]])
;; =>
[["/api/v1/todos"
  {::serr/ns   :project.endpoint.todo-list
   ::serr/type :collection
   :name       :todo-lists}]]

You might be wondering why you would specify both ::serr/path-prefix and ::serr/path. In the above case it doesn’t necessarily makes sense. It makes more sense when you consider that route options can be applied to multiple routes. We saw that above when ::serr/path-prefix was applied to both :member and :collection routes. In a later section you’ll see how to specify route options for groups of namespace routes.

Member Routes

What if you wanted to route a path like "/todo-list/{id}/todo-items"?

member routes
(serr/expand-routes
 [[:project.endpoint.todo-list {::serr/expand-with [[:member/todo-items]]}]])
;; =>
[["/todo-list/{id}/todo-items"
  {::serr/ns   :project.endpoint.todo-list,
   ::serr/type :member/todo-items,
   :name       :todo-list/todo-items,
   :id-key     :id}]]

You add a route type of :member/todo-items. It generates a route with the desired path and the name :todo-list/todo-items. In the corresponding namespace, you would define handlers with something like:

member route handlers
(def decisions
  {:member/todo-items
   {:get {:handle-ok (fn [ctx])}
    :post {:handle-created (fn [ctx])}}})

Remember, the keys in decisions correspond to route types, and you generated the route above with the type :member/todo-items.

Nested Routes

How about routing "/admin/todo-list" and "/admin/todo-list/{id}"? You could use ::serr/path-prefix, but you probably also want the handlers to live in a separate namespace and to use separate route names. Here’s how you’d do it:

nested routes
(serr/expand-routes
 [[:project.endpoint.admin.todo-list]])
;; =>
[["/admin/todo-list"
  {::serr/ns   :project.endpoint.admin.todo-list
   ::serr/type :collection
   :name       :admin.todo-lists}]
 ["/admin/todo-list/{id}"
  {::serr/ns   :project.endpoint.admin.todo-list
   ::serr/type :member
   :name       :admin.todo-list
   :id-key     :id}]]

Arbitrary Routes

The expand-routes function only performs route expansion when it encounters vectors where the first element is a keyword, like [:project.endpoint.admin.todo-list]. In addition to these namespace-based routes, you can also write plain ol' reitit routes. The next example matches a regular reitit route with a namespace route:

nested routes
(serr/expand-routes
 [["/init" {:name :init}]
  [:project.endpoint.todo-list]])
;; =>
[["/init" {:name :init}]
 ["/todo-list"
  {::serr/ns   :project.endpoint.todo-list,
   ::serr/type :collection,
   :name       :todo-lists}]
 ["/todo-list/{id}"
  {::serr/ns   :project.endpoint.todo-list,
   ::serr/type :member,
   :name       :todo-list,
   :id-key     :id}]]

The regular route isn’t touched. One non-obvious consequence of this is that you’ll need to supply a :handler key yourself; Sweet Tooth uses the ::serr/ns and ::serr/type keys to construct a handler, but those are absent. You can add a handler as an integrant ref or by using the sweet-tooth.endpoint.utils/clj-kvar function:

nested routes
(serr/expand-routes
 [["/init" {:name :init
            :handler (ig/ref :project.endpoint.init/handler)}]])

(serr/expand-routes
 [["/init" {:name    :init
            :handler (sweet-tooth.endpoint.utils/clj-kvar :project.endpoint.init/handler)}]])

The clj-kvar function returns the corresponding var during Clojure compilation and returns the keyword during ClojureScript compilation. This makes it easier to write routes that can cross-compile.

You should use an integrant ref if the handler needs to participate in integrant’s configuration system - if you need to initialize the handler with environment variables or system components, for example. Using clj-kvar would let you forego integrant initialization and keep your integrant config a little leaner.

Shared Route Options

What if you want to give multiple routes a prefix or otherwise want to apply options to multiple routes?

nested routes
(serr/expand-routes
 [{::serr/path-prefix "/api/v1"}
  [:project.endpoint.todo-list]
  [:project.endpoint.todo]])
;; =>
[["/api/v1/todo-list"
  {::serr/ns   :project.endpoint.todo-list
   ::serr/type :collection
   :name       :todo-lists}]
 ["/api/v1/todo-list/{id}"
  {::serr/ns   :project.endpoint.todo-list
   ::serr/type :member
   :name       :todo-list
   :id-key     :id}]
 ["/api/v1/todo"
  {::serr/ns   :project.endpoint.todo
   ::serr/type :collection
   :name       :todos}]
 ["/api/v1/todo/{id}"
  {::serr/ns   :project.endpoint.todo
   ::serr/type :member
   :name       :todo
   :id-key     :id}]]

expand-routes takes a vector as its argument. Whenever it encounters a vector in that map, as it does with {::serr/path-prefix}, it adds that map as route options for all the routes that follow. If one group of routes need a set of common options that differs from another group of routes, you could write something like this:

nested routes
(serr/expand-routes
 [{::serr/path-prefix "/api/v1"}
  [:project.endpoint.todo-list]

  {:id-key :db/id}
  [:project.endpoint.todo]])
;; =>
[["/api/v1/todo-list"
  {::serr/ns   :project.endpoint.todo-list
   ::serr/type :collection
   :name       :todo-lists}]
 ["/api/v1/todo-list/{id}"
  {::serr/ns   :project.endpoint.todo-list
   ::serr/type :member
   :name       :todo-list
   :id-key     :id}]
 ["/todo"
  {::serr/ns   :project.endpoint.todo
   ::serr/type :collection
   :name       :todos
   :id-key     :db/id}]
 ["/todo/{db/id}"
  {::serr/ns   :project.endpoint.todo
   ::serr/type :member
   :name       :todo
   :id-key     :db/id}]]

Notice that todo routes have a different :id-key and they also don’t have the /api/v1 prefix. Whenever a new common options map ({:id-key :db/id}) is encountered, it replaces the previous map ({::serr/path-prefix "/api/v1"}).

Can you improve this documentation?Edit on GitHub

cljdoc is a website building & hosting documentation for Clojure/Script libraries

× close