["/users/{id}" {:name :users
:handler (fn [req] {:body "response"})}]
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:
["/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.
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:
(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):
(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.
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
:
(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.
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
You can specify paths with the keys ::serr/path-prefix
and
:serr/path-suffix
:
(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}]]
::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?
(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
:
(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.
What if you wanted to route a path like "/todo-list/{id}/todo-items"
?
(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:
(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
.
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:
(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}]]
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:
(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:
(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.
What if you want to give multiple routes a prefix or otherwise want to apply options to multiple 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:
(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