This is the continuation of a tutorial showing how a blog can easily be implemented in Common Lisp. This is part 2 of the tutorial, part 1 should be read first.
Here we expand the code from the first part of the tutorial with functionality for showing each blog post on a separate page, and an interface for editing them. For your convenience, the source code of the files created in this tutorial can be downloaded (note that this is the final code for this part of the tutorial, and that it goes through many steps).
If you at any point get some unexplainable internal server error
when testing the web server, you can try enabling logging to file
by writing (setf (hunchentoot:log-file)
"/tmp/error.log")
. For more info, see
the Hunchentoot
documentation.
Update: Part 3 of this tutorial is now available.
First off, to be able to show each blog post on a separate page,
we will need to be able to uniquely identify each individual blog
post. In our current model there is no attribute in
the blog-post
class that we can use for this.
It is common in many blogs to generate a unique URL from the title of the blog post, and we can use that to uniquely identify the post in our code as well. First, we make a function to generate a URL part from a title. Allowed characters are a - z, any digits, and dashes. Spaces will be replaced with dashes before filtering, and all other characters removed.
(defun make-url-part (title) "Generate a url-part from a title. A url-part should only have alphanumeric characters or dashes (in place of spaces)." (string-downcase (delete-if #'(lambda (x) (not (or (alphanumericp x) (char= #\- x)))) (substitute #\- #\Space title)))) |
Now we can add a url-part attribute to our model, and we also need to make sure that Elephant indexes our model so that we can create queries that fetch instances of this class from the store.
(defpclass blog-post () ((title :initarg :title :accessor title) (body :initarg :body :accessor body) (timestamp :initarg :timestamp :accessor timestamp :initform (get-universal-time) :index t) (url-part :initarg :url-part :accessor url-part :initform nil :index t))) |
As you can see, we have defined a new attribute, and it is
initialized to nil
if it is not specified when the
blog post is created. Since it is generated from the title, we can
generate it automatically when the blog post is created, by
writing a method that runs after initialize-instance
,
which is run by make-instance
. For more details on
object orientation in Common Lisp,
see Practical
Common Lisp, Chapter 17.
(defmethod initialize-instance :after ((obj blog-post) &key) "If :url-part wasn't non-nil when making the instance, generate it automatically." (cond ((eq nil (url-part obj)) (setf (url-part obj) (make-url-part (title obj)))))) |
Unfortunately, previously created posts are not automatically
added to indexes, so we would have to do that manually. Since we
have only created two posts so far, the easiest thing is to simply
re-create them. Adding them to the right indexes is left as an
exercise to the reader. Simply run the calls
to make-instance
from part 1 of the tutorial.
MY-BLOG> (make-instance 'blog-post :title "Hello blog world" :body "First post!") #<BLOG-POST oid:29> MY-BLOG> (make-instance 'blog-post :title "This is fun" :body "Common Lisp is easy!") #<BLOG-POST oid:30> MY-BLOG>
Because they are indexed, we no longer need to maintain a list of
posts separatly, so we can get rid of
the *blog-posts*
variable. With indexing turned on we
can query the Elephant store directly instead.
MY-BLOG> (drop-pobject *blog-posts*) ; Remove the list of blog posts from the store NIL
We of course remove *blog-posts*
from our source code
as well. Next we will have to rewrite the code that used that
variable to query the store instead. This can be done with
functions such
as get-instances-by-class
, get-instances-by-value
and get-instances-by-range
(see
the Elephant
API documentation for more information). To get a list of
blog posts ordered by timestamp, we will use the latter function.
The arguments to get-instances-by-range
are the class
we want to get instances of, the attribute we want to sort on, and
two values indicating a range. If the last two parameters are
both nil
, all instances will be returned, ordered by
the ordering specified by the index.
In generate-index-page
we can now
replace (pset-list *blog-posts*)
with (nreverse
(get-instances-by-range 'blog-post 'timestamp nil nil))
(reverse to list posts by newest first).
(defun generate-index-page () "Generate the index page showing all the blog posts." (with-output-to-string (stream) (html-template:fill-and-print-template #P"index.tmpl" (list :blog-posts (loop for blog-post in (nreverse (get-instances-by-range 'blog-post 'timestamp nil nil)) collect (list :title (title blog-post) :body (body blog-post)))) :stream stream))) |
With all this in place, we can then create the functionality for
showing blog posts in their own pages. First, let's create a
template, post.tmpl
,
that we will use for this.
<!DOCTYPE html> <html> <head> <title><!-- tmpl_var title --> - My Blog</title> <style type="text/css"> body { margin-left: 10%; margin-right: 10%; background: white; color: black; } h1 { font-variant: small-caps; border-bottom: 2px solid black; color: darkblue; } h2 { font-variant: small-caps; color: darkblue; } </style> </head> <body> <h1>* <!-- tmpl_var title --></h1> <div> <!-- tmpl_var body --> </div> </body> </html>
Next, we will create a
function generate-blog-post-page
that accepts a
url-part uniquely identifying the blog post (as discussed above),
and generates the HTML based on the template and the blog post
object.
(defun generate-blog-post-page (url-part) (with-output-to-string (stream) ; Create a stream that will give us a sting (let ((blog-post (get-instance-by-value 'blog-post 'url-part url-part))) ; Get the blog post we're interested in (html-template:fill-and-print-template #P"post.tmpl" (list :title (title blog-post) :body (body blog-post)) :stream stream)))) |
Let's test it!
MY-BLOG> (generate-blog-post-page "another-blog-post") WARNING: New template printer for #P"/Users/vetler/Documents/devel/cl-webapp-intro/source/post.tmpl" created "<!DOCTYPE html> <html> <head> <title>Another blog post - My Blog</title> <link rel=\"stylesheet\" type=\"text/css\" href=\"style.css\"/> </head> <body> <h1>Another blog post</h1> <div> Common Lisp isn't so hard </div> </body> </html> " MY-BLOG>
Now we need to make the web server call this function when it gets a request for a blog post page. We can do that by modifying the dispatch table, and creating a regex dispatcher that sends all requests matching a particular regular expression to our function.
We will also have to change the previously defined dispatcher to only match the single slash. If we kept it as it is now, any URL would show the index page.
(setq hunchentoot:*dispatch-table* (list (hunchentoot:create-regex-dispatcher "^/$" 'generate-index-page) (hunchentoot:create-regex-dispatcher "^/view/$" 'generate-blog-post-page))) |
This will result in generate-blog-post-page
being
called if we go to http://localhost:8080/view/
. The
blog post to be viewed will be specified in the query string,
i.e. http://localhost:8080/view/?hello-blog-world
.
Unfortunately we will have to
change generate-blog-post-page
to read the query string
before fetching the page from the store. We will simply wrap the
existing function body with a let
macro.
(defun generate-blog-post-page () (let ((url-part (hunchentoot:query-string))) (with-output-to-string (stream) ; Create a stream that will give us a sting (let ((blog-post (get-instance-by-value 'blog-post 'url-part url-part))) ; Get the right blog post (html-template:fill-and-print-template #P"post.tmpl" (list :title (title blog-post) :body (body blog-post)) :stream stream))))) |
The blog posts are now available on separate URLs, for instance http://localhost:8080/view/?hello-blog-world [screenshot].
The functionality implemented above is of course of no use if no
one can find our pages, so we will add links from the front
page. First, the function that generates the index page will have
to be changed to also send the url-part
attribute to
the template. We simply add url-part
to the list of
parameters sent to the template
in generate-blog-post-page
.
(list :title (title blog-post) :body (body blog-post) :url-part (url-part blog-post)) |
Next, the template will be changed so that the blog post title is a link to the blog post page.
<h2>* <a href="/view/?<!-- tmpl_var url-part -->"><!-- tmpl_var title --></a></h2>
The front page now both lists the blog posts and links to the separate pages [screenshot].
Finally, we will create a page for editing blog posts, and functionality for saving edits. First, we create a template with a form for editing the blog posts.
<!DOCTYPE html> <html> <head> <title><!-- tmpl_var title --> - My Blog</title> <style type="text/css"> body { margin-left: 10%; margin-right: 10%; background: white; color: black; } h1 { font-variant: small-caps; border-bottom: 2px solid black; color: darkblue; } h2 { font-variant: small-caps; color: darkblue; } textarea { width: 30em; height: 20em;" } </style> </head> <body> <h1>Edit blog post</h1> <form action="?<!-- tmpl_var url-part -->" method="POST"> <h2>Title</h2> <input style="width: 20em;" type="text" name="title" value="<!-- tmpl_var title -->"/> <h2>Body</h2> <textarea name="body"><!-- tmpl_var body --></textarea> <input style="display: block" type="submit"/> </form> </body> </html>
Next, we must make the web server serve this page. We happen to
have a function that does a lot of what we need to do - fetch the
right blog post instance and
call fill-and-print-template
. The function we use for
generating blog posts pages, generate-blog-post-page
,
does exactly this, but the template name is hardcoded into the
function.
To avoid duplicating the function, we can make it take the template filename as a parameter, but if we do that then we will have to create some additional functions that the web server can use to generate the right page with the right template.
(defun generate-blog-post-page (template) ...) ; As before, except template filename replaced with the function parameter (defun view-blog-post-page () "Generate a page for viewing a blog post." (generate-blog-post-page #P"post.tmpl")) (defun edit-blog-post () "Generate a page for editing a blog post." (generate-blog-post-page #P"post-edit.tmpl")) ; Update the dispatch table (setq hunchentoot:*dispatch-table* (list (hunchentoot:create-regex-dispatcher "^/$" 'generate-index-page) (hunchentoot:create-regex-dispatcher "^/view/$" 'view-blog-post-page) (hunchentoot:create-regex-dispatcher "^/edit/$" 'edit-blog-post))) |
Now http://localhost:8080/edit/?hello-blog-world [screenshot] will show a form for editing the blog post.
Finally we will need to handle what happens when the user submits
the form. The current form will submit its data to the same URL
that it uses for generating the blog post, but use a POST request
instead of GET. First, let's change edit-blog-post
to
handle differently if the request is a POST request.
(defun edit-blog-post () (cond ((eq (hunchentoot:request-method) :GET) (generate-blog-post-page #P"post-edit.tmpl")) ((eq (hunchentoot:request-method) :POST) (save-blog-post)))) |
Then we just have to define save-blog-post
.
(defun save-blog-post () "Read POST data and modify blog post." (let ((blog-post (get-instance-by-value 'blog-post 'url-part (hunchentoot:query-string)))) (setf (title blog-post) (hunchentoot:post-parameter "title")) (setf (body blog-post) (hunchentoot:post-parameter "body")) (setf (url-part blog-post) (make-url-part (title blog-post))) (hunchentoot:redirect (url-part blog-post)))) |
This function first gets the blog post we're editing from the
store, using the old url-part
from the query
string. Then it is just a matter of getting the post parameters and
setting the attributes in the blog post instance. Finally a
new url-part
is generated and the user redirected to
the newly edited blog post.
The basic example from the first part has now been extended with a little more functionality, but there is still a lot missing and it has a lot of flaws to be a proper web application. However, this should be enough to give you an overview of the basics and get you started.
Stay tuned for part 3!