Create a Common Lisp Web app using ningle

Rajasegar Chandran - Aug 2 '22 - - Dev Community

In this post we are going to build a Common Lisp web application using a light-weight framework called ningle.

For building web applications in Common Lisp using a scalable and conventional framework, we can use Caveman, I have written a post about this here.

We are going to build a small demo app made in Common Lisp to get a list of Beers and their details like food pairings from Punk API which is based on BrewDog's DIY Dog API.

Before diving into the topic, let's first have a brief introduction about the different tools and libraries we are going to use to build the app.

ningle

ningle is a lightweight web application framework for Common Lisp created by Eitaro Fukamachi. It is a fork project of Caveman. It doesn't require you to generate a project skeleton.

As ningle is a thin framework, you need to have subtle knowledge about Clack. It is a server interface ningle bases on.

Clack

Clack is a web application environment for Common Lisp inspired by Python's WSGI and Ruby's Rack. Clack provides a script to start a web server. It's useful when you deploy to production environment. You need to install Roswell before as Clack depends on it.

(defvar *handler*
    (clack:clackup
      (lambda (env)
        (declare (ignore env))
        '(200 (:content-type "text/plain") ("Hello, Clack!")))))
Enter fullscreen mode Exit fullscreen mode

Create project

First, create our project folder by creating a new directory and add the relevant source files. In our case we only need one file called app.lisp.

mkdir cl-beers
cd cl-beers
touch app.lisp
Enter fullscreen mode Exit fullscreen mode

Installing dependencies

Second, let's install and load the required dependencies for our project using quicklisp. Let's add the below line at the top of our app.lisp

(ql:quickload '(:ningle :djula :dexador :cl-json))
Enter fullscreen mode Exit fullscreen mode

A normal web application framework need to take care of some minimal things to get it running.

Those are:

  • Template for rendering HTML markup with dynamic content
  • AJAX requests
  • Handle JSON
  • Able to define routing with parameters and various HTTP methods

Let's see how we can implement each of one these in our demo application using some third-party libraries and dependencies with ningle.

Templating with Djula

Djula is a port of Python's Django template engine to Common Lisp. Caveman framework by default uses this template engine for rendering the HTML.

Create a new folder called templates inside our project folder. And we need to setup our template folder with some default layout and the individual template files for our pages or routes.

mkdir templates
cd templates
touch index.html
mkdir layouts
cd layouts
touch default.html
Enter fullscreen mode Exit fullscreen mode

Our default template is the master layout for our app and will have markup something like below:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="UTF-8"/>
    <title>cl-beers</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
  </head>
    <body>
      {% block content %}
      {% endblock %}
    <script src="https://unpkg.com/htmx.org@1.5.0"></script>
    <script src="https://unpkg.com/hyperscript.org@0.8.0"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.min.js" integrity="sha384-cVKIPhGWiC2Al4u+LWgxfKTRIcfu0JTxR+EQDz/bgldoEyl4H0zUF0QKbrJ0EcQF" crossorigin="anonymous"></script>
  </body>
</html>
Enter fullscreen mode Exit fullscreen mode

Now we need to wire up the template engine Djula to our app by telling the system where to find the template files, how to load them and render them.

;; Tell Djula to load the templates from the templates directory
(djula:add-template-directory  #P"templates/")
;; Set up the template registry for Djula
(defparameter *template-registry* (make-hash-table :test 'equal))

;; render template - copied & modified from caveman
(defun render (template-path &optional data)
  (let ((template (gethash template-path *template-registry*)))
    (unless template
      (setf template (djula:compile-template* (princ-to-string template-path)))
      (setf (gethash template-path *template-registry*) template))
    (apply #'djula:render-template* template nil data)))
Enter fullscreen mode Exit fullscreen mode

AJAX requests with dexador

Dexador is a HTTP client for Common Lisp with neat APIs and connection-pooling. It is fast, particularly when requesting to the same host with neat APIs and signal a condition when HTTP request failed.

This is how you use dexador to make GET requests:

(dex:get "https://api.punkapi.com/v2/beers?per_page=24")
Enter fullscreen mode Exit fullscreen mode

JSON parsing with cl-json

We need our app to handle JSON responses from the APIs, and with Lisp there is an awesome library called cl-json for the job. cl-json provides an encoder of Lisp objects to JSON format and a corresponding decoder of JSON data to Lisp objects.

We can use the decode-json-from-string function from cl-json to decode the response from the API.

(let ((beers (cl-json:decode-json-from-string (dex:get "https://api.punkapi.com/v2/beers?per_page=24"))))
Enter fullscreen mode Exit fullscreen mode

Defining routes

Now we move on to defining routes for our app. In ningle we will just update the ningle:route object with the url and a lambda function which takes the parameters for the route as an argument and will handle the route by making some api calls and finally rendering the corresponding template for the route. This is how we define routes in a ningle app.

;; GET /
(setf (ningle:route *app* "/")
      #'(lambda (params)
      (let ((beers (cl-json:decode-json-from-string (dex:get "https://api.punkapi.com/v2/beers?per_page=24"))))
      (render #P"index.html" (list :beers beers)))))
Enter fullscreen mode Exit fullscreen mode

For rendering the templates, we will make use of the render function from Djula which will take the name of the template file and a list object for the template data as arguments.

Running the app

You need roswell to install clack

ros install clack
Enter fullscreen mode Exit fullscreen mode

And then using clackup

clackup app.lisp
Enter fullscreen mode Exit fullscreen mode

Go to http://localhost:5000 in your browser to see the app in action.

demo.gif

Source code

This is the full code for our app.

(ql:quickload '(:ningle :djula :dexador :cl-json))
(djula:add-template-directory  #P"templates/")
(defparameter *template-registry* (make-hash-table :test 'equal))

;; render template - copied & modified from caveman
(defun render (template-path &optional data)
  (let ((template (gethash template-path *template-registry*)))
    (unless template
      (setf template (djula:compile-template* (princ-to-string template-path)))
      (setf (gethash template-path *template-registry*) template))
    (apply #'djula:render-template* template nil data)))

(defvar *app* (make-instance 'ningle:app))

(djula:def-filter :truncate-desc (val)
  (if (> (length val) 100)
      (concatenate 'string (subseq val 0 97) "...")
      val))

(defun increment-page (page)
  (1+ (parse-integer page)))

;; GET /
(setf (ningle:route *app* "/")
      #'(lambda (params)
      (let ((beers (cl-json:decode-json-from-string (dex:get "https://api.punkapi.com/v2/beers?per_page=24"))))

      (render #P"index.html" (list :beers beers)))))

;; GET /more
(setf (ningle:route *app* "/more")
      #'(lambda (params)
      (print params)
      (let* ((page (cdr (assoc "page" params :test #'string=)))
         (beers (cl-json:decode-json-from-string (dex:get (concatenate 'string "https://api.punkapi.com/v2/beers?per_page=24&page=" page)))))

      (render #P"_more-beer.html" (list :beers beers :page (increment-page page))))))

;; GET /beer/:id
(setf (ningle:route *app* "/beer/:id")
      #'(lambda (params)
      (let ((beer (cl-json:decode-json-from-string (dex:get (concatenate 'string "https://api.punkapi.com/v2/beers/" (cdr (assoc :id params)))))))

        (render #P"show.html" (list :beer (car beer))))))

;; GET /random
(setf (ningle:route *app* "/random")
      #'(lambda (params)
      (let ((beer (cl-json:decode-json-from-string (dex:get "https://api.punkapi.com/v2/beers/random" ))))
        (render #P"show.html" (list :beer (car beer))))))

;; GET /glossary
(setf (ningle:route *app* "/glossary")
      (render #P"glossary.html"))

;; POST /search
(setf (ningle:route *app* "/search" :method :POST)
      #'(lambda (params)
          (let* ((query (cdr (assoc "query" params :test #'string=)))
                (beers (cl-json:decode-json-from-string (dex:get (concatenate 'string "https://api.punkapi.com/v2/beers?beer_name=" query)))))

            (render #P"_search-results.html" (list :beers beers)))))

*app*
Enter fullscreen mode Exit fullscreen mode

The source code for this tutorial is hosted here in Github

Hope you have enjoyed the post, please let me know your comments and feedback in the comments section below. I have omitted a lot of implementation details here, since explaining each and every line is not possible within a single blog post. You can take a look at the source code and figure it out yourself.

References

. . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . . .