Snooze
Snooze is a framework for building REST web services in Common Lisp.
Here's a small sample
(defpackage :snooze-symbols)
(in-package :snooze-symbols)
(snooze:defroute probe-symbol (:get "text/plain" symbol-name &key (package :cl))
(if (and (find-package (string-upcase package))
(find-symbol (string-upcase symbol-name)
(string-upcase package)))
(format nil "Hello world, package ~a has the symbol ~a" package symbol-name)
(snooze:http-error 404 "Sorry, no such symbol")))
(snooze:start (make-instance 'snooze:snooze-server :port 9003
:route-packages '(:snooze-symbols)))
You can now navigate to:
http://localhost:9003/probe-symbol/defun
http://localhost:9003/probe-symbol/defroute?package=snooze
Rationale
Snooze sees the operations of Representation State Transfer (REST) as really only function calls on resources.
It maps REST concepts like resources, HTTP verbs, content-types and URI queries to Common Lisp concepts like generic functions, lambda-lists and and argument specializers.
So, for example, GET
ting the list of the Beatles in JSON format by
order of most guitars owned is asking for the URI
/beatles?sort-by=number_of_guitars
, which matches the following
function:
(snooze:defroute beatles (:get "application/json" &key (sort-by #'age))
(jsonify (sort #'> (all-the-beatles) :key sort-by)))
Content-type matching is automatically taken care of: only
application/json
-accepting requests are accepted by this
snippet. Argument parsing in the URI, including ?param=value
and
#fragment
bits is also automatically handled (but can be customized
if you don't like the default).
Because it's all done with CLOS, every route is a method, so you can.
-
cl:trace
it like a regular function - find its definition with
M-.
- reuse other methods using
call-next-method
- use
:after
,:before
and:around
qualifiers - delete the route by deleting the method
Snooze's only current backend implementation is based on the great Hunchentoot, but I'd welcome pull requests that make it plug into other web server.
Try it out
This assumes you're using a recent version of quicklisp
(push "path/to/snoozes/parent/dir" quicklisp:*local-project-directories*)
(ql:quickload :snooze)
or alternatively, just use asdf
(push "path/to/snoozes/dir" asdf:*central-registry*)
(asdf:require-system :snooze)
now create some Lisp file with
(defpackage :snooze-demo (:use :cl))
(in-package :snooze-demo)
(defvar *todo-counter* 0)
(defclass todo ()
((id :initform (incf *todo-counter*) :accessor todo-id)
(task :initarg :task :accessor todo-task)
(done :initform nil :initarg :done :accessor todo-done)))
(defparameter *todos*
(list (make-instance 'todo :task "Wash dishes")
(make-instance 'todo :task "Scrub floor")
(make-instance 'todo :task "Doze off" :done t)))
(defun find-todo-or-lose (id)
(or (find id *todos* :key #'todo-id)
(error 'snooze:404)))
(snooze:defroute todo (:get "text/plain" id)
(let ((todo (find-todo-or-lose id)))
(format nil "~a: ~a ~a" (todo-id todo) (todo-task todo)
(if (todo-done todo) "DONE" "TODO"))))
(snooze:defroute todos (:get "text/plain")
(format nil "~{~a~^~%~}" (mapcar #'todo-task *todos*)))
(snooze:start (make-instance 'snooze:snooze-server
:port 4242 :route-packages '(:snooze-demo)))
And connect to "http://localhost:4242/todos" to see a list of
todo
's.
CLOS-based tricks
Routes are really only CLOS methods: defroute
little more than
defmethod
. For example, to remove particular route just delete the
particular method.
Here's the method that updates a todo
's data using a PUT request
(snooze:defroute todo (:put (payload "text/plain") id)
(let ((todo (find-todo-or-lose id)))
(setf (todo-task todo) (snooze:request-body))))
To make the route accept JSON content:
(snooze:defroute todo (:put (payload "application/json") id)
(let ((todo (find-todo-or-lose id)))
(setf (todo-task todo) (snooze:request-body))))
It could also be just
(snooze:defroute todo (:put payload id)...)
To have the route accept any kind of content, or even
(snooze:defroute todo (:put (payload "text/*") id)...)
To accept only text-based content. call-next-method
works as
normally so you can reuse behaviour between routes. If a route fails
to match (because the client's Accepts:
header was too restrictive
or because its Content-type:
is unsupported by the server), no
routes are applicable and the client gets a 404.
More tricks
Another trick is to coalesce all the defroute
definitions into a
single defresource
definitions, much like defmethod
can be in a
defgeneric
:
(snooze:defresource todo (verb content-type id)
(:genurl todo-url)
(:route (:get "text/*" id)
(todo-task (find-todo-or-lose id)))
(:route (:get "text/html" id)
(format nil "<b>~a</b>" (call-next-method)))
(:route (:get "application/json" id)
;; you should use some json-encoding package here, tho
(let ((todo (find-todo-or-lose id)))
(format nil "{id:~a,task:~a,done:~a}"
(todo-id todo) (todo-task todo) (todo-done todo)))))
Using defresource
gives you another bonus, in the form of an
URL-generating function for free, in this case todo-url
, to use in
your view code:
SNOOZE-DEMO> (todo-url 3)
"todo/3"
SNOOZE-DEMO> (todo-url 3 :protocol "https" :host "localhost")
"https://localhost/todo/3"
What happens if I add &optional or &key?
That's a very good question, and thanks for asking 😁. They're allowed, of course, and their values deduced from URI parameters.
Confusing? Consider a route that lets you filter the todo
items:
(snooze:defresource todos (verb content-type &key from to substring)
(:genurl todos-url)
(:route (:get "text/plain" &key from to substring)
(format
nil "~{~a~^~%~}"
(mapcan #'todo-task
(remove-if-not (lambda (todo)
(and (or (not from)
(> (todo-id todo) from))
(or (not to)
(< (todo-id todo) to))
(or (not substring)
(search substring
(todo-task todo)))))
*todos*)))))
The function that you get for free is now todos-url
and does this:
SNOOZE-DEMO> (todos-url :from 3 :to 6 :substring "d")
"todos/?from=3&to=6&substring=d"
SNOOZE-DEMO> (todos-url :from 3)
"todos/?from=3"
SNOOZE-DEMO> (todos-url)
"todos/"
Controlling errors
TODO...
Support
To discuss matters open an issue for now or perhaps ask in the #lisp IRC channel.