4c3588fc |
#+TITLE: TODO backend implementation using CL and fukamachi/ningle
|
19b6e2b4 |
* Setup
|
2abb94cb |
- src_sh[:exports code]{git clone https://github.com/fiddlerwoaroof/data-lens.git ~/quicklisp/local-projects/data-lens}
- src_sh[:exports code]{git clone https://github.com/fukamachi/lack.git ~/quicklisp/local-projects/lack}
#+BEGIN_SRC sh
sbcl --eval '(asdf:load-asd (truename "todo-backend.asd"))'
--eval '(ql:quickload :todo-backend)'
--eval '(fwoar.todo::ensure-started)'
#+END_SRC
After this, all the tests [[http://www.todobackend.com/specs/index.html?http://localhost:5000][here]] should pass and the frontend [[http://www.todobackend.com/client/index.html?http://localhost:5000][here]] should work.
|
19b6e2b4 |
|
4c3588fc |
* todo API
We use a fairly simple structure for our "database": a fset map (a
clojure-inspired persistent data structure) and a handful of
interface functions that wrap it. In this code, this fset map is
referenced as =*todo*=, but this is a detail hidden behind the API.
|
b7951e2b |
** List-level APIs
|
4c3588fc |
|
6613ae69 |
These are functions for getting the todo list and clearing
it. These are activated by the root route: =todos= for GET requests
and =clear-todos= for DELETE requests.
|
4c3588fc |
#+NAME: todolist-manipulation
#+BEGIN_SRC lisp :tangle no
(defun todos ()
(gmap:gmap :seq
(lambda (_ b)
(declare (ignore _))
b)
(:map *todos*)))
(defun clear-todos ()
(setf *todos*
(fset:empty-map)))
#+END_SRC
** Getting/Replacing a todo
This uses lisp's [[http://www.lispworks.com/documentation/HyperSpec/Body/05_a.htm][generalized references]] to abstract away the
storage details of the todos. We also provide a =delete-todo=
|
6613ae69 |
function for removing a todo from the list. =todo= is what backs
the GET request for a specific todo by id.
|
4c3588fc |
#+NAME: todo-accessor
#+BEGIN_SRC lisp :tangle no
(defun todo (id)
(let ((todo (fset:@ *todos* id)))
todo))
(defun (setf todo) (new-value id)
(setf (fset:@ *todos* id)
new-value))
(defun delete-todo (id)
(setf *todos*
(fset:less *todos* id)))
#+END_SRC
** Adding and modifying todos
=new-todo= is fairly trivial. It's main feature is that it has to
make sure the =completed= and =url= keys are set to the appropriate
values. Completed isn't a lisp boolean, so it serializes to JSON
|
6613ae69 |
properly. =new-todo= backs POST requests to the root endpoint.
|
4c3588fc |
#+NAME: new-todo
#+BEGIN_SRC lisp :tangle no
|
4258e918 |
(defvar *external-host*
"localhost")
(defvar *external-port*
5000)
|
4c3588fc |
(defun new-todo (value)
(let ((id (next-id)))
(setf (todo id)
(alexandria:alist-hash-table
(rutilsx.threading:->>
value
(acons "completed" 'yason:false)
|
5fd28f99 |
(acons "url"
(format nil "http://~a:~d/todo/~d"
,*external-host*
,*external-port*
id)))
|
4c3588fc |
:test 'equal))))
#+END_SRC
=update-todo= just merges the input from the frontend into the
relevant todo and then makes sure that the =completed= key is a
|
6613ae69 |
yason-compatible boolean. =update-todo= backs PATCH requests to the
todo endpoint for a specific ID.
|
4c3588fc |
#+NAME: update-todo
#+BEGIN_SRC lisp :tangle no
(defun update-todo (id v)
|
5fd28f99 |
(let* ((old-todo (or (todo id)
(make-hash-table :test 'equal)))
(in-hash-table (alexandria:alist-hash-table v :test 'equal))
(update (data-lens.lenses:over *completed-lens*
'bool-to-yason
in-hash-table)))
(setf (todo id)
(serapeum:merge-tables old-todo
update))))
|
4c3588fc |
#+END_SRC
** Examples
#+BEGIN_SRC lisp :tangle no :noweb yes :exports both :results verbatim
<<example-setup>>
(with-fresh-todos ()
(new-todo '(("title" . "get groceries")))
(new-todo '(("title" . "write-better-documentation")))
(fset:convert 'list (todos)))
#+END_SRC
#+RESULTS:
: (#<hash-table "url": "http://localhost:5000/todo/22",
: "title": "get groceries",
: "completed": YASON:FALSE>
: #<hash-table "url": "http://localhost:5000/todo/23",
: "title": "write-better-documentation",
: "completed": YASON:FALSE>)
|
4258e918 |
* Routing
** Routing utilities
The core utility here is the =defroutes= macro. This takes a
sequence of endpoint descriptions which contain nested definitions
for HTTP verbs and expands to ningle's functions for manipulating
routes.
|
7762e7b6 |
#+NAME: defroutes
#+BEGIN_SRC lisp
(defmacro defroutes (app &body routes)
(alexandria:once-only (app)
`(setf
,@(loop for (target . descriptors) in routes
append (loop for (method callback) in descriptors
append `((ningle:route ,app ,target
|
9360d164 |
:method ,method)
|
7762e7b6 |
,callback))))))
#+END_SRC
This macro organizes all the HTTP verbs for a given endpoint under
the path to that endpoint. A more complete version might allow for
a list of verbs =(:GET :POST)= in the head of each handler clause.
|
4258e918 |
#+BEGIN_SRC lisp :exports both :tangle no :results verbatim
(macroexpand-1
'(defroutes app
("/"
(:GET (handler () (todos)))
(:POST (handler (v) (new-todo v)))
(:DELETE (handler () (clear-todos))))))
#+END_SRC
|
7762e7b6 |
|
4258e918 |
#+RESULTS:
#+begin_example
(LET ((#:APP1852 APP))
(SETF (NINGLE/APP:ROUTE #:APP1852 "/" :METHOD METHOD)
(HANDLER NIL
(TODOS))
(NINGLE/APP:ROUTE #:APP1852 "/" :METHOD METHOD)
(HANDLER (V)
(NEW-TODO V))
(NINGLE/APP:ROUTE #:APP1852 "/" :METHOD METHOD)
(HANDLER NIL
(CLEAR-TODOS))))
T
#+end_example
|
7762e7b6 |
Finally, there are some simple helpers to handle some of the
boilerplate in a clack webserver. Of particular interest is the
=handler= macro, which (since this is a json-only API) makes sure
that all the API results get JSON encoded.
|
4258e918 |
#+NAME: routing-helpers
#+BEGIN_SRC lisp
(defun success (value)
|
7762e7b6 |
(list 200 '(:conent-type "application/json") value))
|
4258e918 |
(defmacro handler ((&optional (sym (gensym "PARAMS"))) &body body)
`(lambda (,sym)
(declare (ignorable ,sym))
(success
(fwoar.lack.json.middleware:wrap-result
(progn ,@body)))))
|
7762e7b6 |
#+END_SRC
** todo routes
|
46d4eb2f |
=setup-routes= binds the endpoints to handlers: ="/"= to handlers
that handle the todo lists while ="/todo/:id"= to handlers that
|
7762e7b6 |
handle individual todos. The =:id= indicates that the
corresponding segment of the path is bound to =:id= in the param
alist. =get-id= handles this, and extracts an integer for the id
(since we are using successive integers for the todo ids).
#+NAME: todo-routes
#+BEGIN_SRC lisp
;; routing
(defun get-id (params)
(parse-integer (serapeum:assocdr :id params)))
(defun setup-routes (app)
(defroutes app
("/" (:GET (handler () (todos)))
(:POST (handler (v) (new-todo v)))
(:DELETE (handler () (clear-todos))))
("/todo/:id" (:GET (handler (v) (todo (get-id v))))
(:DELETE (handler (v)
(delete-todo (get-id v))
nil))
(:PATCH (handler (v)
(update-todo (get-id v)
(remove :id v :key #'car)))))))
#+END_SRC
|
4258e918 |
|
4c3588fc |
* Source
** model.lisp source code
#+BEGIN_SRC lisp :tangle model.lisp :noweb yes :comments noweb
<<package-include>>
<<model-utils>>
(defvar *todos* (fset:empty-map))
<<todolist-manipulation>>
<<todo-accessor>>
<<new-todo>>
<<update-todo>>
(defmacro with-fresh-todos (() &body body)
`(let ((*todos* (fset:empty-map)))
,@body))
#+END_SRC
** routing.lisp source
|
4258e918 |
#+BEGIN_SRC lisp :tangle routing.lisp :noweb yes :comments noweb
<<package-include>>
|
7762e7b6 |
<<defroutes>>
|
4258e918 |
|
7762e7b6 |
<<routing-helpers>>
|
4258e918 |
|
7762e7b6 |
<<todo-routes>>
|
4c3588fc |
#+END_SRC
** main.lisp source
#+BEGIN_SRC lisp :tangle main.lisp :noweb yes
|
9360d164 |
<<package-include>>
;;; entrypoint
(defun setup ()
(let ((app (make-instance 'ningle:<app>)))
(prog1 app (setup-routes app))))
(defvar *handler*)
(defun is-running ()
(and (boundp '*handler*)
,*handler*))
(defun ensure-started (&rest r &key (address "127.0.0.1") (port 5000))
(declare (ignore address port))
(let ((app (setup)))
(values app
(setf *handler*
(if (not (is-running))
(apply 'clack:clackup
(lack.builder:builder
:accesslog
'fwoar.lack.cors.middleware:cors-middleware
'fwoar.lack.json.middleware:json-middleware
app)
r)
,*handler*)))))
(defun stop ()
(if (is-running)
(progn
(clack:stop *handler*)
(makunbound '*handler*)
nil)
nil))
(defun main (&rest _)
(declare (ignore _))
(ensure-started :address "0.0.0.0" :port 5000)
(loop (sleep 5)))
|
4c3588fc |
#+END_SRC
#+NAME: package-include
#+BEGIN_SRC lisp :tangle no :exports none
(in-package :fwoar.todo)
#+END_SRC
#+NAME: model-utils
#+BEGIN_SRC lisp :tangle no :exports none
(defparameter *cur-id* 0)
(defun next-id ()
(incf *cur-id*))
(defparameter *completed-lens*
(data-lens.lenses:make-hash-table-lens "completed"))
(defun bool-to-yason (bool)
(if bool
'yason:true
'yason:false))
#+END_SRC
#+NAME: example-setup
#+BEGIN_SRC lisp :tangle no :noweb yes :exports none
<<package-include>>
(load "pprint-setup")
#+END_SRC
#+HTML_HEAD: <style>
#+HTML_HEAD: :root {
#+HTML_HEAD: --zenburn-fg-plus-2: #ffffef;
#+HTML_HEAD: --zenburn-fg-plus-1: #f5f5d6;
#+HTML_HEAD: --zenburn-fg: #dcdccc;
#+HTML_HEAD: --zenburn-bg: #3f3f3f;
#+HTML_HEAD: --zenburn-bg-plus-1: #4f4f4f;
#+HTML_HEAD: --zenburn-bg-plus-2: #5f5f5f;
#+HTML_HEAD: --zenburn-blue: #8cd0d3;
#+HTML_HEAD: }
|
291406c5 |
#+HTML_HEAD:
#+HTML_HEAD: #table-of-contents h2 {
#+HTML_HEAD: text-align: center;
#+HTML_HEAD: padding-top: 3.5em;
#+HTML_HEAD: }
#+HTML_HEAD:
#+HTML_HEAD: #table-of-contents {
#+HTML_HEAD: width: 25rem;
#+HTML_HEAD: position: fixed;
#+HTML_HEAD: left: 0;
#+HTML_HEAD: top: 0;
#+HTML_HEAD: height: 100%;
#+HTML_HEAD: overflow-y: scroll;
#+HTML_HEAD: scrollbar-width: thin;
#+HTML_HEAD: }
#+HTML_HEAD:
#+HTML_HEAD: #table-of-contents::-webkit-scrollbar { width :6px; }
#+HTML_HEAD:
|
4c3588fc |
#+HTML_HEAD: * {box-sizing: border-box;}
#+HTML_HEAD:
#+HTML_HEAD: body {
#+HTML_HEAD: font-size: 1.2rem;
#+HTML_HEAD: width: 75rem;
#+HTML_HEAD: margin: 0 0 0 25rem;
#+HTML_HEAD: background: var(--zenburn-bg);
#+HTML_HEAD: color: var(--zenburn-fg);
|
e193c726 |
#+HTML_HEAD: font-family: "Alegreya Sans", "Lato", "Roboto", "Open Sans", "Helvetica", sans-serif;
|
4c3588fc |
#+HTML_HEAD: }
|
291406c5 |
#+HTML_HEAD:
|
4c3588fc |
#+HTML_HEAD: a {color: var(--zenburn-blue);}
#+HTML_HEAD:
|
e193c726 |
#+HTML_HEAD: h1, h2, h3, h4, h5, h6 {margin: 0; margin-top: 1.5em; margin-bottom: 0.5em;}
|
4c3588fc |
#+HTML_HEAD:
#+HTML_HEAD: pre {margin: 0; box-shadow: none; border-width: 0.5em;}
#+HTML_HEAD:
#+HTML_HEAD: pre.example {
#+HTML_HEAD: background-color: var(--zenburn-bg-plus-2);
#+HTML_HEAD: color: var(--zenburn-fg-plus-2);
#+HTML_HEAD: border: none;
#+HTML_HEAD: padding-left: 4em;
#+HTML_HEAD: }
#+HTML_HEAD:
#+HTML_HEAD: pre.src {
#+HTML_HEAD: background-color: var(--zenburn-bg-plus-1);
#+HTML_HEAD: border-color: var(--zenburn-bg-plus-2);
#+HTML_HEAD: color: var(--zenburn-fg-plus-1);
#+HTML_HEAD: }
#+HTML_HEAD:
#+HTML_HEAD: pre.src::before {
#+HTML_HEAD: background-color: var(--zenburn-bg-plus-1);
#+HTML_HEAD: border-color: var(--zenburn-bg-plus-2);
#+HTML_HEAD: color: var(--zenburn-fg-plus-1);
#+HTML_HEAD: }
#+HTML_HEAD: </style>
# Local Variables:
|
f9720d09 |
# after-save-hook: (lambda nil (org-babel-tangle) (when (org-html-export-to-html) (rename-file "README.html" "docs/index.html" t)))
|
4c3588fc |
# End:
|