About 1 week ago I wrote a small Reddit Clone in about 90 lines of Clojure. The amount interest and feedback was unexpectedly high so I've decided to extend the example to a whopping 160 lines as well as echo some of the chatter.
My original Reddit Clone was a reaction to a Common Lisp program which did very much the same thing. The Clojure version added some extra deployment goodness in that in compiled to a cross-platform jar which would launch on any Java supportive OS. Following my blogpost many more or less interesting versions popped up across the web, these caught my eye:
This guy, actually sat down and wrote out a Reddit Clone in PHP, which I thought was amazing - If PHP could do the same as Clojure in only 6 extra lines I would be greatly impressed - However it lacked the almost every feature except rendering links.
Wow - Half the time of PHP and only 4 lines! See the code here: Gist
Unfortunately it also lacks every single feature of the PHP version and when you unpack it, it weighs in at about 80 lines which you can read: Here
This is by far one of the funniest contributions to this Reddit Clone War - QBasic emitted via CGI-BIN. Despite weighing in at 250 lines, you will probably enjoy the read: here
The reason I these, is that I think its a lot of fun to see a project like this performed in various languages, but if we're to learn something from each other we should aim to offer the same features - So while you guys are catching up, I'll go ahead and implement user management, as in login/logout, registration etc. :) ps: Can we see some Scala and Haskell versions soon?
Since we're quickly adding pages we should consider extracting as much code as possible into wrapper function. As the code-base itself increases would also make a lot of sense to separate logic from views into their own files and namespaces, but because this is purely for demonstrative purposes of Clojure/Compojure I'll stick to a single file.
We can store our data in any way shape or form we want, but I'll stick with the approach of my last post, ie. keeping all the data in memory. Because we run the server (unlike something like PHP) data, functions, threads etc live between the requests.
(def data (ref {"http://www.bestinclass.dk" {:title "Best in Class" :points 1 :date (DateTime.) :poster "LauJensen"}})) (def users (ref {"lau.jensen@bestinclass.dk" {:username "LauJensen" :password "way2secret"}})) (def online-users (ref {}))
The data struct is similar to what I used in the first post, but I've added a new keyword :poster, to keep track of who is posting what. The users list would normally be put in a database as it simply contains a list of all registered users. The final list is a hashmap of all logged in users, instead of storing the login-information in cookies with the client, we'll keep tabs on who is online via the Jetty server.
Why refs? Well, with the current complexity (or lack of), we might actually be able to pull through just using atoms but in a near future we might need coordinated change, hence the STM. Whichever we pick, Clojures language level concurrency support makes it a breeze to handle concurrent users.
For those of you who read the Beating The Arc Challenge post, you might be wondering why I'm tracking the session in my own datastructure instead of using the built-in session/assoc/dissoc functions of Compojure and the simple reason is that I might as well show off both approaches.
In order to get to Jetty's session information, we need to active Compojures Session Middleware, so our old main function becomes:
(defn -main [& args] (run-server {:port 8080} "/*" (->> reddit with-session servlet)))
Our routes are passed to the middle-ware and the modified routes are then passed to servlet. Now all of the routes can make use of the 'session' variable which contains the Jetty ID - A unique ID which we can use to track users across the site.
To log in is simple: We see if the supplied email is live in the system, if it is then we check if the password is a match and if thats the case then we associate the user-details from users with the Jetty ID:
(defn login-user [session [email password]] (redirect-to (if-let [user (@users email)] (if (= password (:password user)) (dosync (alter online-users assoc (:id session) user) "/") "/login/?msg=Bad username/password combo") "/login/?msg=User does not exist")))
Again the hash-maps is making access very simple, so to log out we simple need to disassociate that Jetty ID from the online-users list. The argument might look a little weird to you, but thats a clever way to destructure the session variable - Any hashmap can be broken down into named keys by calling {:keys [k1 k2 k2]}, like so:
(defn logout-user [{:keys [id]}] (dosync (alter online-users dissoc id)) (redirect-to "/"))
In a very short space we've now written our own backend session handling, so all thats left is given users a way into the system. First I would like to avoid always writing out the same head/css/js includes, so we'll make a wrapper. Basically all it has to do is write out head-tag with a user supplied title, and then add a login button if the user isn't logged in. If the user is logged in, we want to see the username instead:
(defn with-head [session title & body] (html [:head [:title title] (include-css "/styles/reddit.css")] [:body (if-let [user (@online-users (:id session))] [:div#user (:username user) (link-to "/logout/" "(Log out)")] [:div#user (link-to "/login/" "(Log in)")]) body]))
So with that out of the way, the login form is simply
(defn login-form [session msg] (with-head session "Reddit.Clojure - Login screen" [:h1 "Login"] (when msg [:h4 msg]) (form-to [:post "/login/"] [:table [:tr [:td "email"] [:td (text-field "email") ]] [:tr [:td "password"][:td (password-field "psw") ]]] (submit-button "Login"))))
Giving you:

When you hit the submit button, this will POST to the backend functions below, resulting in either an error message or the front-page:

So now users who are in the system can authenticate and submit links which are joined to their usernames. The logical next step is to allow newcomers to register. I would really enjoy doing some kind of fusion between Captcha and an IQ test, but thats probably best left for a separate blogpost.
Starting with the backend, we need to make some kind of fall-through input validation like you saw in the last post and I'll leave it up to you and your imagination to put stuff in there that makes sense, but if all the input is good then we need to check if the email is live in the system and if so throw an error. If its not live then a user should be created both in users and in online-users joining the latter to the Jetty ID:
(defn add-user [session-id [email user password]] (redirect-to (cond (invalid-email? email) "/register/?msg=Invalid email" :else (dosync (if (@users email) "/register/?msg=Email already registered" (do (alter users assoc email {:username user :password password}) (alter online-users assoc session-id (@users email)) "/"))))))
With the backend ready for customers, we just need a simple front-end registration form. This isn't optimal but I just wanted to show off how make life a little simpler with a small for-loop:
(defn registration-form [session msg] (with-head session "Reddit.Clojure - Registration form" [:h1 "Registration"] (when msg [:h4 msg]) (form-to [:post "/register/"] [:table (for [field ["Email" "Username" "Password"]] [:tr [:td field] [:td (text-field field)]])] (submit-button "Sign up"))))
Instead of adding a specific link to this functionality, I'll bundle it directly with the route for submitting links, ie. if you're not logged in you either should, or you should register:
(GET "/new/*" (if (@online-users (:id session)) (reddit-new-link session (:msg params)) (redirect-to "/register/")))
So if you're not logged in, submitting a link and subsequently trying to use my email will give you this:

Like I mentioned earlier, the key to Clojure happiness is structure, so if you're considering booting up a Compojure project split the codebase into logical units, this makes for much faster development later as the project grows. Depending on your network interface, consider where you launch the clone. This morning I checked my referrers list and found an IP calling from port :8080, I figured someone was playing with Compojure so I followed the link - only to find myself looking at my own Reddit Clone launched from somewhere in the US - Cooool :)
Code still: here