Routing
Quickstart
In the quickstart tutorial, we saw how to create and render widgets, and how to react to user events. We also learned that by default the app name defined its base url, and how to change it.
Here, we will extend the example and make each task accessible under
/tasks/<task id>
.
Before we start, here's the summary on how to handle routing in Reblocks:
use
REBLOCKS-NAVIGATION-WIDGET:DEFROUTES
.DEFROUTES
associatesURL
s (as strings) to a widget:
(defroutes tasks-routes
("/tasks/\d+" (make-task-page))
("/tasks/" (make-task-list)))
reblocks/session:init
must return an instance of the route widget, using theMAKE-TASKS-ROUTES
constructor created byDEFROUTES
macro.
Let's start. Note that you can see the full code on Github.
Getting started
Warning!
As for this version of Reblocks,
REBLOCKS-NAVIGATION-WIDGET
system is not in Quicklisp yet. To install it you need to clone the repository somewhere whereASDF
will find it, for example, to the~/common-lisp/
or~/quicklisp/local-projects/
directories.
You can also install the Ultralisp Quicklisp distribution where all Reblocks-related libraries are present and up to date.
Load and import the routing library:
TODO> (ql:quickload '(:reblocks-navigation-widget))
The package definition becomes::
TODO> (defpackage todo
(:use #:cl
#:reblocks-ui/form
#:reblocks/html)
(:import-from #:reblocks/widget
#:render
#:update
#:defwidget)
(:import-from #:reblocks/actions
#:make-js-action)
(:import-from #:reblocks/app
#:defapp)
(:import-from #:reblocks-navigation-widget
#:defroutes))
Extending the example: to each task an id
We want each task to have an id, so we add a slot to the task
widget:
TODO> (defwidget task ()
((title
:initarg :title
:initform nil
:accessor title)
(done
:initarg :done
:initform nil
:accessor done)
(id
:initarg :id
:initform nil
:accessor id
:type integer)))
We also need a simple in-memory "database". We'll use a hash-table to save the tasks. It associates an id to the task:
TODO> (defparameter *store* (make-hash-table) "Dummy store for tasks: id -> task.")
Our task constructor will give them an incremental id:
TODO> (defparameter *counter* 0 "Simple counter for the hash table store.")
TODO> (defun make-task (title &key done)
"Create a task and store it by its id."
(let* ((id (incf *counter*))
(task (make-instance 'task :title title :done done :id id)))
(setf (gethash id *store*) task)
task))
So we create a utility function to find a task by its id. All this could just be an interface to a database.
TODO> (defun get-task (id)
(gethash id *store*))
When we render the tasks list, we add an href on the task, so we can go to /tasks/<id>
:
TODO> (defmethod render ((task task))
(with-html
(:p (:input :type "checkbox"
:checked (done task)
:onclick (make-js-action
(lambda (&key &allow-other-keys)
(toggle task))))
(:span (if (done task)
(with-html
(:s (title task)))
(:a :href (format nil "/tasks/~a" (id task)) ;; <-- only addition.
(title task)))))))
The task-page widget
In Reblocks, an HTML
block that we want to display, and possibly update
independently, is a widget. Here, we want to show a task's details on
their own page, it is then a widget.
TODO> (defwidget task-page ()
((task
:initarg :task
:initform nil
:accessor task)))
TODO> (defmethod render ((task-page task-page))
(let ((task (task task-page)))
(with-html
(:div "Task " (id task))
(:h1 (title task))
(:div (if (done task) "Done!" "To Do."))
(:div "Lorem ipsum…"))))
Defining routes
At this point we can think of our routes like this:
(defroutes tasks-routes
("/tasks/\d+" <create the task-page widget>)
("/tasks/" (make-task-list)))
The regexp \d+
will capture any URL
that is formed of digits and
contains at least one.
As we see, the TASK-PAGE
constructor will need to get the id
matched by the route.
Path and URL parameters
To get the current path, use (reblocks/request:get-path)
. Then,
you can find the matching parameters with CL-PPCRE.
Our TASK-PAGE
constructor becomes:
TODO> (defun make-task-page ()
(let* ((path (reblocks/request:get-path))
(id (first (ppcre:all-matches-as-strings "\d+" path)))
(task (get-task (parse-integer id))))
(if task
(make-instance 'task-page :task task)
(not-found))))
TODO> (defun not-found ()
"Show a 404 not found page."
(with-html
(:div "Task not found.")))
And our router is simply:
TODO> (defroutes tasks-routes
("/tasks/\d+" (make-task-page))
("/tasks/" (make-task-list "Make my first Reblocks app"
"Deploy it somewhere"
"Have a profit")))
The DEFROUTES
macro creates a new class and its constructor, named
MAKE-<CLASS-NAME>
.
Note
It is important to use the constructor instead of
MAKE-INSTANCE
, as it defines properties on the fly.
Redirections
To perform redirections, use (reblocks/response:redirect "/url")
:
TODO> (defroutes tasks-routes
("/tasks/\d+" (make-task-page))
("/tasks/list/?" (reblocks/response:redirect "/tasks/")) ;; <-- redirection
("/tasks/" (make-task-list "Make my first Reblocks app"
"Deploy it somewhere"
"Have a profit")))
Here the trailing /?
allows to catch /tasks/list
and /tasks/list/
.
And indeed, contrary to what we stated in the introduction,
reblocks/response:redirect
does not return a widget but signals a specital condition.
Final steps
Make our router the main widget for this session:
TODO> (defmethod reblocks/session:init ((app tasks))
(declare (ignorable app))
(make-tasks-routes))
Reset the session:
TODO> (defun reset ()
(setf *counter* 0)
(reblocks/debug:reset-latest-session))
TODO> (reset)
And access the app at http://localhost:40000/tasks/.
Lowlevel API
Inherit from this class to add a custom routes.
Defines a handler for a given route. By default route should return
a serialized JSON
:
(defroute (app /api/data)
"{"my-data": [1, 2, 3]}")
but you can redefine the content type:
(defroute (app /api/data :content-type "application/xml")
"<my-data><item>1</item><item>2</item></my-data>")
or to serve robots.txt
:
(defroute (app /robots.txt :content-type "text/plain")
(alexandria:read-file-into-string
(asdf:system-relative-pathname "frontend"
"robots.txt")))
Returns a route, matched on given path. If none matched, then returns nil.
Path should be a string.
This function could be useful for customizing widget rendering
depending on the URL
in the browser.
Methods should return a list like that:
(list 200 ;; status-code
(list :content-type content-type) ;; headers
content) ;; content