Skip to content
Snippets Groups Projects
user avatar
João Távora authored
2e0da37a
History

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, GETting 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.