Learning Clojure. Part 2
18 May, 2018
This time we are going to learn how to work with clojure data structures, submit forms and use ring middleware.
Destructuring
Destructuring allows us to extract and name the parts of complex data structures.
Clojure breaks it up into two categories:
Sequential destructuring
(def my-vector [0 1 2 3 4 5 6 7 8 9])
(let [[a b c _ d & rest] my-vector]
(println a b c d rest))
;=> 0 1 2 4 (5 6 7 8 9)
Associative destructuring
(def my-obj {:foo "0" :bar "1" :baz "2"})
(let [{a :foo
b :bar
c :baz
d :meow} my-obj]
(println a b c d ))
;=> 0 1 2 nil
If you don't need to rename the keys, you can use the :keys
key
(let [{:keys [foo bar baz]} my-obj]
(println foo bar baz ))
;=> 0 1 2
And you can also provide the default values for some keys with the :or
key
(let [{:keys [foo bar baz meow]
:or {meow 3}} my-obj]
(println foo bar baz meow ))
;=> 0 1 2 3
Now we can destructure the page component options.
Read this guide https://clojure.org/guides/destructuring for more information.
Page component
Let's make the page component generic.
(rum/defc page [opts & children]
(let [page-title "yolk"
{:keys [index?]
:or {index? false}} opts]
[:html
[:head
[:meta {:charset "utf-8"}]
[:title page-title]
[:style { :dangerouslySetInnerHTML {:__html styles}}]]
[:body
[:main.page
[:header.page__header
(if index?
[:h1.page__title page-title]
[:h1.page__title
[:a.page__title-link {:href "/"} page-title]])]
[:section.page__content children]]]]))
and use it for the index page and for the post page
(rum/defc post [post]
[:article
[:header
[:h1 (:title post)]
[:.author (:author post)]]
[:.created (:created post)]
[:p (:body post)]
[:a {:href (str "/post/" (:id post))} "link" ]])
(rum/defc index-page [post-ids]
(page {:index? true }
(for [id post-ids]
(post (get-post id)))))
(rum/defc post-page [post-id]
(page {}
(post (get-post post-id))))
Ring
We're going to use ring library.
A web application developed for Ring consists of four components:
- Handler: functions taking a request map as a param and returning you a response map
- Request: a Clojure map with several properties (e.g.
:uri
,:header
,:body
, etc) - Response: a Clojure map with 3 keys (
:status
,:header
,:body
) - Middleware: higher-level functions that add additional functionality to handler functions
There is a large selection of pre-written middleware in ring and we're going to use some.
Add ring as a dependency in project.clj
[ring/ring-core "1.6.2"]
and import two middleware
[ring.middleware.params :refer [wrap-params]]
[ring.middleware.multipart-params :refer [wrap-multipart-params]]
Read more about ring concepts on the documentation page: https://github.com/ring-clojure/ring/wiki/Concepts
Form Submit
Let's start with making a page for post editing/creation.
Read the post with provided id and if it exists we show its attributes in the form fields, otherwise they will be empty.
To allow uploading files, we set the multipart/form-data
encoding type.
(rum/defc edit-post-page [post-id]
(let [post (get-post post-id)
edit? (some? post)]
(page {}
[:.edit-post
[:form {:action (str "/post/" post-id "/edit")
:enctype "multipart/form-data"
:method "post"}
[:.edit-post__body
[:input
{:value (:title post "")
:name "title"}]
[:textarea
{:value (:body post "")
:name "body"}]]
[:.edit-post__submit
[:button
{:type "submit"} (if edit? "edit" "create")]]]])))
Now when we click "edit"/"create" button we need to handle the request on /post/post-id/edit
route.
To be able to read the form attributes from the request map, we need to wrap the Compojure route with wrap-params
middleware. To read the params of "multipart" form, we need to use wrap-multipart-params
middleware.
(def app
(-> routes
(wrap-params)))
(wrap-multipart-params
(compojure/POST "/post/:post-id/edit" [post-id :as req]
(let [params (:multipart-params req)
body (get params "body")
title (get params "title")]
(save-post! {:id post-id
:author "sasha"
:title title
:body body
:created (java.util.Date.)}))
{:status 302
:headers { "Location" (str "/post/" post-id)}}))
And define save-post!
function which will create a new directory with post data or edit the existent one.
(defn save-post! [post]
(let [dir (io/file (str "posts/" (:id post)))]
(.mkdir dir)
(spit (io/file dir "post.edn") (pr-str post))))
Create a new post
First, let's change the way we read the post ids (they are hardcoded now) and read them from the fs. Now when a new post created, we don't need to update the list of ids.
(defn post-ids []
(let [all-files (seq (.list (io/file "posts")))
dirs (filter #(.isDirectory (io/file "posts" %)) all-files)]
(sort dirs)))
For actual post creation all we need to do is redirect a person to /post/post-id/edit
route.
(compojure/GET "/post/new" []
{:status 303
:headers {"Location"
(str "/post/" "replace-me-with-id" "/edit")}})
At the moment you have to update URL for setting a proper post id. In the future we need to generate a random string as id.
File upload
For now let's say, we want to be able to upload only one image file per post.
To serve a static file, ring
provides with ring.util.response/file-response
function.
Define a new route
(compojure/GET "/post/:post-id/:img" [post-id img]
(ring.util.response/file-response (str "posts/" post-id "/" img)))
Add a new input with type=file
to the form
[:input {:type "file"
:name "picture"
:multiple false}]
Pass picture
from the request map to save-post!
function as a second argument
(let [params (:multipart-params req)
body (get params "body")
title (get params "title")
picture (get params "picture")]
(save-post! {:id post-id
:author "sasha"
:title title
:body body}
picture))
Now the most interesting part: saving a picture.
Let's have a look at the picture
data structure
{:filename filename.png,
:content-type image/png,
:tempfile #object[java.io.File 0x2710edd4 /var/folders/zl/d_bl_85x2g15vgnhztzlb6pr0000gn/T/ring-multipart-5478219630474133336.tmp],
:size 354730}
Okay, we should copy the tempfile into the post directory with a proper filename and then delete the tempfile.
(io/copy (:tempfile picture) (io/file dir (:filename picture)))
(.delete (:tempfile picture))
Also, update the post so it has a picture filename
[let new-post (assoc post
:picture (:filename picture))]
And now we can render the picture on a post page
(if (:picture post)
[:img {:src (str "/post/" (:id post) "/" (:picture post))}])
If you noticed incorrect rendering of utf-8 symbols on the picture above, stay tuned and we will fix it in the next part :)