utweet is a small Twitter clone inspired by Flask's minitwit example.
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
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
which represents a user,
subscription, which represents a user following
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.
NILif none is found.
(&key username full-name email password)
userinstances) that follow
user's tweets, sorted through time.
First, we'll create the
utweet.views package. We'll
:use :lucerne to
import everything and simply export the
(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
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.")))
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 "/"))