Lucerne ยป Example: A Twitter Clone

utweet is a small Twitter clone inspired by Flask's minitwit example.

The Models

We'll make a package, utweet.models, specifically for the models (users, tweets, et cetera). We'll build an abstract interface that could be implemented over an SQL database, a document DB, or, in the case of this example, a simple in-memory storage.

First, we create a models.lisp and add the system definition:

(in-package :cl-user)
(defpackage utweet.models
  (:use :cl)
  ;; Users
  (:export :user
           :user-username
           :user-full-name
           :user-password
           :user-avatar-url)
  ;; Subscriptions (follows)
  (:export :subscription
           :subscription-follower
           :subscription-followed)
  ;; Tweets
  (:export :tweet
           :tweet-author
           :tweet-text
           :tweet-timestamp)
  ;; Some functions
  (:export :find-user
           :register-user
           :followers
           :following
           :tweet
           :user-timeline
           :user-tweets
           :follow))
(in-package :utweet.models)

The actual class definitions are fairly straightforward: We define user, which represents a user, subscription, which represents a user following another, and tweet, which is a single tweet.

(defclass user ()
  ((username :accessor user-username
             :initarg :username
             :type string)
   (full-name :accessor user-full-name
              :initarg :full-name
              :type string)
   (email :accessor user-email
          :initarg :email
          :type string)
   (password :accessor user-password
             :initarg :password
             :type string)
   (avatar-url :accessor user-avatar-url
               :initarg :avatar-url
               :type string))
  (:documentation "A user."))

(defclass subscription ()
  ((follower :reader subscription-follower
             :initarg :follower
             :type string
             :documentation "The follower's username.")
   (followed :reader subscription-followed
             :initarg :followed
             :type string
             :documentation "The followed's username."))
  (:documentation "Represents a user following another."))

(defclass tweet ()
  ((author :reader tweet-author
           :initarg :author
           :type string
           :documentation "The author's username.")
   (text :reader tweet-text
         :initarg :text
         :type string)
   (timestamp :reader tweet-timestamp
              :initarg :timestamp
              :initform (local-time:now)))
  (:documentation "A tweet."))

Now, we won't discuss the actual implementation of the functions. Those are availble in the source code. We'll just present the function documentation which describes the interface.

find-user(username)
Find a user by username, returns NIL if none is found.
register-user(&key username full-name email password)
Create a new user and hash their password.
followers(user)
List of users (user instances) that follow user.
following(user)
List of users (user instances) the user follows.
tweet(author text)
Create a new tweet from author containing text.
user-timeline(user)
Find the tweets for this user's timeline.
user-tweets(user)
Return a user's tweets, sorted through time.
follow(follower followed)
Follow a user. Takes two user instances: follower and followed.

The Views

First, we'll create the utweet.views package. We'll :use :lucerne to import everything and simply export the app.

(in-package :cl-user)
(defpackage utweet.views
  (:use :cl :lucerne)
  (:export :app))
(in-package :utweet.views)
(annot:enable-annot-syntax)

That last line is important, it allows us to use the reader macros Lucerne uses for routing.

Now, we define the application. We use the session middleware, since we'll need it for authentication, and also the static files middleware: This takes every request that starts with /static/ and finds the corresponding file in the examples/utweet/static/ folder inside the Lucerne source.

(defapp app
  :middlewares (clack.middleware.session:<clack-middleware-session>
                (clack.middleware.static:<clack-middleware-static>
                 :path "/static/"
                 :root (asdf:system-relative-pathname :lucerne-utweet
                                                      #p"examples/utweet/static/"))))

Now we add some Djula templates for the different pages:

(djula:add-template-directory
 (asdf:system-relative-pathname :lucerne-utweet #p"examples/utweet/templates/"))

(defparameter +timeline+ (djula:compile-template* "timeline.html"))

(defparameter +index+ (djula:compile-template* "index.html"))

(defparameter +profile+ (djula:compile-template* "profile.html"))

(defparameter +user-list+ (djula:compile-template* "user-list.html"))

Next up, a couple of utility functions: current-user finds the user model that corresponds to the username stored in Lucerne's session data. display-tweets is a function to make templating easier: It goes through a list of tweets, and creates a plist that has the tweet object as well as the author object (instead of referencing the author through its username).

(defun current-user ()
  "Find the user from request data."
  (let ((username (lucerne-auth:get-userid)))
    (when username
      (utweet.models:find-user username))))

(defun display-tweets (tweets)
  "Go through a list of tweets, and create a list of plists with data from the
tweet and its author."
  (loop for tweet in tweets collecting
    (list :author (utweet.models:find-user (utweet.models:tweet-author tweet))
          :text (utweet.models:tweet-text tweet))))

The index view is very simple: If the user is logged in, find the user object, and display their timeline. If the user is not logged in, display the landing page.

@route app "/"
(defview index ()
  (if (lucerne-auth:logged-in-p)
      ;; Serve the user's timeline
      (let* ((user (current-user)))
        (render-template (+timeline+)
                         :username (utweet.models:user-username user)
                         :name (utweet.models:user-full-name user)
                         :tweets (display-tweets (utweet.models:user-timeline user))))
      (render-template (+index+))))

When visiting a user's profile, we find that user by name, get a list of their tweets, and render the profile page template. We additionally ask whether the user is the logged-in user: This lets us know whether we should display buttons to follow unfollow the user.

@route app "/profile/:username"
(defview profile (username)
  (let* ((user (utweet.models:find-user username))
         ;; The user's timeline
         (user-tweets (utweet.models:user-tweets user))
         ;; Is the user viewing his own profile?
         (is-self (string= (lucerne-auth:get-userid)
                           username)))
    (render-template (+profile+)
                     :user user
                     :tweets (display-tweets user-tweets)
                     :is-self is-self)))

These next views are quite simple, utweet.models does most of our work.

@route app "/followers/:username"
(defview user-followers (username)
  (let ((user (utweet.models:find-user username)))
    (render-template (+user-list+)
                     :user user
                     :title "Followers"
                     :users (utweet.models:followers user))))

@route app "/following/:username"
(defview user-following (username)
  (let ((user (utweet.models:find-user username)))
    (render-template (+user-list+)
                     :user user
                     :title "Following"
                     :users (utweet.models:following user))))

And, finally, the core of the app: Tweeting something. If the user's not logged in, we give them an error, otherwise, we create the tweet and redirect them to the home page.

@route app (:post "/tweet")
(defview tweet ()
  (if (lucerne-auth:logged-in-p)
      (let ((user (current-user)))
        (with-params (tweet)
          (utweet.models:tweet user tweet))
        (redirect "/"))
      (render-template (+index+)
                       :error "You are not logged in.")))

Authentication

Here we implement all the authentication views. We'll use the cl-pass library so we don't have to concern ourselves with security needs.

The signup view is the most complex: We have to check if a user with that name exists and that the supplied passwords match. If both check out, we create the user and redirect them to the home page.

@route app (:post "/signup")
(defview sign-up ()
  (with-params (name username email password password-repeat)
    ;; Does a user with that name exist?
    (if (utweet.models:find-user username)
        ;; If it does, render the landing template with a corresponding error
        (render-template (+index+)
                         :error "A user with that name already exists.")
        ;; We have a new user. Do both passwords match?
        (if (string= password password-repeat)
            ;; Okay, the passwords are a match. Let's create the user and return
            ;; the user to the homepage
            (progn
              (utweet.models:register-user :username username
                                           :full-name name
                                           :email email
                                           :password password)
              (redirect "/"))
            ;; The passwords don't match
            (render-template (+index+)
                             :error "Passwords don't match.")))))

To sign in, we both check whether a username by that name exists and if the password is a match. If so, we log them in and redirect them to their timeline.

@route app (:post "/signin")
(defview sign-in ()
  (with-params (username password)
    ;; Check whether a user with this name exists
    (let ((user (utweet.models:find-user username)))
      (if user
          (if (cl-pass:check-password password
                                      (utweet.models:user-password user))
              (progn
                ;; Log the user in
                (lucerne-auth:login username)
                (redirect "/"))
              ;; Wrong password
              (render-template (+index+)
                               :error "Wrong password."))
          ;; No such user
          (render-template (+index+)
                           :error "No such user.")))))

Signing out is simpler: If the user is logged in, sign them out. Otherwise, do nothing. Then redirect them to the home.

@route app "/signout"
(defview sign-out ()
  (when (lucerne-auth:logged-in-p)
    (lucerne-auth:logout))
  (redirect "/"))