40ants-routes - Framework agnostic URL routing library

Overview

40ants-routes is a framework-agnostic URL routing library for Common Lisp, inspired by Django's URL routing system. It provides a clean and flexible way to define URL routes, generate URLs, and handle URL parameters.

Features

Installation

(ql:quickload :40ants-routes)

Usage Examples

Defining Routes

Routes can be defined using the 40ants-routes/defroutes:defroutes macro.

Inside it's body, use 40ants-routes/defroutes:get, 40ants-routes/defroutes:post, macro to define final routes in the collection.


(uiop:define-package #:test-routes
  (:use #:cl)
  (:shadowing-import-from #:40ants-routes/defroutes
                          #:defroutes
                          #:include
                          #:get
                          #:post)
  (:import-from #:40ants-routes/route-url
                #:route-url)
  (:import-from #:40ants-routes/handler
                #:call-handler)
  (:import-from #:40ants-routes/with-url
                #:with-partially-matched-url
                #:with-url))
(in-package #:test-routes)

(defroutes (*blog-routes* :namespace "blog")
  (get ("/" :name "index")
       (format t "Handler for blog index was called."))
  (get ("/<string:slug>" :name "post")
       (format t "Handler for blog post ~S was called."
               slug)))

Routes, defined by this 40ants-routes/defroutes:defroutes are stored in *blog-routes* variable and can be used either to 40ants-routes/defroutes:include these routes into the route hierarchy, or to search a route, matched to the URL. See section Matching the URL.

Here's an example demonstrating how to use an integer URL parameter:

(defroutes (*article-routes* :namespace "articles")
  (get ("/" :name "index")
       (format t "Handler for articles index was called."))
  (get ("/<int:id>" :name "article")
       (format t "Handler for article with ID ~D was called."
               id)))

In this example, the route will match URLs like /123 and the argument ID will be parsed as an integer.

You can also capture the rest of the URL as a parameter using the .* regex pattern:

(defroutes (*file-routes* :namespace "files")
  (get ("/" :name "index")
       (format t "Handler for files index was called."))
  (get ("/<.*:path>" :name "file")
       (format t "Handler for file at path ~S was called."
               path)))

This will match URLs like /documents/reports/annual/2023.pdf and capture the entire path documents/reports/annual/2023.pdf as the PATH argument.

Including Routes

Routes from libraries can be included in application routes using 40ants-routes/defroutes:include function.

This way they can form a hyerarchy:

(defroutes (*app-routes* :namespace "app")
  (get ("/" :name "index")
       (format t "Handler for application's index page."))
  (include *blog-routes*
           :path "/blog/"))

In it's turn, *blog-routes* might also include other routes itself.

This allows to build a composable web-applications and libraries. For example, some library might build routes to show the list of objects, show details about an object, edit it and delete. Then such routes can be included into a more complex application.

Matching the URL

Imagine, user have opened the URL with a path like this /blog/some-post.

Then in your web-application you might setup the context in which this route processing should happen. Use 40ants-routes/with-url:with-url or 40ants-routes/with-url:with-partially-matched-url macros to setup the context. Inside the context you can use call-handler function to call a body of the route, matched to the URL:


TEST-ROUTES> (with-url (*app-routes* "/blog/some-post")
               (call-handler))
Handler for blog post "some-post" was called.

TEST-ROUTES> (with-url (*app-routes* "/blog/")
               (call-handler))
Handler for blog index was called.

TEST-ROUTES> (with-url (*app-routes* "/")
               (call-handler))
Handler for application's index page.

40ants-routes/with-url:with-url will signal 40ants-routes/errors:no-route-for-url-error error if there is no route matching the whole URL, but 40ants-routes/with-url:with-partially-matched-url will try to do the best it can.

So, inside the 40ants-routes/with-url:with-url body you can use call-handler always, while inside the 40ants-routes/with-url:with-partially-matched-url macro handler should be called only if 40ants-routes/route:current-route-p function returns T.

Generating URLs

Another feature of 40ants-routes is URL generation. URLs can be generated using the 40ants-routes/route-url:route-url function. Like call-handler, it should be called when URL context is available.

In our application routes tree there are two index routes, but we can get paths to both of them using namespaces. Route's namespace is defined as a list of names from the root route, given to the with-url macro up to the matched route. Each defroutes form or a call to include form create an object having the name. These names are added to the current route's namespace.

Imagine we are on the blog-post page and we want to get path to all blog posts. Easiest way to do this, is to call route-url function with only route name:

TEST-ROUTES> (with-url (*app-routes* "/blog/some-post")
               (route-url "index"))
"/blog/"

But this will not work if the user is on the root page:

TEST-ROUTES> (with-url (*app-routes* "/")
               (route-url "index"))
"/"

You might want to make URL resolution more stable, especially if these URLs are used in some common page parts such as header or footer. In this case, help URL resolver by giving it a namespace:

TEST-ROUTES> (with-url (*app-routes* "/")
               (route-url "index"
                          :namespace '("app" "blog")))
"/blog/"

Note, when you are building a reusable component which creates it's own 40ants-routes/routes:routes (1 2) object, you should not use these absolute namespaces, because you don't know beforehand which namespace will be used by user when including the component's routes.

Let's update our blog component routes and add one to edit the blog post:

TEST-ROUTES> (defroutes (*blog-routes* :namespace "blog")
               (get ("/" :name "index")
                 (format t "Handler for blog index was called."))
               (get ("/<string:slug>" :name "post")
                 (format t "Handler for blog post ~S was called.~
                            To edit post go to ~S."
                         slug
                         (route-url "edit-post"
                                    :slug slug)))
               (get ("/<string:slug>/edit" :name "edit-post")
                 (format t "Handler for blog post ~S edit form was called."
                         slug)))
#<40ANTS-ROUTES/ROUTES:ROUTES "blog" 3 subroutes>

Note, how we did use route-url inside the /<string:slug> handler to get path to the post edit page.

Now, let's try to call this handler when this blog's routes are included into the application routes:

TEST-ROUTES> (with-url (*app-routes* "/blog/some-post")
               (call-handler))
Handler for blog post "some-post" was called.To edit post go to "/blog/some-post/edit".

See, it did return /blog/some-post/edit path to the edit page and there wasn't need to specify a namespace at all!

Generating Breadcrumbs

Breadcrumbs can be generated using the 40ants-routes/breadcrumbs:get-breadcrumbs function. This function returns a list of 40ants-routes/breadcrumbs:breadcrumb objects that represent the path from the root to the current page.

Each 40ants-routes/breadcrumbs:breadcrumb object has the following properties: - The URL path to the breadcrumb (accessible via 40ants-routes/breadcrumbs:breadcrumb-path) - The display title for the breadcrumb (accessible via 40ants-routes/breadcrumbs:breadcrumb-title) - The route object associated with the breadcrumb (accessible via 40ants-routes/breadcrumbs:breadcrumb-route)

To use breadcrumbs, you need to define routes with titles:


(defroutes (*admin-users-routes* :namespace "users")
  (post ("/" :name "users"
         :title "Users")
    (format nil "Users list"))
  (get ("/<string:username>"
        :name "user"
        :title "User Profile")
    (format nil "User profile: ~A" username)))


(defroutes (*admin-routes* :namespace "admin")
  (get ("/" :name "admin-index" :title "Admin")
    (format nil "Admin index"))
  (include *admin-users-routes*
           :path "/users/"))


(defroutes (*app-routes* :namespace "app")
  (get ("/" :name "index" :title "Home")
    (format nil "App index"))
  (include *admin-routes*
           :path "/admin/"))

Then, you can generate breadcrumbs for a specific URL:


TEST-ROUTES> (with-url (*app-routes* "/admin/users/john")
               (let ((crumbs (40ants-routes/breadcrumbs:get-breadcrumbs)))
                 ;; This way you can get all paths or titles:
                 (values
                  (mapcar #'40ants-routes/breadcrumbs:breadcrumb-path crumbs)
                  (mapcar #'40ants-routes/breadcrumbs:breadcrumb-title crumbs))))
("/" "/admin/" "/admin/users/" "/admin/users/john")
("Home" "Admin" "Users" "User Profile")

or to generate an HTML code like this:


TEST-ROUTES> (with-url (*app-routes* "/admin/users/john")
               (let ((crumbs (40ants-routes/breadcrumbs:get-breadcrumbs)))
                 (format t "<nav aria-label=\"breadcrumb\">~%")
                 (format t "  <ol class=\"breadcrumb\">~%")
                 (loop for crumb in crumbs
                       for last-p = (eq crumb (car (last crumbs)))
                       do (format t "    <li class=\"breadcrumb-item~:[~; active~]\"~:[~; aria-current=\"page\"~]>~%" 
                                  last-p last-p)
                          (if last-p
                              (format t "      ~A~%" (40ants-routes/breadcrumbs:breadcrumb-title crumb))
                              (format t "      <a href=\"~A\">~A</a>~%" 
                                      (40ants-routes/breadcrumbs:breadcrumb-path crumb) 
                                      (40ants-routes/breadcrumbs:breadcrumb-title crumb)))
                          (format t "    </li>~%"))
                 (format t "  </ol>~%")
                 (format t "</nav>~%")))
<nav aria-label="breadcrumb">
  <ol class="breadcrumb">
    <li class="breadcrumb-item">
      <a href="/">Home</a>
    </li>
    <li class="breadcrumb-item">
      <a href="/admin/">Admin</a>
    </li>
    <li class="breadcrumb-item">
      <a href="/admin/users/">Users</a>
    </li>
    <li class="breadcrumb-item active" aria-current="page">
      User Profile
    </li>
  </ol>
</nav>

For more advanced usage, you can also use functions as route titles to generate dynamic titles based on URL parameters. This is demonstrated in the test file:

First, you need to define a function which will accept an arguments extracted from URL:

(defun get-user-name (&key username &allow-other-keys)
  "A function for retrieving user display names based on username parameter"
  (cond
    ((string= username "john")
     "John Smith")
    ((string= username "jane")
     "Jane Doe")
    (t
     (format nil "User: ~A" username))))

Then redefine routes, to use this function as TITLE argument of the route:

(defroutes (*admin-users-routes* :namespace "users")
  (post ("/" :name "users" :title "Users")
    (format nil "Users list"))
  (get ("/<string:username>"
        :name "user"
        ;; Example of using a function for retrieving
        ;; route title dynamically at runtime:
        :title #'get-user-name)
    (format nil "User profile: ~A" username)))

And now you will get a real user's name as the last breadcrumb title:


TEST-ROUTES> (with-url (*app-routes* "/admin/users/john")
               (let ((crumbs (40ants-routes/breadcrumbs:get-breadcrumbs)))
                 (values
                  (mapcar #'40ants-routes/breadcrumbs:breadcrumb-path crumbs)
                  (mapcar #'40ants-routes/breadcrumbs:breadcrumb-title crumbs))))
("/" "/admin/" "/admin/users/" "/admin/users/john")
("Home" "Admin" "Users" "John Smith")

This makes it easy to create meaningful breadcrumb navigation that adapts to the content being displayed.

API Reference

40ANTS-ROUTES/BREADCRUMBS

Classes

BREADCRUMB

Readers

Functions

Generate breadcrumbs list for the current URL set by 40ants-routes/with-url:with-url macro.

40ANTS-ROUTES/DEFROUTES

Functions

Macros

macro
(var-name &key namespace (routes-class 'routes)) &body route-definitions

Define a variable holding collection of routes and binds it to a variable VAR-NAME.

This macro acts like a DEFVAR - if there is already an 40ants-routes/routes:routes (1 2) object bound to the variable, then it is not replaced, but updated inplace. This allows to change routes on the fly even if they were included into some routes hierarchy.

You can use ROUTES-CLASS argument to supply you own class, inherited from routes (1 2). This way it might be possible to special processing for these routes, for example, inject some special code for representing this routes in the "breadcrumbs".

Use get, post, put, DELETE macros in ROUTE-DEFINITIONS forms.

See more examples how to define routes in the Defining Routes section.

macro
(path &key name title (route-class 'route)) &body handler-body
macro
(path &key name title (route-class 'route)) &body handler-body
macro
(path &key name title (route-class 'route)) &body handler-body

40ANTS-ROUTES/ERRORS

Classes

ARGUMENT-MISSING-ERROR

Readers

NAMESPACE-DUPLICATION-ERROR

Readers

NO-COMMON-ELEMENTS-ERROR

Readers

NO-ROUTE-FOR-URL-ERROR

Readers

PATH-DUPLICATION-ERROR

Readers

URL-RESOLUTION-ERROR

Readers

40ANTS-ROUTES/FIND-ROUTE

Functions

Find a route by name in the given namespace hierarchy.

If route was found, then returns it.

Additionally, it will call ON-MATCH callable argument with each route node along path to the leaf route.

40ANTS-ROUTES/GENERICS

Generics

generic-function
routes route-or-routes-to-add &key override

Add a route or included-routes object to the routes collection at runtime. If a route with the same path or namespace already exists, an error will be signaled unless override is set to true.

Should write a piece of URL to the STREAM substituting arguments from plist ARGS.

When called, it should write a piece of URL without starting backslash.

Returns a list of breadcrumbs associated with given routes node.

NODE argument could have 40ants-routes/route:route class, 40ants-routes/routes:routes class or an object of other class bound to some object of 40ants-routes/route:route class.

For objects of class 40ants-routes/routes:routes usually the method return breadcrumbs of the route having the / path.

Method can return from zero to N objects of 40ants-routes/breadcrumbs:breadcrumb class. A returning of multiple breadcrumbs can be useful if route matches to some filename in a nested directory and you want to give an ability to navigate into intermediate directories.

Returns T of node can respond to node-namespace generic-function call.

Checks for complete match of the object to URL.

Should return an OBJ if it fully matches to a given url. May return a sub-object if OBJ matches to a prefix and sub-object matches the rest of URL.

If match was found, the second returned value should be a alist with matched parameters.

If ON-MATCH argument is given, then in any case of match, full or prefix, calls ON-MATCH function with OBJ as a single argument.

Returns a string name of node's namepace. Works only for objects for which has-namespace-p returns true.

Tests of obj matches to the a prefix of URL.

If match was found, should return two values: the object which matches and position of the character after the matched prefix.

If OBJ is a compound element, then a sub-element can be returned in case of match.

40ANTS-ROUTES/HANDLER

Functions

Calls a handler of current route.

Should be called only during 40ants-routes/with-url:with-url macro body execution.

40ANTS-ROUTES/INCLUDED-ROUTES

Classes

INCLUDED-ROUTES

Readers

The original collection that was included

Path to add to all routes in the collection

Functions

40ANTS-ROUTES/MATCHED-ROUTE

Classes

MATCHED-ROUTE

Readers

Parameters extracted from the URL pattern as alist where keys are parameter names and values - parameter types.

The original route object which has been matched.

Functions

40ANTS-ROUTES/ROUTE

Classes

ROUTE

Readers

Function to handle the route

HTTP method (GET, POST, PUT, etc.)

Name of the route

Title for breadcrumbs

Functions

Returns the current route.

Should be called only during 40ants-routes/with-url:with-url macro body execution.

Returns T if there current route matching the URL was found..

Should be called only during 40ants-routes/with-url:with-url or 40ants-routes/with-url:with-partially-matched-url macro body execution.

Checks if OBJ is of route class.

40ANTS-ROUTES/ROUTE-URL

Functions

function
name &rest args &key namespace &allow-other-keys

Generate a URL for a named route with the given parameters.

40ANTS-ROUTES/ROUTES

Classes

ROUTES

Readers

List of children in this collection.

Namespace of this routes collection.

Accessors

List of children in this collection.

Namespace of this routes collection.

Functions

Checks if object is of class routes.

Macros

macro
(namespace &key (routes-class 'routes)) &body route-definitions

Define a variable holding collection of routes the same way as 40ants-routes/defroutes:defroutes does, but do not bind these routes to the variable.

40ANTS-ROUTES/URL-PATTERN

Classes

URL-PATTERN

Readers

Alist with parameter types

Functions

Parse a URL pattern and extract parameter specifications.

Returns an object of class url-pattern.

40ANTS-ROUTES/WITH-URL

Macros

Execute body with the current routes object corresponding to a given URL argument.

Difference between this macro and with-url macro is that with-url signals an error if it is unable to find a leaf route matching to the whole URL.

with-partially-matched-url will try to find a routes path matching as much of URL as possible. As the result, 40ants-routes/route:current-route-p function might return NIL when URL was not fully matched by with-partially-matched-url.

macro
(root-routes url) &body body

Execute body with the current routes object corresponding to a given URL argument.