Part6: TODO アプリを組み上げる

TODO アプリとして足りない機能を作る

これまでのところでは基本的に「 TODO の表示」部分だけに絞って実装してきましたが、これを TODO アプリと呼ぶのは少々厳しいでしょう。なので、この Part では TODO アプリとして必要そうな機能を追加していきましょう。足りない機能は次のようになると思います。

  • TODO の追加
  • TODO の更新
  • TODO の削除
  • TODO の詳細表示

このくらいは最低でも欲しいところですよね。他にもユーザー管理などという機能も追加したいところですが、それらは後の Part に譲るとして今回は上述した機能を作りこんでいきたいと思います。

TODO の追加画面を作る

今までも TODO の追加は REPL を使えば TODO の追加は出来ました。ですが、 REPL だけからしか追加出来ないというのは少々不便なのでそれようの画面を作りましょう。ここで実装するのはハンドラーとビューの部分になりますが、先にビューを作ります。

;; src/todo_clj/view/todo.clj
(ns todo-clj.view.todo
  (:require [hiccup.form :as hf] ;; hiccup.form を追加
            [todo-clj.view.layout :as layout]))

(defn todo-new-view [req]
  (->> [:section.card
        [:h2 "TODO 追加"]
        (hf/form-to
         [:post "/todo/new"]
         [:input {:name :title :placeholder "TODO を入力してください"}]
         [:button.bg-blue "追加する"])]
       (layout/common req)))

新しく hiccup.form ネームスペースを todo-clj.view.todorequire しました。今回は hiccup.form/form-to のみを使用することにします。この関数はフォームを簡単に定義できるようになっていて、第一引数として渡している [:post "/todo/new"] のひとつめの要素がメソッド、ふたつめの要素がアクションとなっていて、残りは通常通り Hiccup の記法で書かれたデータを受け取ります。第一引数の手前にマップを指定することでフォームの属性を追加していすることが出来ますが、ここでは必要無いので省略します。

次にこの画面を表示するためにハンドラー todo-clj.handler.todo/todo-new を修正しましょう。 Part3 でルーティングの定義をしていたのでそこに肉付けしていく形をとります。

;; src/todo_clj/handler/todo.clj
(defn todo-new [req]
  (-> (view/todo-new-view req)
      res/response
      res/html))

ほとんど todo-index と同じですね。早速 http://localhost:3000/todo/new をブラウザで確認してみましょう。 TODO 追加画面が確認できましたね。これで適当な値を入力してボタンを押すと "TODO new post" と書かれた画面に飛んでしまいますが、これはまだ POST の処理を扱うハンドラー todo-clj.handler.todo/todo-new-post の中身を書いていないからですね。早速書いてみます。

ひとつ忘れてました。これは POST を扱うハンドラーでここまでで一度も取り扱ってないので、どうやって値が飛んでくるのか分からないですよね。ちょっと確認してみましょう。まずは次のように todo-new-post を実装してみます。

;; src/todo_clj/handler/todo.clj
(defn todo-new-post [req]
  (-> (pr-str req)
      res/response
      res/html))

pr-str 関数でリクエストマップを文字列化して、画面に出すようにしました。このまま先ほどと同じように画面から POST 処理を行ってみます。すると以下のような出力を得ることが出来ます。

{:ssl-client-cert nil, :protocol "HTTP/1.1", :remote-addr "127.0.0.1", :params {}, :route-params {}, :headers {"accept" "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", "user-agent" "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:41.0) Gecko/20100101 Firefox/41.0", "referer" "http://localhost:3000/todo/new", "connection" "keep-alive", "host" "localhost:3000", "accept-language" "en-US,en;q=0.5", "accept-encoding" "gzip, deflate", "content-length" "10", "content-type" "application/x-www-form-urlencoded"}, :server-port 3000, :content-length 10, :compojure/route [:post "/new"], :content-type "application/x-www-form-urlencoded", :path-info "/new", :character-encoding nil, :context "/todo", :uri "/todo/new", :server-name "localhost", :query-string nil, :body #object[org.eclipse.jetty.server.HttpInputOverHTTP 0x56833ace "HttpInputOverHTTP@56833ace"], :scheme :http, :request-method :post}

test という TODO を追加したのですが、どうやらパッと見で人が読める文字列にはなっていなさそうです。ここで Ring のリクエストマップのおさらいですが、リクエストマップの中には :body キーがあってこれはリクエストボディがある場合に InputStream が送られてくるようになっています。今回のマップの中にもどうやら :body キーがあるのでこれを読み込んで文字列にしてみましょう。 todo-new-post を次のように編集します。

;; src/todo_clj/handler/todo.clj
(defn todo-new-post [req]
  (-> (pr-str (slurp (:body req) :encoding "utf-8"))
      res/response
      res/html))

こう修正した後に再度実行すると次のような出力を得ることが出来ました。

"title=test"

test と入力したのでこれで良さそうですね。これをパースしたりするのは少々大変なので Ring のユーティリティを使ってみましょう。

;; src/todo_clj/handler/todo.clj
(defn todo-new-post [req]
  (let [body (slurp (:body req) :encoding "utf-8")
        params (ring.util.codec/form-decode body "utf-8")]
    (-> (pr-str (get params "title"))
        res/response
        res/html)))

これを実行すると以下のような出力が得られることが出来たと思います。

"test"

このように目的の値を取得出来たのはいいですが、これを毎回書かないといけないのは少々手間なので既に用意されているミドルウェアを使ってこの問題を解決しましょう。 todo-clj.core ネームスペースを修正して ring.middleware.params ネームスペースを追加します。

;; src/todo_clj/core.clj
(ns todo-clj.core
  (:require [compojure.core :refer [routes]]
            [environ.core :refer [env]]
            [ring.adapter.jetty :as server]
            [ring.middleware.keyword-params :as keyword-params] ;; 追加
            [ring.middleware.params :as params] ;; 追加
            [ring.middleware.resource :as resource]
            [todo-clj.handler.main :refer [main-routes]]
            [todo-clj.handler.todo :refer [todo-routes]]
            [todo-clj.middleware :refer [wrap-dev]]))

(def app
  (-> (routes
       todo-routes
       main-routes)
      (wrap wrap-dev (:dev env))
      (wrap resource/wrap-resource "public")
      (wrap keyword-params/wrap-keyword-params true) ;; 追加
      (wrap params/wrap-params true))) ;; 追加

ring.middleware.params/wrap-params はさっきまでのコードと同様にリクエストマップからフォームのデータをパースしてリクエストマップの :params にマップしてくれるものです。フォームのデータ以外にも URI のクエリ文字列からもデータを取得してマップしてくれるので今後の開発においても期待できる機能です。

それから気付いているとおもいますが ring.middleware.params 以外にも追加しているミドルウェアがあります。リクエストマップの :params のキーにマップされているマップデータはキーが文字列なのでそれをキーワードに変換するためのミドルウェアですね。このミドルウェアはリクエストマップの :params キーに対してのみ働くため、 ring.middleware.params/wrap-params より前に実行してしまっては意味がないため適用する順番には気をつける必要があります。

ここまで出来たら todo-new-post を以下のように修正して改めて確認してみます。

;; src/todo_clj/handler/todo.clj
(defn todo-new-post [{:as req :keys [params]}] ;; 分配束縛で ``:params`` を取り出してしまうと操作が楽です
  (-> (pr-str (:title params))
      res/response
      res/html))

"test" (もしくはあなたが入力した値)と画面に出たなら成功です。これを元にデータベースに TODO を追加する処理を足しましょう。

;; src/todo_clj/handler/todo.clj
(defn todo-new-post [{:as req :keys [params]}]
  (when (todo/save-todo (:title params))
    (-> (view/todo-complete-view req)
        res/response
        res/html)))

todo-clj.db.todo/save-todo を実行して正常に実行できた場合には完了画面を出力するようにしました。完了画面については以下のような定義にしました。

;; src/todo_clj/view/todo.clj
(defn todo-complete-view [req]
  (->> [:section.card
        [:h2 "TODO を追加しました!!"]]
       (layout/common req)))

ここまでで追加画面が出来ました。しかし、気付いているかもしれませんが、これは不完全です。 CSRF 対策や入力のバリデーションができていませんし、もし DB への保存が失敗した場合なども考慮されていません。これらについては他の画面の実装が終わったところで触れていきたいと思います。

TODO の詳細画面を作る

TODO を新規作成出来るようになったわけですが、出来れば TODO 詳細画面を表示したい気がしますね。なので新規作成した後は追加した TODO の詳細画面を表示するようにしましょう。

まずは TODO を 1 件だけ取得する関数を作ります。

;; src/todo_clj/db/todo.clj
(defn find-first-todo [id]
  (first (jdbc/query db/db-spec ["select * from todo where id = ?" id])))

clojure.java.jdbc/query 関数は必ずシーケンスを返すのでこのように 1 件だけしか結果を返さないクエリでも first 関数などを使って先頭要素を取り出してあげる必要があります。実際にこの関数は以下のように動作します。

todo-clj.db.todo> (find-first-todo 1)
;; => {:id 1, :title "朝ごはんを作る"}
todo-clj.db.todo> (find-first-todo 2)
;; => {:id 2, :title "燃えるゴミを出す"}
todo-clj.db.todo> (find-first-todo 999)
;; => nil

存在しない ID を指定した場合(検索結果が 0 件)、 nil を返却します。

さて、データベースから TODO を取得する処理は出来たので今度は表示をなんとかしましょう。

;; src/todo_clj/handler/todo.clj
(defn todo-show [{:as req :keys [params]}]
  (if-let [todo (todo/find-first-todo (Long/parseLong (:todo-id params)))]
    (-> (view/todo-show-view req todo)
        res/response
        res/html)))

params から :todo-id キーの値を取得していますが、これは Compojure のルーティング定義部分で指定していたルートパラメーターですね。 Compojure のルートをリクエストマップが通るときに、自動的にルートパラメーターが :params にマップされているマップデータへと追加されます。そして、これは文字列の値なので数値へと変換する必要があります。

指定されたルートパラメーターを取得して TODO を検索するわけですが、 URI を手動で入力されたりする場合は該当する TODO が存在しない可能性があるので if-let を使って分岐しますが、エラー処理については後述するのでここではその部分について言及を避けます。

ビュー部分に関しては TODO のタイトルを表示するだけにしたいので次のようにします。

;; src/todo_clj/view/todo.clj
(defn todo-show-view [req todo]
  (->> [:section.card
        [:h2 (:title todo)]]
       (layout/common req)))

ここまで実装出来たら http://localhost:3000/todo/1 と入力して、 TODO の 1 件目が表示されているのが確認出来たら最後に TODO 作成後にこの画面にリダイレクトするように変更しましょう。まずはリダイレクト用のユーティリティ関数を todo-clj.util.response ネームスペースに追加します。

;; src/todo_clj/util/response.clj
(def redirect #'res/redirect)
(alter-meta! #'redirect #(merge % (meta #'res/redirect)))

response 関数と同じように redirect 関数を持ってきます。 todo-clj.handler.todo/todo-new-post を修正して次のようにします。

;; src/todo_clj/handler/todo.clj
(defn todo-new-post [{:as req :keys [params]}]
  (if-let [todo (first (todo/save-todo (:title params)))]
    (-> (res/redirect (str "/todo/" (:id todo)))
        res/html)))

これで TODO を追加したら自動的に詳細画面へとリダイレクトされるようになりました。ただ、いきなり詳細画面が出されても嬉しくないのでちょっとしたアラートが TODO を追加した直後の詳細画面でのみ表示されるようにしましょう。 Rails や他のフレームワークでいう flash 機能を使いたいので、例によってこれをミドルウェアで実現します。まずはいつも通り todo-clj.core ネームスペースを修正して、ミドルウェアを追加します。

;; src/todo_clj/core.clj
(ns todo-clj.core
  (:require [compojure.core :refer [routes]]
            [environ.core :refer [env]]
            [ring.adapter.jetty :as server]
            [ring.middleware.flash :as flash] ;; 追加
            [ring.middleware.keyword-params :as keyword-params]
            [ring.middleware.params :as params]
            [ring.middleware.resource :as resource]
            [ring.middleware.session :as session] ;; 追加
            [todo-clj.handler.main :refer [main-routes]]
            [todo-clj.handler.todo :refer [todo-routes]]
            [todo-clj.middleware :refer [wrap-dev]]))

(def app
  (-> (routes
       todo-routes
       main-routes)
      (wrap wrap-dev (:dev env))
      (wrap resource/wrap-resource "public")
      (wrap keyword-params/wrap-keyword-params true)
      (wrap params/wrap-params true)
      (wrap flash/wrap-flash true) ;; 追加
      (wrap session/wrap-session true))) ;; 追加

ring.middleware.flashring.middleware.session を足しました。これらはレスポンスマップに対して修正を加えるミドルウェアですが、それぞれ flash ミドルウェアはレスポンスマップに :flash というキーがある場合に、次のリクエストマップに対して :flash キーでコンテンツを追加します。 session ミドルウェアは flash ミドルウェアと同様に :session というキーで同じ動作をします。また ring.middleware.flashring.middleware.session に依存しているので、適用する順番には気をつける必要があります。

次に todo-new-post 関数を少し修正します。

;; src/todo_clj/handler/todo.clj
(defn todo-new-post [{:as req :keys [params]}]
  (if-let [todo (first (todo/save-todo (:title params)))]
    (-> (res/redirect (str "/todo/" (:id todo)))
        (assoc :flash {:msg "TODO を正常に追加しました。"}) ;; 追加
        res/html)))

レスポンスマップ( redirect 関数はレスポンスマップを返却する)の :flash キーにマップデータを追加します。このようにして追加された flash データはビューで次のように利用します。

;; src/todo_clj/view/todo.clj
(defn todo-show-view [req todo]
  (->> [:section.card
        (when-let [{:keys [msg]} (:flash req)] ;; リクエストマップに ``:flash`` があればそれをアラートとして表示される
          [:div.alert.alert-success [:strong msg]])
        [:h2 (:title todo)]]
       (layout/common req)))

早速、 http://localhost:3000/todo/new から新しい TODO を追加してみて、アラートが正常に表示されることを確認します。出来たら次に進みましょう。あ、 todo-complete-view 関数は使わなくなったので削除してしまっても問題ありません。

TODO の編集画面を作る

追加して、詳細画面を表示出来るようになったら今度は既にある TODO を更新出来るようにしたいですよね(今のところ TODO のタイトルしか作成出来ないのでそうでもない?)。早速書いていきます。

ブラウザで http://localhost:3000/todo/1/edit にアクセスすると素っ気ない文字列が出てくる状態だと思うので、さっとハンドラーを修正してビューも作成してしまいましょう。

;; src/todo_clj/handler/todo.clj
(defn todo-edit [{:as req :keys [params]}]
  (if-let [todo (todo/find-first-todo (Long/parseLong (:todo-id params)))]
    (-> (view/todo-edit-view req todo)
        res/response
        res/html)))
;; src/todo_clj/view/todo.clj
(defn todo-edit-view [req todo]
  (let [todo-id (get-in req [:params :todo-id])]
    (->> [:section.card
          [:h2 "TODO 編集"]
          (hf/form-to
           [:post (str "/todo/" todo-id "/edit")]
           [:input {:name :title :value (:title todo)
                    :placeholder "TODO を入力してください"}]
           [:button.bg-blue "更新する"])]
         (layout/common req))))

こんな感じで編集画面を作りました。ほとんど、 todo-newtodo-show で書いたようなコードなので改めて説明する必要はあまりないと思います。これで http://localhost:3000/todo/1/edit にアクセスすると追加画面と似たような(というかほぼ同じ)画面が見えるようになっていますが、ここに何か入力して「更新する」ボタンお押してもまた素っ気ない文字列が出てくるだけです。次は POST 処理を書いてあげる必要がありますね。先にデータベースへ更新をかける関数を書きます。

;; src/todo_clj/db/todo.clj
(defn update-todo [id title]
  (jdbc/update! db/db-spec :todo {:title title} ["id = ?" id]))

実際に更新出来るか REPL でこれを試してみましょう。

todo-clj.db.todo> (update-todo 1 "夜ご飯を食べる")
;; => (1)
todo-clj.db.todo> (update-todo 9999 "ラザニアを作る")
;; => (0)

前の Part で説明したように clojure.java.jdbc/update! 関数は更新件数を返すので、更新件数が 0 だったら更新する対象がなかったみなすことが出来そうです。これを使って実際の POST 処理を書くと次のようになります。

;; src/todo_clj/handler/todo.clj
(defn todo-edit-post [{:as req :keys [params]}]
  (let [todo-id (Long/parseLong (:todo-id params))]
    (if (pos? (first (todo/update-todo todo-id (:title params))))
      (-> (res/redirect (str "/todo/" todo-id))
          (assoc :flash {:msg "TODO を正常に更新しました"})
          res/html))))

pos? で更新件数が 1 件以上であることを確かめています。もし更新件数が 1 件以上であれば(期待値としては 1 件しかないはずですが)正常に更新処理を出来たということなので追加処理のとき同様リダイレクトして詳細画面を表示させましょう。ここまで書いたら http://localhost:3000/todo/1/edit から更新して詳細画面が出ることを確認しましょう。

TODO の削除画面を作る

さて、追加、表示、編集ときたので最後の削除画面を作りましょう。これも難しくないので編集画面と同様さっくりやってしまいましょう。まずは http://localhost:3000/todo/1/delete でアクセスされたら削除するのか確認するような画面を作りましょう。

;; src/todo_clj/handler/todo.clj
(defn todo-delete [{:as req :keys [params]}]
  (if-let [todo (todo/find-first-todo (Long/parseLong (:todo-id params)))]
    (-> (view/todo-delete-view req todo)
        res/response
        res/html)))
;; src/todo_clj/view/todo.clj
(defn todo-delete-view [req todo]
  (let [todo-id (get-in req [:params :todo-id])]
    (->> [:section.card
          [:h2 "TODO 削除"]
          (hf/form-to
           [:post (str "/todo/" todo-id "/delete")]
           [:p "次の TODO を本当に削除しますか?"]
           [:p "*" (:title todo)]
           [:button.bg-red "削除する"])]
         (layout/common req))))

ここまでも大凡同じですね。次はデータベースから TODO を削除する処理を書きます。

;; src/todo_clj/db/todo.clj
(defn delete-todo [id]
  (jdbc/delete! db/db-spec :todo ["id = ?" id]))

これを REPL で試すとこうなります。

todo-clj.db.todo> (delete-todo 2)
;; => (1)
todo-clj.db.todo> (delete-todo 2)
;; => (0)

これも更新関数と同様に更新件数を返します。なので、削除対象が 0 件の場合は 0 が返ってきます。最後に POST を処理する関数を書いたら完成です。

;; src/todo_clj/handler/todo.clj
(defn todo-delete-post [{:as req :keys [params]}]
  (let [todo-id (Long/parseLong (:todo-id params))]
    (if (pos? (first (todo/delete-todo todo-id)))
      (-> (res/redirect "/todo")
          (assoc :flash {:msg "TODO を正常に削除しました"})
          res/html))))

削除したあとのリダイレクト先に TODO の一覧画面にします。一覧画面上にアラートを出したいので、一覧画面の方にも修正を加えます。

;; src/todo_clj/view/todo.clj
(defn todo-index-view [req todo-list]
  (->> [:section.card
        (when-let [{:keys [msg]} (:flash req)]
          [:div.alert.alert-success [:strong msg]])
        [:h2 "TODO 一覧"]
        [:ul
         (for [{:keys [title]} todo-list]
           [:li title])]]
       (layout/common req)))

書けたら実際に http://localhost:3000/todo/1/delete から削除してみましょう。削除が成功していればここまでは大丈夫でしょう。

仕上げ

簡単に幾つかの画面を作ってきましたが、気付いていると思いますが各画面を行き来するためのリンクが欠けていたり、エラーハンドリングが出来ていなかったりとちょっと杜撰です。ということでアプリを仕上げていきましょう。

各画面をリンクさせる

まずは今まで作った画面をリンクさせましょう。

;; src/todo_clj/view/todo.clj
(defn todo-index-view [req todo-list]
  (->> [:section.card
        (when-let [{:keys [msg]} (:flash req)]
          [:div.alert.alert-success [:strong msg]])
        [:h2 "TODO 一覧"]
        [:ul
         (for [{:keys [id title]} todo-list]
           [:li [:a {:href (str "/todo/" id)} title]])]]
       (layout/common req)))

TODO 一覧はそれぞれの TODO に詳細画面へといけるリンクを付けました。

;; src/todo_clj/view/todo.clj
(defn todo-show-view [req todo]
  (let [todo-id (:id todo)]
    (->> [:section.card
          (when-let [{:keys [msg]} (:flash req)]
            [:div.alert.alert-success [:strong msg]])
          [:h2 (:title todo)]
          [:a.wide-link {:href (str "/todo/" todo-id "/edit")} "修正する"]
          [:a.wide-link {:href (str "/todo/" todo-id "/delete")} "削除する"]]
         (layout/common req))))

詳細画面には編集と削除画面のそれぞれにいけるリンクを付けました。これでとりあえず URI 直接入力しないと各画面にいけないという問題は解消できました。それと CSS に少し書き足しています。

;; resources/public/css/style.css
a.wide-link {
    margin: 0 5px;
}

バリデーションを作る

今までの状態では何も入力しないでも TODO を追加したり更新することができていました。出来ればちゃんとしたデータを入力してもらいたいので、入力がない場合入力画面へと再度誘導したいと思います。

これを実現するためにまずはバリデーションを簡単に行えるライブラリを導入しましょう。今回は bouncer というバリデーション用ライブラリを使うことにします。

;; project.clj
:dependencies [[org.clojure/clojure "1.7.0"]
               [ring "1.4.0"]
               [compojure "1.4.0"]
               [hiccup "1.0.5"]
               [environ "1.0.1"]
               [org.clojure/java.jdbc "0.4.2"]
               [org.postgresql/postgresql "9.4-1205-jdbc42"]
               [bouncer "0.3.3"]] ;; new

いつものように依存関係に追加したら REPL を再起動して軽く試してみましょう。

user> (require '[bouncer.core :as b]
               '[bouncer.validators :as v])
;; => nil
user> (b/validate {:title ""}
                  :title v/required)
;; => [{:title ("title must be present")} {:title "", :bouncer.core/errors {:title ("title must be present")}}]
user> (b/validate {:title "朝ごはんを作る"}
                  :title v/required)
;; => [nil {:title "朝ごはんを作る"}]
user> (def todo-varidator {:title v/required})
;; => #'user/todo-varidator
user> (b/validate {:title ""} todo-varidator)
;; => [{:title ("title must be present")} {:title "", :bouncer.core/errors {:title ("title must be present")}}]
user> (b/valid? {:title "朝ごはんを作る"} todo-varidator)
;; => true

主に使うことになるのは bouncer.core/validatebouncer.core/valid? だと思います。 bouncer.validators ネームスペースは幾つかの組み込みバリデーターを提供してくれます。使い方はなんとなくわかったと思うので早速これを私たちの TODO アプリに組み込んでみます。

まずは次のようなコードを考えてみます。

(def validator {:foo required})

(defn new-handler [req]
  (do-something req))

(defn new-post-handler [req]
  (with-fallback #(new-handler (assoc req :errors %))
    (let [params (validate (:params req) validator)]
      (do-something params))))

new-post-handlervalidate 関数を呼び出して、バリデーションエラーが発生したら予め登録しておいた匿名関数 #(new-handler (assoc :errors %)) を呼び出し、再度 new-handler を実行する…こういう形でコードを書けたらいちいちバリデーションエラーのために if で条件分岐するとか冗長な処理を色んなところに書かなくて良くなりそうです。

早速上記のような処理が書くためにふたつのヘルパーを新しいネームスペースに定義してみます。

;; src/todo_clj/util/validation.clj
(ns todo-clj.util.validation
  (:require [bouncer.core :as b]))

(defn validate [& args]
  (let [[errors org+errors] (apply b/validate args)]
    (if (nil? errors)
      org+errors
      (throw (ex-info "Validation error" errors)))))

(defmacro with-fallback [fallback & body]
  `(try
     ~@body
     (catch clojure.lang.ExceptionInfo e#
       (~fallback (ex-data e#)))))

todo-clj.util.validation ネームスペースを作ってそこに validate 関数と with-fallback マクロを定義しました。 validate 関数は bouncer.core/validate 関数をラップしたものですが、エラーがある場合にはバリデーションエラーの情報を含めた実行時例外を投げるようにしていて、エラーがない場合には validate 関数に渡されたマップ情報をそのまま返却するようにしています。 with-fallback は先に定義した validate 関数と対になるもので、第一引数として実行時例外が起こった場合に呼び出す関数を受け取り、あとは例外を起こしうるコードを渡すだけです。

todo-clj.util.validation> (def a-validator {:foo bouncer.validators/required})
;; => #'todo-clj.util.validation/a-validator
todo-clj.util.validation> (with-fallback println (validate {:bar :baz} a-validator))
;; {:foo (foo must be present)}
;; => nil
todo-clj.util.validation> (with-fallback println (validate {:foo :any} a-validator))
;; => {:foo :any}

こんな感じで使えるのでこれを踏まえて、ハンドラーにこれらを適用してみましょう。

;; src/todo_clj/handler/todo.clj
(ns todo-clj.handler.todo
  (:require [bouncer.validators :as v] ;; 追加
            [compojure.core :refer [defroutes context GET POST]]
            [todo-clj.db.todo :as todo]
            [todo-clj.util.response :as res]
            [todo-clj.util.validation :as uv] ;; 追加
            [todo-clj.view.todo :as view]))

(def todo-validator {:title [[v/required :message "TODO を入力してください"]]}) ;; 必須入力という制限を設ける + 標準メッセージだと英語なので日本語に

(defn todo-new-post [{:as req :keys [params]}]
  (uv/with-fallback #(todo-new (assoc req :errors %)) ;; エラーなら ``todo-new`` を呼び出す
    (let [params (uv/validate params todo-validator)]
      (if-let [todo (first (todo/save-todo (:title params)))]
        (-> (res/redirect (str "/todo/" (:id todo)))
            (assoc :flash {:msg "TODO を正常に追加しました。"})
            res/html)))))

(defn todo-edit-post [{:as req :keys [params]}]
  (uv/with-fallback #(todo-edit (assoc req :errors %))
    (let [params (uv/validate params todo-validator)
          todo-id (Long/parseLong (:todo-id params))]
      (if (pos? (first (todo/update-todo todo-id (:title params))))
        (-> (res/redirect (str "/todo/" todo-id))
            (assoc :flash {:msg "TODO を正常に更新しました"})
            res/html)))))

ハンドラーに適用するとこのようになりました。そして fallback として設定した匿名関数の引数にはリクエストマップに :errors キーを追加してバリデーションエラーの結果を挿入します。追加された :errors キーの値をビュー側で呼び出して、エラーメッセージを表示させます。

;; src/todo_clj/view/todo.clj
(defn error-messages [req]
  (when-let [errors (:errors req)]
    [:ul
     (for [[k v] errors
           msg v]
       [:li.error-message msg])]))

(defn todo-new-view [req]
  (->> [:section.card
        [:h2 "TODO 追加"]
        (hf/form-to
         [:post "/todo/new"]
         (error-messages req) ;; 追加
         [:input {:name :title :placeholder "TODO を入力してください"}]
         [:button.bg-blue "追加する"])]
       (layout/common req)))

(defn todo-edit-view [req todo]
  (let [todo-id (get-in req [:params :todo-id])]
    (->> [:section.card
          [:h2 "TODO 編集"]
          (hf/form-to
           [:post (str "/todo/" todo-id "/edit")]
           (error-messages req) ;; 追加
           [:input {:name :title :value (:title todo)
                    :placeholder "TODO を入力してください"}]
           [:button.bg-blue "更新する"])]
         (layout/common req))))

:errors がリクエストマップにある場合、それを展開してエラーメッセージのリストを表示するようにしました。ここまで出来たら追加か編集画面で何も入力せずにボタンを押してみましょう。また入力画面になってエラーメッセージが表示されたら成功です。

CSRF 対策する

バリデーション機能を実装したところで次は CSRF 対策もしましょう。 CSRF 対策とはなにかという話はしませんが、 CSRF 対策を怠るとどうなるか詳しく知らない人は「ぼくはまちちゃん騒動」など調べると良いと思います。

さて、この問題も ring-anti-forgery という Ring ミドルウェアを追加するだけで解決出来てしまうんですが、ここでちょっと todo-clj.core ネームスペースの app を確認してみます。

;; src/todo_clj/core.clj
(def app
  (-> (routes
       todo-routes
       main-routes)
      (wrap wrap-dev (:dev env))
      (wrap resource/wrap-resource "public")
      (wrap keyword-params/wrap-keyword-params true)
      (wrap params/wrap-params true)
      (wrap flash/wrap-flash true)
      (wrap session/wrap-session true)))

ミドルウェアがかなり増えてきてちょっと複雑になってきた気がするのでこれをまずはスマートにしましょう。独自で todo-clj.middleware/wrap-dev のような関数を作ってしまっても良いのですが、実は広く一般に使われているライブラリでこれらを統合しているものがあるのでそれを使いたいと思います。ライブラリの名前は Ring-Defaults です。依存関係へと追加します。

;; project.clj
:dependencies [[org.clojure/clojure "1.7.0"]
               [ring "1.4.0"]
               [compojure "1.4.0"]
               [hiccup "1.0.5"]
               [environ "1.0.1"]
               [org.clojure/java.jdbc "0.4.2"]
               [org.postgresql/postgresql "9.4-1205-jdbc42"]
               [bouncer "0.3.3"]
               [ring/ring-defaults "0.1.5"]] ;; <- New

REPL を再起動して早速導入しましょう、と言いたいところですがどんなライブラリがこれで使えるようになるのか理解していないと「とりあえずこれ追加すれば OK 」という風になってしまうので少しばかり遠回りして解説しましょう。

私たちがこれから使おうとしているミドルウェアは ring.middleware.defaults/wrap-defaults というもので、その全貌は以下のようになっています。

;; in ring.middleware.defaults ns
(defn wrap-defaults
  "Wraps a handler in default Ring middleware, as specified by the supplied
  configuration map.
  See: api-defaults
       site-defaults
       secure-api-defaults
       secure-site-defaults"
  [handler config]
  (-> handler
      (wrap wrap-anti-forgery     (get-in config [:security :anti-forgery] false))
      (wrap wrap-flash            (get-in config [:session :flash] false))
      (wrap wrap-session          (:session config false))
      (wrap wrap-keyword-params   (get-in config [:params :keywordize] false))
      (wrap wrap-nested-params    (get-in config [:params :nested] false))
      (wrap wrap-multipart-params (get-in config [:params :multipart] false))
      (wrap wrap-params           (get-in config [:params :urlencoded] false))
      (wrap wrap-cookies          (get-in config [:cookies] false))
      (wrap wrap-absolute-redirects (get-in config [:responses :absolute-redirects] false))
      (wrap wrap-resource         (get-in config [:static :resources] false))
      (wrap wrap-file             (get-in config [:static :files] false))
      (wrap wrap-content-type     (get-in config [:responses :content-types] false))
      (wrap wrap-default-charset  (get-in config [:responses :default-charset] false))
      (wrap wrap-not-modified     (get-in config [:responses :not-modified-responses] false))
      (wrap wrap-x-headers        (:security config))
      (wrap wrap-hsts             (get-in config [:security :hsts] false))
      (wrap wrap-ssl-redirect     (get-in config [:security :ssl-redirect] false))
      (wrap wrap-forwarded-scheme      (boolean (:proxy config)))
      (wrap wrap-forwarded-remote-addr (boolean (:proxy config)))))

ring-defaults より引用しましたが、今まで書いてきたコードと似たようなコードがあることに気付いたでしょうか。 flash, session, params などなど今まで必要に迫られて追加してきたものですが、今回欲しい anti-forgery ミドルウェアが入っているのも確認出来ます。さて、このミドルウェアですが第一引数にハンドラーを受け取るのは他と同様ですが、第二引数に設定をマップデータとして受け取るようです。ではその設定はいちいち自分で書かないといけないのかというとそうではなく、これも同じネームスペースにあるのでそれを使います。 secure-api-defaults, api-defaults, secure-site-defaults, site-defaults とありますが、今回は side-defaults を使うことにしましょう。実際に使う場合は次のようになると思います。

(ns example.core
  (:require [ring.middleware.defaults :as defaults]))

(defn handler [req]
  (do-something))

(def app
  (defaults/wrap-defaults handler defaults/site-defaults))

またこの初期設定( site-defaults など)がこのまま使いたくない、気に入らないという場合はそれぞれただのマップデータなので書き換え可能です。例えばこんな風に。

(def my-defaults
  (assoc-in defaults/site-defaults [:security :anti-forgery] false))

(def app
  (defaults/wrap-defaults handler my-defaults))

このように自分で何が必要かを選んで適用することが出来るのでテストのときは適用するミドルウェアを変更したいなどという要望に対応することが出来ます。実際にこの wrap-defaults ミドルウェアを私たちの TODO アプリへと適用します。

todo-clj.middleware ネームスペースへ次のような middleware-set 関数を足します。

;; src/todo_clj/middleware.clj
(ns todo-clj.middleware
  (:require [environ.core :refer [env]]
            [ring.middleware.defaults :as defaults]))

(def ^:private wrap #'defaults/wrap)

(defn middleware-set [handler]
  (-> handler
      (wrap wrap-dev (:dev env))
      (defaults/wrap-defaults defaults/site-defaults)))

元々、 todo-clj.corerequire していた environ もこちらに持ってきています。 wrap 関数は todo-clj.core で実装していたのを ring.middleware.defaults のものを使うようにしました。今まで todo-clj.core/app に対して色々なミドルウェアを middleware-set 関数で一旦受けてそれをあとで todo-clj.core/app に適用するということですね。それでは todo-clj.core も修正しましょう。

;; src/todo_clj/core.clj
(ns todo-clj.core
  (:require [compojure.core :refer [routes]]
            [ring.adapter.jetty :as server]
            [todo-clj.handler.main :refer [main-routes]]
            [todo-clj.handler.todo :refer [todo-routes]]
            [todo-clj.middleware :refer [middleware-set]]))

(def app
  (middleware-set
   (routes
    todo-routes
    main-routes)))

色々と require していたものがなくなったので ns マクロがスッキリしました。また沢山のミドルウェアをスレッディングマクロで適用していた app もスッキリとしています。ここまで書き換えたらサーバーを再起動します。ミドルウェアを追加したりした場合は reload ミドルウェアでは解決出来ないことがあるのでサーバーを再起動する必要があります。

試しに TODO を追加してみましょう。

…ちゃんと “Invalid anti-forgery token” というメッセージを見ることが出来たでしょうか? anti-forgery ミドルウェアを追加したのでビューに anti-forgery ミドルウェアが提供するトークンを埋め込む必要があります。早速やってみましょう。

(ns todo-clj.view.todo
  (:require [hiccup.form :as hf]
            [ring.util.anti-forgery :refer [anti-forgery-field]] ;; `anti-forgery` ミドルウェアと一緒に提供されるユーティリティ
            [todo-clj.view.layout :as layout]))

(defn todo-new-view [req]
  (->> [:section.card
        [:h2 "TODO 追加"]
        (hf/form-to
         [:post "/todo/new"]
         (anti-forgery-field) ;; 他の POST するフォームにも同じように追加
         (error-messages req)
         [:input {:name :title :placeholder "TODO を入力してください"}]
         [:button.bg-blue "追加する"])]
       (layout/common req)))

Ring-Anti-Forgeryring.util.anti-forgery/anti-forgery-field という関数を提供しているので、基本的にこれを使えば問題なく POST 処理が出来るようになります。また、どうしても生のトークンが欲しい場合は ring.middleware.anti-forgery/*anti-forgery-token* を参照することで手に入れることが出来ます。

改めて TODO を追加しようとすると今度は成功するようになっていると思います。これで CSRF 対策が出来ました。とはいっても簡単にこれを確かめることが出来ないので効果が分かり難いですが、例えば Ajax で TODO を追加しようとすると弾かれます(もし、時間があって気になる方はミドルウェア適用前後で効果を確認してみましょう)。

エラーをうまく処理する

これまでのプログラムではエラーに対する処理が欠けていました。例えば、更新処理のリクエストを投げたときに更新対象が削除されていたらどうするのかとか、編集しようと思って編集画面を開こうとしたときに更新対象が削除されていたらどうするのかとか、そういうケースに対して現在は何も対策をしていません。

試しに http://localhost:3000/todo/1/edit を開いたまま、別のタブで http://localhost:3000/todo/1 を削除してみます。削除出来たら編集画面を開いているタブから適当な値を入力して更新ボタンを押してみます。すると "Not found" が表示されたと思います。本当は "Not found" ではなくて、 "Conflict" とか表示させたいような気がします。適切な HTTP ステータスコードの選び方はここで説明しませんが、ここでは適切なエラー処理の仕方を説明したいと思います。

まずは新しくライブラリをみっつ追加します。 ring-http-response と slingshot 、それから potemkin です。実際に使いたいのは ring-http-response だけなんですが、これを追加するときに細かいことを色々としたいので他のふたつも一緒に追加しています。簡単に説明するなら ring-http-response は ring.util.response ネームスペースを置き換える便利な HTTP レスポンスに関するライブラリで、 slingshot は Clojure の trythrow のそれぞれと互換がある try+, throw+ というマクロを提供するライブラリ、 potemkin は特化した機能はありませんが幾つかの便利な関数/マクロを提供してくれるライブラリです。

;; project.clj
:dependencies [[org.clojure/clojure "1.7.0"]
               [ring "1.4.0"]
               [compojure "1.4.0"]
               [hiccup "1.0.5"]
               [environ "1.0.1"]
               [org.clojure/java.jdbc "0.4.2"]
               [org.postgresql/postgresql "9.4-1205-jdbc42"]
               [bouncer "0.3.3"]
               [ring/ring-defaults "0.1.5"]
               [metosin/ring-http-response "0.6.5"] ;; new
               [slingshot "0.12.2"] ;; new
               [potemkin "0.4.1"]] ;; new

プロジェクトの依存関係へ追加したら REPL を再起動して、私たちの todo-clj.util.response を強力にしたいと思います。

;; src/todo_clj/util/response.clj
(ns todo-clj.util.response
  (:require [potemkin :as p]
            [ring.util.http-response :as res]))

(defmacro import-ns [ns-sym]
  (do
    `(p/import-vars
      [~ns-sym
       ~@(map first (ns-publics ns-sym))])))

(import-ns ring.util.http-response)

(defn html [res]
  (res/content-type res "text/html; charset=utf-8"))

potemkin のマクロのひとつ potemkin/import-vars を拡張して、特定のネームスペースにある全ての公開された Var*ns* に追加します。そして import-ns マクロを使って ring.util.http-response から全ての Vartodo-clj.util.response へと追加しています。 potemkin/import-vars は以前 todo-clj.util.responseresponse 関数を定義したようなことをマクロで機械的にやってくれています。ちなみに今まで定義していた response 関数や redirect 関数は機能が重複するものが ring.util.http-response から提供されるので削除しました。

ring.util.http-responsering.util.response は似ているので基本的には同じように使えるのですが、 responseokredirectfound という名前なのでそれにあわせてハンドラーの方も修正します。

;; src/todo_clj/handler/todo.clj
(defn todo-new [req]
  (-> (view/todo-new-view req)
      res/ok ;; response -> ok
      res/html))

(defn todo-new-post [{:as req :keys [params]}]
  (uv/with-fallback #(todo-new (assoc req :errors %))
    (let [params (uv/validate params todo-validator)]
      (if-let [todo (first (todo/save-todo (:title params)))]
        (-> (res/found (str "/todo/" (:id todo))) ;; redirect -> found
            (assoc :flash {:msg "TODO を正常に追加しました。"})
            res/html)))))

これで一旦、 todo-clj.util.response に対する修正は終わりました。次はエラー処理を追加していきます。どう実装するかは好みの問題ですが、ここでは簡単さを取って各ハンドラーは 404 や 500 という状態のときになったらその情報を持たせた例外を投げることにして、レスポンスに対するミドルウェアでその例外をキャッチをしそれぞれの画面を表示するという風にしましょう。 ring.util.http-response ネームスペースはエラー用の関数を幾つか提供してくれるのでそれを使いましょう。

;; src/todo_clj/handler/todo.clj
(defn todo-show [{:as req :keys [params]}]
  (if-let [todo (todo/find-first-todo (Long/parseLong (:todo-id params)))]
    (-> (view/todo-show-view req todo)
        res/ok
        res/html)
    (res/not-found!))) ;; ``todo-clj.util.response`` は ``ring.util.http-response`` の関数を全てインポートしているのでこのように使える

このように今まで実装していなかったところに対して例外を投げる関数を置いてみました。 ring.util.http-response ネームスペースの関数でエクスクラメーションマークが付いているものは例外を投げ、エクスクラメーションマークがない同名の関数は例外を投げずにエラーのレスポンスマップを返却します。 not-found! に対応する not-found という関数があるので REPL で確認してみると良いでしょう。今回は例外を投げる方法で実装していきますが、例外を使わずにエラー用のページを用意してそれを表示するというのでも良いと思います。

さて、もしかしたら気付いている方もいると思いますが、 not-found!todo-show 関数の中で実行しないでも実はちゃんと "Not found" と表示されるようになってました。何故今まで何も実装していないのにこうなっていたのでしょう。理由は Compojure にあります。 Compojure はマッチするルーティングを探して定義順にマッチするものを探していくのですが、マッチしたハンドラーが nil を返却してくる場合は再度そこからルーティングを探し直します。

分かりやすいように次のようなルーティングを考えます。

(defroutes app
  (GET "/" req home)
  (GET "/a" req a-index)
  (GET ["/a/:id" :id #"\d+"] req a-show)
  (GET "/a/:command" req req a-command)
  (route/not-found "<h1>Not found</h1>"))

これに対して /a/99 というパスからアクセスがきた場合、まずはマッチするルーティングが a-show になるのでそれを実行します。しかし、何かしらの問題があってこのハンドラー関数が nil を返してきた場合、 Compojure は更に下って a-command ハンドラーを実行します。もし、 a-commandnil を返却した場合は最後に route/not-found が実行されて画面には "Not found" と表示されるわけですね。

じゃあ、 todo-show の中で not-found! 実行しなくてもいいんじゃない?と思うかもしれませんが、それは違います。理由はふたつあって (1) Compojure の実装に依存してしまっている、 (2) 先の例にあったようにもしその後にマッチしてしまうルーティングが存在するならそちらが実行されてしまうので、ここでちゃんとエラーを処理しておく必要があります。

さて、 Compojure の話になってしまいましたが、 not-found!todo-show の中においただけでは例外を投げっぱなしになっているのでこれを受けてやるミドルウェアを実装していきます。今、 not-found! 関数がどんな例外を投げているのかは REPL で確かめてみましょう。

user> (require '[todo-clj.util.response :as res]) ;; 重ねて説明しておくと ``ring.util.http-response`` の関数が全てインポートされている
;; => nil
user> (res/not-found!)
;; => ExceptionInfo throw+: {:type :ring.util.http-response/response, :response {:status 404, :headers {}, :body nil}}  ring.util.http-response/throw! (http_response.clj:10)

ExceptionInfo を投げているので Clojure のマップデータも一緒に投げています。そして、 ring.util.http-response にある例外を投げる関数(エクスクラメーションマーク付き)は slingshot の throw+ を使っているので、同じく slingshot が提供する try+ を使うことで簡単にそのデータを取得したりハンドルする例外を定めることが出来ます。

user> (require '[slingshot.slingshot :refer [try+ throw+]])
;; => nil
user> (try+ (throw+ {:type :bar :msg "this is bar"})
            (catch [:type :foo] {:keys [msg]}
              (println "Exception type is foo: " msg))
            (catch [:type :bar] {:keys [msg]}
              (println "Exception type is bar: " msg)))
;; Exception type is bar:  this is bar
;; => nil

こんな感じで ExceptionInfo の例外に付随するマップ情報でマッチした場合に、それを処理するということが簡単に出来ます。ここまで分かったところで前置きが長くなりましたがミドルウェアを実装します。あらたに todo-clj.middleware.http-response ネームスペースを作りましょう。実装は以下のようになります。

;; src/todo_clj/middleware/http_response.clj
(ns todo-clj.middleware.http-response
  (:require [hiccup.core :as h]
            [ring.util.http-status :as status]
            [slingshot.slingshot :refer [try+]]
            [todo-clj.util.response :as res]))

(defn- error-view [{:as response :keys [status]}]
  (let [{:keys [name description]} (status/status status)]
    (-> `([:h1 ~name]
          [:h2 ~description])
        h/html
        res/ok
        res/html)))

(defn wrap-http-response [handler]
  (fn [req]
    (try+
     (handler req)
     (catch [:type :ring.util.http-response/response] {:keys [response]}
       (error-view response)))))

wrap-http-response 関数は簡単ですね。ハンドラーの実行を try+ マクロで囲んで、 ExceptionInfo のマップデータにある :type:ring.util.http-response/response のときのみ例外を処理します。 error-view 関数はエラーの名前の説明を表示するだけです。ミドルウェアを作ったら適用しましょう。 todo-clj.middleware/middleware-set の中に入れてみます。

;; src/todo_clj/middleware.clj
(ns todo-clj.middleware
  (:require [environ.core :refer [env]]
            [ring.middleware.defaults :as defaults]
            [todo-clj.middleware.http-response :as http-response]))

(defn middleware-set [handler]
  (-> handler
      http-response/wrap-http-response ;; ここに追加
      (wrap wrap-dev (:dev env))
      (defaults/wrap-defaults defaults/site-defaults)))

レスポンスに対するミドルウェアなので wrap-dev より内側にある必要があります( prone.middleware/wrap-exceptions が全ての例外を掴んじゃうのでそれより先に例外を掴む必要がある)。ここまで出来たら一旦サーバーを再起動してみて、 http://localhost:3000/todo/1 とか削除した TODO にアクセスしてみましょう。綺麗に "Not Found" とその下に説明が表示されたら成功です。あとは他のハンドラーにも適当なエラー処理を追加してあげれば良さそうです。

;; src/todo_clj/handler/main.clj
(defroutes main-routes
  (GET "/" _ home)
  (route/not-found res/not-found!)) ;; ここも修正
;; src/todo_clj/handler/todo.clj
(defn todo-new-post [{:as req :keys [params]}]
  (uv/with-fallback #(todo-new (assoc req :errors %))
    (let [params (uv/validate params todo-validator)]
      (if-let [todo (first (todo/save-todo (:title params)))]
        (-> (res/found (str "/todo/" (:id todo)))
            (assoc :flash {:msg "TODO を正常に追加しました。"})
            res/html)
        (res/internal-server-error!))))) ;; 追加

(defn todo-edit [{:as req :keys [params]}]
  (if-let [todo (todo/find-first-todo (Long/parseLong (:todo-id params)))]
    (-> (view/todo-edit-view req todo)
        res/ok
        res/html)
    (res/not-found!))) ;; 追加

(defn todo-edit-post [{:as req :keys [params]}]
  (uv/with-fallback #(todo-edit (assoc req :errors %))
    (let [params (uv/validate params todo-validator)
          todo-id (Long/parseLong (:todo-id params))]
      (if (pos? (first (todo/update-todo todo-id (:title params))))
        (-> (res/found (str "/todo/" todo-id))
            (assoc :flash {:msg "TODO を正常に更新しました"})
            res/html)
        (res/conflict!))))) ;; 追加

(defn todo-delete [{:as req :keys [params]}]
  (if-let [todo (todo/find-first-todo (Long/parseLong (:todo-id params)))]
    (-> (view/todo-delete-view req todo)
        res/ok
        res/html)
    (res/not-found!))) ;; 追加

(defn todo-delete-post [{:as req :keys [params]}]
  (let [todo-id (Long/parseLong (:todo-id params))]
    (if (pos? (first (todo/delete-todo todo-id)))
      (-> (res/found "/todo")
          (assoc :flash {:msg "TODO を正常に削除しました"})
          res/html)
      (res/conflict!)))) ;; 追加

さて、これで正しくエラー処理が出来たような気がするので、試しに REPL 上で以下のように試してみます。

user> (require '[todo-clj.handler.todo :as ht])
;; => nil
user> (ht/todo-edit-post {:params {:todo-id "1" :title "食器を片付ける"}})
;; => ExceptionInfo throw+: {:type :ring.util.http-response/response, :response {:status 404, :headers {}, :body nil}}  ring.util.http-response/throw! (http_response.clj:10)

既に削除されている TODO に対して更新処理をかけようとしました。ですが、どうやら投げられている例外が期待しているものと違うようです( 404 ではなくて 409 のはず)。どういうことでしょう。例外を読んでみるとなんとなく原因が分かります。

http_response.clj:  283  ring.util.http-response/not-found!
http_response.clj:  281  ring.util.http-response/not-found!
         todo.clj:   45  todo-clj.handler.todo/todo-edit
         todo.clj:   48  todo-clj.handler.todo/todo-edit-post/fn
         todo.clj:   48  todo-clj.handler.todo/todo-edit-post
             REPL:   60  user/eval29218

どうやら not-found!todo-edit が実行しているようです。 todo-edit-post から呼び出されているようですが、これはつまり todo-clj.util.validation/with-fallback が機能しているということです。

;; src/todo_clj/util/validation.clj
(defmacro with-fallback [fallback & body]
  `(try
     ~@body
     (catch clojure.lang.ExceptionInfo e#
       (~fallback (ex-data e#)))))

ありました。 ExceptionInfo を全てキャッチするので、 slingshot.slingshot/throw+ で投げる例外は全てココで捕まっていたようです。なので、ここも修正して slingshot を使うことにしましょう。

;; src/todo_clj/util/validation.clj
(ns todo-clj.util.validation
  (:require [bouncer.core :as b]
            [slingshot.slingshot :refer [try+ throw+]]))

(defn validate [& args]
  (let [[errors org+errors] (apply b/validate args)]
    (if (nil? errors)
      org+errors
      (throw+ {:type ::validation-error :errors errors})))) ;; ``throw+`` を使って ``:type`` を指定しておく

(defmacro with-fallback [fallback & body]
  `(try+
    ~@body
    (catch [:type ::validation-error] {:keys [errors#]} ;; ``type`` が ``::validation-error`` のときだけ例外を処理するように変更
      (~fallback errors#))))

これで良いでしょう。再度 REPL 上で確認してみましょう。

user> (ht/todo-edit-post {:params {:todo-id "1" :title "食器を片付ける"}})
;; => ExceptionInfo throw+: {:type :ring.util.http-response/response, :response {:status 409, :headers {}, :body nil}}  ring.util.http-response/throw! (http_response.clj:10)

ちゃんと期待通りのステータスを持った例外を投げることが出来ました。実際に画面上でも削除済みの TODO に対して更新処理を行おうとすると "Conflict" と表示されるようになりました。 これでようやく TODO アプリとしてそれなりに使えるようになりました。次の Part では実際に Heroku へデプロイをしてみます。

ここまでで学んだこと

  • ring-anti-forgery ミドルウェアや ring-defaults ミドルウェアの関係
  • バリデーション処理の書き方
  • エラー処理の書き方