LoginSignup
5
2

More than 5 years have passed since last update.

ring/compojureでサーバを始めてみる(入門編) part3

Last updated at Posted at 2016-12-25

Clojure Advent Calendar 2016 25日目の記事です。

今回も ring & compojure で Twitter 連携なウェブアプリを作るお話です。前回までの話の流れは part1part2 をご覧ください。

依存関係の見直し

part2 では確か、以下のような依存関係でした。

(defproject <project-name> "0.1.0-SNAPSHOT"
  :descriptions "プロジェクトの概要"
  :url          "ソースコードを保管しているリポジトリなどのURL"
  :license      {:name "このプロジェクトが従属するライセンスの名前"
                 :url "ライセンスの条文が置かれているサイトのURL"}
  :exclusions   [org.clojure/clojure]
  :dependencies [[org.clojure/clojure "1.7.0"]
                 [org.clojure/data.json "0.2.6"]
                 [org.clojure/java.jdbc "0.4.2"]
                 [compojure "1.4.0"]
                 [hiccup "1.0.5"]
                 [lib-noir "0.9.9"]
                 [clj-http "2.0.0"]
                 [clj-oauth "1.5.3" :exclusions [clj-http commons-codec]]]
  :plugins       [[lein-ring "0.9.7"]
                  [lein-auto "0.1.2"]
                  [codox "0.9.0"]]
  :ring          {:handler <project-name>.core.handler/app}
  :profiles      {:dev {:dependencies [[javax.servlet/servlet-api "2.5"]
                                       [ring/ring-mock "0.3.0"]]}})

あれから更に1年、またまたパッケージが新しくなっています。現時点で最新のパッケージに合わせた結果、以下のようになりました:

(defproject <project-name> "0.1.0-SNAPSHOT"
  :description "The implementation for \"Confluentwee\" the web application."
  :url "http://gitlub.com/func-hs/confluentwee"
  :min-lein-version "2.0.0"
  :exclusions   [org.clojure/clojure]
  :dependencies [[org.clojure/clojure "1.8.0"]
                 [org.clojure/data.json "0.2.6"]
                 [org.clojure/java.jdbc "0.6.1"]
                 [commons-codec/commons-codec "1.10"]
                 [compojure "1.5.1" :exclusions [commons-codec]]
                 [hiccup "1.0.5"]
                 [lib-noir "0.9.9" :exclusions [compojure ring ring/ring-defaults hiccup clout]]
                 [clj-http "3.3.0" :exclusions [commons-codec]]
                 [clj-oauth "1.5.5" :exclusions [clj-http commons-codec/commons-codec]]
                 [ring/ring-defaults "0.2.1"]
                 [ring "1.5.0"]]
  :plugins [[lein-ring "0.9.7"]
            [lein-auto "0.1.2"]
            [codox "0.10.1"]]
  :ring {:handler confluentwee.handler/app}
  :profiles
  {:dev {:dependencies [[javax.servlet/servlet-api "2.5"]
                        [ring/ring-mock "0.3.0"]]}})

意外と見逃していた依存関係の除去

前回との違いは以下の4点です。

  • compojure も commons-codec に依存していたため、これを取り除いた(正確には compojure が依存している ring-codec にその依存がある)。
  • lib-noir が compojure 、 ring 、および hiccup に依存していたため、これを取り除いた(更には lib-noir 自体の更新が1年前から止まっており、上記3パッケージへの依存の更新も1年前で止まっていたため、 compojure や ring と同様の依存パッケージも取り除いた)。
  • そうして取り除いた依存関係の最新バージョンを当該プロジェクト自体で抱えるようにした。

:exclusions の展開結果を見たい

前回は :exclusions で指定した依存を取り除きつつ、自身のプロジェクトの中で新たに依存関係を再構築できると書きましたが、これを含めたコードが実際にどのように展開されるのかについては触れませんでした。それを手っ取り早く確認するためには、 lein-pprint というプラグインを使います。早速インストールしてみたいところですが、どこのファイルにその情報を書くかが気になりますね。当該プロジェクトに直接必要なのであれば project.clj の :plugins 部分に書いてしまってもいいのですが、これをプロジェクト自身が参照するということはありません。しかし、ちょっと気になった時にすぐ使えるようになっていてほしい(つまり、いつでもインストールが成立する感じにはしておきたい)。そういう時は、 ~/.lein/ 下に profiles.clj というファイルを置き、その中に以下のように記載すればよいです。

~/.lein/profiles.clj
{:user {:plugins [[lein-pprint "1.1.2"]]}}

順に説明すると、 :user と書かれている箇所はそのプラグインがどのレベルで必要になっているかを意味しています。今回は user 、つまり lein を利用しているユーザアカウント単位で依存していることを意味しています。もし、システム全体で利用可能な形にしておきたい場合は、 :user の代わりに :system を指定します。ユーザレベルのプロファイルは ~/.lein 下に、システムレベルのプロファイルは /etc/leiningen 下に置くことで認識されます。どちらに置いたものもデフォルトは :user profile として扱われますが、優先順位は ~/.lein 下にあるものの方が上です。なので、 /etc/leiningen 下にある方は :system profile にすべきと思って差し支えないでしょう。これを書いたらコマンドラインに戻り、当該プロジェクトのディレクトリ下で lein pprint を叩けば、以下のような依存関係データが確認できるはずです。

; 前略
 :dependencies
 [[org.clojure/clojure "1.8.0" :exclusions ([org.clojure/clojure])]
  [org.clojure/data.json "0.2.6" :exclusions ([org.clojure/clojure])]
  [org.clojure/java.jdbc "0.6.1" :exclusions ([org.clojure/clojure])]
  [commons-codec/commons-codec
   "1.10"
   :exclusions
   ([org.clojure/clojure])]
  [compojure/compojure
   "1.5.1"
   :exclusions
   ([org.clojure/clojure] [commons-codec/commons-codec])]
  [hiccup/hiccup "1.0.5" :exclusions ([org.clojure/clojure])]
  [lib-noir/lib-noir
   "0.9.9"
   :exclusions
   ([org.clojure/clojure]
    [compojure/compojure]
    [ring/ring]
    [ring/ring-defaults]
    [hiccup/hiccup]
    [clout/clout])]
  [clj-http/clj-http
   "3.3.0"
   :exclusions
   ([org.clojure/clojure] [commons-codec/commons-codec])]
  [clj-oauth/clj-oauth
   "1.5.5"
   :exclusions
   ([org.clojure/clojure]
    [clj-http/clj-http]
    [commons-codec/commons-codec])]
  [ring/ring-defaults "0.2.1" :exclusions ([org.clojure/clojure])]
  [ring/ring "1.5.0" :exclusions ([org.clojure/clojure])]
  [org.clojure/tools.nrepl
   "0.2.12"
   :exclusions
   ([org.clojure/clojure])]
  [clojure-complete/clojure-complete
   "0.2.4"
   :exclusions
   ([org.clojure/clojure])]
  [javax.servlet/servlet-api
   "2.5"
   :exclusions
   ([org.clojure/clojure])
   :scope
   "test"]
  [ring/ring-mock
   "0.3.0"
   :exclusions
   ([org.clojure/clojure])
   :scope
   "test"]],
; 後略

ご覧のように、トップレベルの :exclusions に記載したパッケージが全依存先から取り除かれ、パッケージレベルのそれはそのパッケージからのみ取り除かれていることが確認できました。なお、 :scope "test" は、project.clj に記載されている :dev プロファイルとしての依存であることを意味しています。

プロファイル設定はまだまだ細かい書き方・書き分け方ができますが、当記事ではその説明は省略いたします。詳しくは leiningen/doc/PROFILES.md をご覧ください。 profiles.clj に記載できる設定項目については project.clj のそれとほぼ同様です。詳しくは leiningen/sample.project.clj をご覧ください。

前回の指摘を踏まえて

前置きが長くなりましたが、ここから本題に入っていきます。
さて、前回私は以下のご指摘を賜りました。

  1. 閉じ括弧は改行しない方がいい。
  2. インデントが狂っている。
  3. "名前空間完全修飾とかめんどい…" は誤解である。
  4. "MVC がちゃんと揃ってないフレームワークのテストはまだ辛い…" は元のコードの書き方に依存する。
  5. ハンドラの責務とビューの責務を綺麗に分けるべきである。

1 と 2 について

part2 記事のコメントにもあるのですが、当時は Clojure にもスタイルガイドが発行されていることに気づかず、また、インデントに対する考え方も二転三転していたため、あのような書き方になっていました(現在の part2 記事中のコードはご指摘を受けて修正した後のものです)。なので、 ayato_p さんにご紹介頂いた Clojure Style Guide を基にして、目に見える範囲からコードの整形を行いました。当記事では整形してみた感想をご紹介するに留めさせていただきます。

ns マクロ周辺

:use を使わず :require

:use:require:refer の合わせ技です。つまり、指定した名前空間をその構造ごと読み込んだ後で、 public な var については呼び出し元の名前空間に取り込み、名前空間の修飾を省略させます。ここで気をつけなければいけないのは、呼び出し元や clojure.core に既にあるものと同じ名前のものが存在した場合はエラーが返されます。先に定義されたもので固定されてしまうわけです。もし同じシンボルを同時に利用したい場合は以下のように別名で修飾するか、完全修飾で呼び出す必要があります。

(ns hoge
  (:require [fuga :as f]
            [piyo :as p]))

これらの名前空間の中から特定の var だけを参照したい場合は、ベクタ中に更に :refer キーワードに続ける形で記述します。

(ns hoge
  (:require [fuga :as f :refer [foo]]
            [piyo :as p :refer [foo]]))

このスタイルに準拠したことにより、以下のメリットを体感できました。

  • var の重複を回避できます。
    :as で別名修飾をしたので当然と言えば当然ですが、無闇に :use で横着しようとせずに :require 経由で参照するようにしているため、名前の衝突はまず起こりません。

  • use と同様にする場合でも、読み込んだ var がどこの空間に属しているのかがよくわかるようになりました。
    もしかしたら私だけかもしれませんが、重複する可能性がない var については、名前空間の修飾なしで手短に呼び出したいという気持ちがあります。例えば、 clojure.test にあるテストユーティリティ群などです。構文として定義されているものにまで名前空間の修飾を込めて何度も書いていくのは、いくらか手間に感じてしまいます。 [hoge :refer [foo]] とすれば、名前空間を省略しつつも元の所属がどの空間なのかファイルの1行目から明示されているため、例え記憶が曖昧になったとしてもファイル先頭まで戻るだけで再確認できます。また、 :as キーワードで別名修飾できることもあり、名前の衝突も回避できて意外と助かることが多いことに気づきました(:refer:as が共存可能だというのも初耳でした)。特に重複もない空間について全部の var を名前空間を省略する形で読み込みたい場合は :refer :all と記述するだけでよいです。

インデントの違い

行頭のインデント

2文字幅については、従来からの伝統ということもあり特に思うことはありません。引数ベースでインデントする場合に、前の引数に対して左揃えにするという部分についても同様です。気になったのは、改行してから第一引数を渡す場合は、1文字幅でインデントすべしというところでした。 Haskell や Python のようなインデントブロックによるコードの書き方に先に慣れ親しんだ身としては、2文字または4文字で統一していきたいという気持ちがありますし、 Haskell を利用した開発を行う場合はやはりそうします。他方で、 Clojure のコードとして見た場合、右下方向に長くなりがちな Lisp 方言のコードにとって、当該スタイルに準拠することで確かに可読性の向上に寄与できるんじゃないかなということは感じました。今後の Clojure によるコーディングで当該スタイルを適用する可能性があるかはまだわかりませんが、採用を検討する価値があるスタイルだということは確かです。(ちなみに、 clj-oauth のソースコードにおいて、数カ所でこのスタイルを適用しているのは確認しました)

let などの束縛フォーム

一部繰り返しになりますが、インデントブロックな言語に先に慣れていた身としては、こういった部分でのスタイルも、やはりインデントによる字下げを基準に変数やラベルの位置を揃えたいという気持ちがあります。 part2 執筆直後に指摘された箇所についても、その気持ちに合わせて揃えていたために起こったことでした。 Clojure コードとしてはスタイルガイドのルールに従った方が見やすいのは確かです。よって、今後は準拠する形でいきます。

[] の置き方

これも let 等の束縛フォームに絡むことです。この [] の位置についても、上述と同様の気持ちで位置を合わせていました。 Clojure のコードとしてはあまりイケてない書き方だというのはわかっていましたが、私としてはそっちの方が束縛フォームのコードを整理しやすいと(あくまでも当時は)思っていたのでそのようにしていました。最終的には慣れの問題かなという気もしているので、準拠しつつ徐々に慣れ親しんで行きます。

root の値の更新

alter-var-root によるトップレベルからの var の更新がスタイルガイドからして推奨されているということは初耳でした。 clojure.core においても他のライブラリにおいても、この関数は原則 protocol のキャッシュの管理等のメタに近い部分で使われる印象があったため、 var の値を変える時は def で再定義するか、さもなくば atom などで更新をしていました。 alter-var-root による更新が不自然ではないと認識されているなら、確かにこれを使うほうがわかりやすいですね。とはいっても、実装フェーズで root レベルでごりごり書き換えるケースが発生するかは正直微妙です。

"名前空間完全修飾とかめんどい…" は誤解である。

指摘を受けて、そこからスタイルを改善していく過程で気づいてきたことなのですが、 private な var でも :as キーワードでその名前空間を別名修飾することで、完全修飾せずとも呼び出せました(意外でした…)。よって、今後は private な var もこの仕組みを利用して呼び出しの簡易化を図ります。

テスト用などに internal 等と名付けた名前空間を用意するかどうか。

現在はまだこれが必要なほど private な var が増えていないのですが、これを考慮しない限りはやはり @#'hoge/foo のように deref 経由で呼ぶしかないのかもなというのが率直な感想です。ただ、仮に用意するにしても、どのように var を配置していくかなどについて、いくらかじっくり考えておいた方がよさそうな気がしています。

"MVC がちゃんと揃ってないフレームワークのテストはまだ辛い…" は元のコードの書き方に依存する。

これは、書き方を見直すことによって、しっかりと感じ取ることができました。ルータが直接呼んでるハンドラにレスポンスマップの扱いと実際のレスポンス(HTML)の生成をごっちゃにして指示していたのが前回分なので、 View として最終的に生成される部分とハンドラがその途中経過として処理した結果を分けてテストすることが難しい状態でした。

ハンドラの責務とビューの責務を綺麗に分けるべきである。

詳しくは後述しますが、今回ハンドラと View とでそれぞれ役割を分けてみたことで、テストコードの記述がいくらか容易になりました。

さあ実装だ。

前回の反省をしたところで、ようやっと実装を始められます。当記事で説明する実装は以下の順番です。

  1. OAuth クライアント機能の実装
  2. 1つのツイートを取得し、表示する。(Twitter の statuses/show 周辺)

OAuth クライアント機能の実装

さて、 Twitter もとい OAuth が必要なアクセスを実装する場合、とにもかくにも Authorization ヘッダを完成させなくてはいけません。すべて自前でやろうとするとかなりだるいです。それをほぼ一手に引き受けてくれるライブラリが clj-oauth です。呼び出し側で以下のように書くだけで、まずはリクエストトークンを取得できます。

(ns hoge
  (:require [oauth.client :as oauth]))

(def consumer (oauth/make-consumer <consumer-key>
                                   <consumer-secret>
                                   "https://api.twitter.com/oauth/request_token"
                                   "https://dev.twitter.com/oauth/reference/post/oauth/access_token"
                                   "https://api.twitter.com/oauth/authenticate"
                                   :hmac-sha1
))

(def request-token (oauth/request-token consumer "oauth_callback"))

このコード、正常であれば何も問題がなく動くんですが、 Twitter からエラーが返された時に面倒くさいことになります。その理由は、 clj-oauth 中の以下のコードに集約されています。

oauth/client.clj
(defn form-decode
  "Parse form-encoded bodies from OAuth responses."
  [s]
  (if s
    (into {}
          (map (fn [kv]
                 (let [[k v] (split kv #"=")
                       k (or k "")
                       v (or v "")]
                   [(keyword (sig/url-decode k)) (sig/url-decode v)]))
               (split s #"&")))))

(defn- check-success-response [m]
  (let [code (:status m)]
    (if (or (< code 200)
            (>= code 300))
      (throw (new Exception (str "Got non-success code: " code ". "
                                 "Content: " (:body m))))
      m)))

(defn post-request-body-decoded [url & [req]]
  (form-decode
   (:body (check-success-response
           (httpclient/post url req)))))

(defn build-oauth-token-request
  "Used to build actual OAuth request."
  ([consumer uri unsigned-oauth-params & [extra-params token-secret]]
     (let [signature (sig/sign consumer
                               (sig/base-string "POST" uri (merge unsigned-oauth-params extra-params))
                               token-secret)
           oauth-params (assoc unsigned-oauth-params :oauth_signature signature)]
       (build-request oauth-params extra-params))))

(defn request-token
  "Fetch request token for the consumer."
  ([consumer]
     (request-token consumer "oob" nil))
  ([consumer callback-uri]
     (request-token consumer callback-uri nil))
  ([consumer callback-uri extra-params]
     (let [unsigned-params (-> (sig/oauth-params consumer
                                                 (sig/rand-str 30)
                                                 (sig/msecs->secs (System/currentTimeMillis)))
                               (assoc :oauth_callback callback-uri))]
       (post-request-body-decoded (:request-uri consumer) 
                                  (build-oauth-token-request consumer 
                                                             (:request-uri consumer) 
                                                             unsigned-params 
                                                             extra-params)))))

順に説明していきます。

第一の面倒: 置き換えられた例外

check-success-response のコードは、例外処理として特に問題ないように見えてました。しかし、問題しかないことに程なくして気付きました。 request-token 関数は clj-http の post 関数を利用して、所定の "POST request_token" API に Authorization ヘッダと oauth_callback パラメータを込めた状態でリクエストを送り、そのトークンをもらいます。では post 関数の実装を見てみましょう。

clj-http/core.clj
(defn request
  ([req] (request req nil nil))
  ([{:keys [body conn-timeout conn-request-timeout connection-manager
            cookie-store cookie-policy headers multipart query-string
            redirect-strategy follow-redirects max-redirects retry-handler
            request-method scheme server-name server-port socket-timeout
            uri response-interceptor proxy-host proxy-port async?
            proxy-ignore-hosts proxy-user proxy-pass digest-auth ntlm-auth]
     :as req} respond raise]
   (let [req (dissoc req :async?)
         scheme (name scheme)
         http-url (str scheme "://" server-name
                       (when server-port (str ":" server-port))
                       uri
                       (when query-string (str "?" query-string)))
         conn-mgr (or connection-manager
                      (get-conn-mgr async? req))
         proxy-ignore-hosts (or proxy-ignore-hosts
                                #{"localhost" "127.0.0.1"})
         ^RequestConfig request-config (request-config req)
         ^HttpClientContext context (http-context request-config)
         ^HttpUriRequest http-req (http-request-for
                                   request-method http-url body)]
     (when-not (conn/reusable? conn-mgr)
       (.addHeader http-req "Connection" "close"))
     (when-let [cookie-jar (or cookie-store *cookie-store*)]
       (.setCookieStore context cookie-jar))
     (when-let [[user pass] digest-auth]
       (.setCredentialsProvider
        context
        (doto (credentials-provider)
          (.setCredentials (AuthScope. nil -1 nil)
                           (UsernamePasswordCredentials. user pass)))))
     (when-let [[user password host domain] ntlm-auth]
       (.setCredentialsProvider
        context
        (doto (credentials-provider)
          (.setCredentials (AuthScope. nil -1 nil)
                           (NTCredentials. user password host domain)))))
     (when (and proxy-user proxy-pass)
       (let [authscope (AuthScope. proxy-host proxy-port)
             creds (UsernamePasswordCredentials. proxy-user proxy-pass)]
         (.setCredentialsProvider
          context
          (doto (credentials-provider)
            (.setCredentials authscope creds)))))
     (if multipart
       (.setEntity ^HttpEntityEnclosingRequest http-req
                   (mp/create-multipart-entity multipart))
       (when (and body (instance? HttpEntityEnclosingRequest http-req))
         (if (instance? HttpEntity body)
           (.setEntity ^HttpEntityEnclosingRequest http-req body)
           (.setEntity ^HttpEntityEnclosingRequest http-req
                       (if (string? body)
                         (StringEntity. ^String body "UTF-8")
                         (ByteArrayEntity. body))))))
     (doseq [[header-n header-v] headers]
       (if (coll? header-v)
         (doseq [header-vth header-v]
           (.addHeader http-req header-n header-vth))
         (.addHeader http-req header-n (str header-v))))
     (when (opt req :debug) (print-debug! req http-req))
     (if-not async?
       (let [^CloseableHttpClient client (http-client req conn-mgr http-url
                                                      proxy-ignore-hosts)]
         (try
           (build-response-map (.execute client http-req context) req conn-mgr)
           (catch Throwable t
             (when-not (conn/reusable? conn-mgr)
               (.shutdown conn-mgr))
             (throw t))))
       (let [^CloseableHttpAsyncClient client
             (http-async-client req conn-mgr http-url proxy-ignore-hosts)]
         (.start client)
         (.execute client http-req context
                   (reify org.apache.http.concurrent.FutureCallback
                     (failed [this ex]
                       (when-not (conn/reusable? conn-mgr)
                         (.shutdown conn-mgr))
                       (if (:ignore-unknown-host? req)
                         ((:unknown-host-respond req) nil)
                         (raise ex)))
                     (completed [this resp]
                       (try
                         (respond (build-response-map resp req conn-mgr))
                         (catch Throwable t
                           (when-not (conn/reusable? conn-mgr)
                             (.shutdown conn-mgr))
                           (raise t))))
                     (cancelled [this]
                       (if-let [oncancel (:oncancel req)]
                         (oncancel))
                       (when-not (conn/reusable? conn-mgr)
                         (.shutdown conn-mgr))))))))))
clj-http/client.clj
(def default-middleware
  "The default list of middleware clj-http uses for wrapping requests."
  [wrap-request-timing
   wrap-async-pooling
   wrap-header-map
   wrap-query-params
   wrap-basic-auth
   wrap-oauth
   wrap-user-info
   wrap-url
   wrap-redirects
   wrap-decompression
   wrap-input-coercion
   ;; put this before output-coercion, so additional charset
   ;; headers can be used if desired
   wrap-additional-header-parsing
   wrap-output-coercion
   wrap-exceptions
   wrap-accept
   wrap-accept-encoding
   wrap-content-type
   wrap-form-params
   wrap-nested-params
   wrap-method
   wrap-cookies
   wrap-links
   wrap-unknown-host])

(defn wrap-request
  "Returns a batteries-included HTTP request function corresponding to the given
  core client. See default-middleware for the middleware wrappers that are used
  by default"
  [request]
  (reduce (fn wrap-request* [request middleware]
            (middleware request))
          request
          default-middleware))

(def ^:dynamic request
  "Executes the HTTP request corresponding to the given map and returns
  the response map for corresponding to the resulting HTTP response.
  In addition to the standard Ring request keys, the following keys are also
  recognized:
  * :url
  * :method
  * :query-params
  * :basic-auth
  * :content-type
  * :accept
  * :accept-encoding
  * :as
  The following keys make an async HTTP request, like ring's CPS handler.
  * :async?
  * :respond
  * :raise
  The following additional behaviors are also automatically enabled:
  * Exceptions are thrown for status codes other than 200-207, 300-303, or 307
  * Gzip and deflate responses are accepted and decompressed
  * Input and output bodies are coerced as required and indicated by the :as
  option."
  (wrap-request #'core/request))

(defn- request*
  [{:keys [async?] :as req} [respond raise]]
  (if async?
    (if (some nil? [respond raise])
      (throw (IllegalArgumentException.
              "If :async? is true, you must pass respond and raise"))
      (request (dissoc req :respond :raise) respond raise))
    (request req)))

(defn post
  "Like #'request, but sets the :method and :url as appropriate."
  [url & [req & r]]
  (check-url! url)
  (request* (merge req {:method :post :url url}) r))

長々とコピペしてしまいましたが、この内引っかかったのはミドルウェアである wrap-exceptions についてです。ここで、悪い(?)のは clj-http ではなく clj-oauth の方であるということを前もってお伝えしておきます。

clj-http/client.clj
(defn- exceptions-response
  [req {:keys [status] :as resp}]
  (if (unexceptional-status? status)
    resp
    (if (false? (opt req :throw-exceptions))
      resp
      (if (opt req :throw-entire-message)
        (throw+ resp "clj-http: status %d %s" (:status %) resp)
        (throw+ resp "clj-http: status %s" (:status %))))))

(defn wrap-exceptions
  "Middleware that throws a slingshot exception if the response is not a
  regular response. If :throw-entire-message? is set to true, the entire
  response is used as the message, instead of just the status number."
  [client]
  (fn
    ([req]
     (exceptions-response req (client req)))
    ([req response raise]
     (client req
             (fn [resp]
               (response (exceptions-response req resp)))
             raise))))

一見わかりづらいですが、このコードは、 clj-http 側も例外を投げ得るということを意味しています。例外を投げるその箇所は throw+ です。詳細は省きますが、これは clj-http が依存している slingshot というユーティリティライブラリが実装している、エラーメッセージを指定のフォーマットに被せつつ clojure.lang.ExceptionInfo に包んで throw してくれるというものです。 ExceptionInfo は RuntimeException のサブクラスです。他の例外クラスと同じようにエラーメッセージと具体的な原因(を意味する例外クラス)を内蔵して stacktrace に流せるというのはもちろんですが、それに加えてより詳細なエラー情報をマップという形で残しておくことができます(これだけでも実際便利)。さて、話を戻しましょう。 throw+ は ExceptionInfo を throw します。 400 番台や 500 番台のレスポンスを返された場合、 clj-http 側から返ってくるのはレスポンスマップではなくて例外クラスなのです。
翻って、 clj-oauth の check-success-response 関数を見直してみましょう。

(defn- check-success-response [m]
  (let [code (:status m)]
    (if (or (< code 200)
            (>= code 300))
      (throw (new Exception (str "Got non-success code: " code ". "
                                 "Content: " (:body m))))
      m)))

問題だらけです…。

  • 常に OAuth API のレスポンスマップを参照できる前提のコードである。
    前述の通り、 clj-http が ExceptionInfo を throw した場合、返ってくるのは例外です。それが返ってきた時点でレスポンスマップの参照にはひと工夫必要になりますが、その事が考慮されていませんでした。

  • 無意味な Exception を投げようとしている。(ついでに言うと、 Exception という万物の祖例外にメッセージを投げるという雑なことをしてるのもNG)
    post 関数から例外が返ってきた場合は、その例外が返ってきた時点で正常時の残りの処理はすべて破棄されるため、 request-token 関数側での throw は何の意味ももちません。仮に動作したとして、エラーの種類をより詳細に定義してくれている clj-http からの非常に助かる例外を完全に無視することになります。案の定、通信結果としてのエラーは clj-http 側によって投げられる ExceptionInfo しか観測することができませんでした。 HTTP 通信の結果も、 400 番台にせよ 500 番台にせよ、ステータスコードがちゃんと返ってくるということは RuntimeException に分類すべきもののはずですが、検査例外でもある万物の祖 Exception に丸投げしてしまっていて、やはりよろしくありません。

  • 300 番台までエラー扱いしている
    HTTP 通信を実装する人間にって、あまり放っておけないことをしていました。確かに oauth/request_token や oauth/access_token からリダイレクトが発生することはないですが、それにしても clj-oauth 側でのエラーの定義が雑すぎます。せめてそこは 400 にしておくべきでしょう(いずれにしても不要な部分ですが…)。

第二の面倒: 余計なお世話

次に post-request-body-decodedform-decode の実装を見直します。

oauth/client.clj
(defn form-decode
  "Parse form-encoded bodies from OAuth responses."
  [s]
  (if s
    (into {}
          (map (fn [kv]
                 (let [[k v] (split kv #"=")
                       k (or k "")
                       v (or v "")]
                   [(keyword (sig/url-decode k)) (sig/url-decode v)]))
               (split s #"&")))))

(defn post-request-body-decoded [url & [req]]
  (form-decode
   (:body (check-success-response
           (httpclient/post url req)))))

まず私は、 request-token 関数の結果として、 Ring SPEC と同じ状態のレスポンスマップが返されることを期待していました。しかし、実際に返されたのは上記にもあるように :body 部だけ抜き取られたデータでした。(まあ前もって clj-oauth のソースを読んでなかった私も私なんですが…)
これらは、2つとも正常時には生文字列が返されるという前提で実装されています。実際、 request token や access token を正常に取得できた場合の応答内容は、 token, token_secret, etc... をURLのクエリパラメータのように繋げただけのただの文字列なので、その点では間違いではありません。しかし、異常時にはまともに機能しません。その理由を後述します。(成功時にレスポンスマップの残りのフィールドをすべて破棄しているのも個人的にはNGです…)

蛇足ですが、 clj-oauth の当該部分の実装があまりにアレだったので、私の方から一度 Pull Request を投げています。1年近く前から他の Pull Request の確認も止まってるっぽいので、反映なりフィードバックなりが為されることはないんだろうなと半分諦めていますが。自作しようか…。

第三の面倒: 書ききれぬテスト

以下は、 form-decode 関数が、異常時に全く機能しなくなる理由でもあります。
次の内、すべてが(特に Twitter の) request_token APIから返り得るステータスコードとレスポンスの組み合わせです。

  1. 200 OK, 生文字列
  2. 400 Bad Request, JSON
  3. 401 Unauthorized, XML
  4. 401 Unauthorized, JSON

200 番のパターンは言わずもがななので割愛します。これから書くのは400番台のパターンについてです。
400 番で JSON が返ってくる場合は、 Authorization ヘッダがないか、あってもフィールドが不完全だったことを意味しています。"Could not authenticate you." のメッセージを込めた JSON が送られてくるはずです。
401 番で同様に JSON が返ってくる場合は、 oauth_timestamp の数値が不正であるか、 consumer_key/consumer_secret や oauth_token などのメッセージダイジェストを生成するためのパラメータの値が不正であることを意味しています。それぞれのエラーに合ったメッセージを込めた JSON が送られてくるはずです。

"oob" に絡んだくそったれなエラー

401 番で XML が返ってくる場合は、 Twitter に desktop application 扱いされていることを意味しています。つまり、 dev.twitter.com でのアプリの設定で Callback URL の設定をし忘れています。これが非常に面倒で、最初の最初でしかその存在を確認できないのです。 dev.twitter.com のアプリ設定画面の Callback URL は一度設定すると二度と空にはできません。また一度でも URL を登録すると、その瞬間から(例え後から "oob" に直しても)ウェブアプリケーション認定されてしまいます。なので、 Twitter からこのエラーが飛んでくる可能性があることまではわかっても、利用者であるサード側がウェブアプリケーションとして何らかの Twitter API を利用する場合は、まずはその設定を済まさなければテストコードを含めて他のどんな実装も通せません。確認できても中身は XML なので、他のエラーと同様に JSON をパースするつもりでいると、テストコードのどのパターンとも関係ない例外が飛んできて非常に時間を吸われてしまいます。フォーマット統一しろよ…!
特に、一から OAuth ライブラリやアプリケーションを実装することを考えている方はこの辺は要注意です。

Twitter からのリダイレクトを確認する術がない。

OAuth APIによる認証の流れは以下の5つです。

  1. Authorization ヘッダと oauth_callback パラメータを揃えた上で request_token API を呼んで、 request_token をもらう。
  2. authorize/authenticate へリダイレクトさせる。このとき、 request_token を URL のクエリパラメータに乗せる。(他サイトへの画面遷移なので、ここで一度 request_token をセッション等に保存しておく)。
  3. OAuth サーバ側は、認証ボタンを押したかどうかの結果を oauth_callback の URL へ渡した状態でリダイレクトさせる。
  4. (認証ボタンを押下したなら)リダイレクトで飛んできたリクエストから oauth_verifier を抜き出す。
  5. oauth_verifier を Authorization ヘッダに加えて、 oauth_signature を oauth_token_secret(request_token) でハッシュ化した上で access_token API を呼んで、 access_token をもらう。

2 と 3 の間、 3 と 4 の間でそれぞれ別サイトへの(または別サイトからの)リダイレクトが発生することを考慮してください。また、リダイレクトが要るということから双方の処理を一度区切らなければいけません。 2 と 3 の間は自分からなのでどうとでもなるのですが、 3 と 4 については OAuth API からのリクエストを待たないといけないので、テストコードだけではどうにもなりませんでした。試しに POST 側の authenticate に必要なパラメータを添えてリクエストを送ってみましたが、エラーが返ってくるだけでした。また実際にその結果を確認できるようにするためには、既に稼働中でもある、実在するサービスのドメインが必要になることもあり、実装段階では断念せざるを得ませんでした。

このように、 OAuth API 周辺は正常系と異常系でフォーマットが整っておらず、利用する側としても非常に手がかかります。また、これが原因で、 Twitter からのクエリ文字列風の Request Token レスポンスや Access Token レスポンスを受け取ることを前提とした form-decode 関数は、エラーレスポンスに対しては盛大な失敗を引き起こします。正直なところ、使っていて不便なところの方が目立ちました…。

1つのツイートを取得し、表示する。

さて、いよいよツイート単体を取得するコードを書いていきます。まずは、初回からのモットー通りに実装コードの品質を担保するテストコードから書いていきましょう。確か、初回にこんな感じのテストになると言っていたような気がします。

手始めに、 URLから渡されてきたid と Twitterのstatus_id とを対応させてみましょう。まずはテストコードを作成します。ここでチェックすべきポイントは以下の2つです:

  • 正しいデータを渡した時に HTTP Status 200 が返ってくるか。
  • 200で返ってきた時に受け取ったJSONフォーマット が tweets.jsonの形 に従っているか

ここでいう「正しいデータを渡した」とは以下の4つの条件に合致したものを指します:

  • Twitter側が指示した通りにOAuthパラメータを渡している。
  • リクエストメソッドが合っている。
  • 必須パラメータを渡している。
  • 渡したIDと同じステータスIDのツイートが返される。

ここで、前回頂いた指摘のひとつである「ハンドラとビューとで責務をわける」ことを意識しながら実装していこうと思います。なので、それらがごちゃ混ぜになってしまっている第二回のテストコード及び実装コードは忘れてください。

id の一致を確かめる

第一回では曖昧なままだった /:user/status/:id のテストコードを具体的に書いていきましょう。まずは id 周辺においてエラーの場合を考えてみます。 id の範囲は、 Tweets オブジェクトのドキュメントによると、

The integer representation of the unique identifier for this Tweet. This number is greater than 53 bits and some programming languages may have difficulty/silent defects in interpreting it. Using a signed 64 bit integer for storing this identifier is safe. Use id_str for fetching the identifier to stay on the safe side. See Twitter IDs, JSON and Snowflake.

とあります。Snowflake というツールを利用しているようです(当記事では省略するので、詳細は各自検索等してください)。 53 bit 以上である(いくつかのプログラミング言語では上手く扱えないかもしれないので符号付き 64 bit整数で扱うとよい)という点が若干気になりますが、その 53 bit 以上という範囲の境界値をサードパーティのテストで確かめるのはあまり意味のあることではないので無視します。よって、今回は

  • 必須パラメータ(id)を渡している。
  • 渡したIDと同じステータスIDのツイートが返される(ユーザ名の一致も同時に確かめる)。

の2点に絞ります(リクエストメソッドと OAuth パラメータは実装コード側で固定できるため省きます)。 id を基準に、 id が渡された場合、渡されなかった場合および id が渡されていてもその id が正しい時と間違っている時それぞれの HTTP ステータスと JSON の状態を確かめていきましょう。それらを記述したテストコードに結果が準拠するように実装を行っていきます。上記の条件を考慮すると、テストコードはこうなりました。

routes_test.clj
(deftest statuses-show-test
  (testing "Without access token and access token secret:"
    (let [response (for-test (mock/request :get "/test/func_hs/status/777871930208587776"))]
      (is (= 400 (:status response))
          (str "HTTP status is: "
               (:status response)
               ", Application responded: "
               (:body response)))))
  (testing "With access token and access token secret:"
    (let [request (mock/request :get "/test/func_hs/status/777871930208587776")
          request (merge request {:cookies {"access-token" {:value <access-token> :path "/"}
                                            "access-token-secret" {:value <access-tokken-scret> :path "/"}}})
          response (for-test request)]
      (is (= 200 (:status response))
          (str "HTTP status is: "
               (:status response)
               ", Application responded: "
               (:body response)))
      (is (= "func_hs" (get-in response [:body :user :screen_name]))
          "Screen name didn't match with specified screen name parameter.")))
  (testing "With access token and access token secret, but when username and status_id did not match."
    (let [request (mock/request :get "/test/func_hs/status/777871930208587775")
          request (merge request {:cookies {"access-token" {:value <access-token> :path "/"}
                                            "access-token-secret" {:value <access-token-secret> :path "/"}}})
          response (for-test request)]
      (is (= 404 (:status response))
          (str "HTTP status is:"
               (:status response)
               ", Application responded:"
               (:body response))))))

一方で、その対となる実装コードは以下のようになりました。

statuses.clj
(ns <project-name>.handlers.statuses
  (:require [clojure.data.json :refer [read-str]]
            [clj-http.client :as http]
            [compojure.coercions :refer [as-int]]
            [compojure.core :refer [GET context]]
            [compojure.response :refer [render Renderable]]
            [confluentwee.oauth :refer [get-credentials]]
            [confluentwee.views.statuses :refer [index-view show-view]]
            [noir.util.route :refer [def-restricted-routes]]
            [oauth.client :refer [build-request]]
            [ring.util.response :as response])
  (:import [clojure.lang ExceptionInfo]
           [clj_http.headers HeaderMap]))

(defn- show-handler
  [user-name status-id]
  (try
    (let [params {:id status-id}
          credentials (get-credentials :get
                                       "https://api.twitter.com/1.1/statuses/show.json"
                                       params)
          response (http/get "https://api.twitter.com/1.1/statuses/show.json"
                             (merge {:query-params params} (build-request credentials)))
          response-json (update response
                                :body
                                #(read-str % :key-fn keyword))]
      (if (= user-name (get-in response-json [:body :user :screen_name]))
        response-json
        (response/status 404 {:errors [{:code 34 :message "Sorrry, that page does not exist"}]})))
    (catch ExceptionInfo e
      (let [error-response (ex-data e)
            error-response-json (update error-response
                                        :body
                                        #(read-str % :key-fn keyword))]
        error-response-json))))

(defn- index-handler
  []
  (try
    (let [params {:count 200}
          credentials (get-credentials :get
                                       "https://api.twitter.com/1.1/statuses/home_timeline.json"
                                       params)
          response (http/get "https://api.twitter.com/1.1/statuses/home_timeline.json"
                             (merge {:query-params params} (build-request credentials)))
          response-json (update response
                                :body
                                #(read-str % :key-fn keyword))]
      response-json)
    (catch ExceptionInfo e
      (let [error-response (ex-data e)
            error-response-json (update error-response
                                        :body
                                        #(read-str % :key-fn keyword))]
        error-response-json))))

(def-restricted-routes statuses-routes
  (context ["/:user" :user #"[0-9A-Za-z_]+"] [user]
    (GET "/" []
      (-> (index-handler) index-view))
    (GET ["/status/:id" :id #"[0-9]+"] [id :<< as-int]
      (-> (show-handler user id) show-view))))

(def-restricted-routes test-statuses-routes
  (context ["/:user" :user #"[0-9A-Za-z_]+"] [user]
    (GET "/" []
      (index-handler))
    (GET ["/status/:id" :id #"[0-9]+"] [id :<< as-int]
      (show-handler user id))))
handler.clj
(ns <project-name>.handler
  (:require [compojure.core :refer [GET context routes]]
            [compojure.route :as route]
            [confluentwee.oauth :refer [oauth-routes]]
            [confluentwee.handlers.statuses :refer [statuses-routes test-statuses-routes]]
            [noir.cookies :as cookies]
            [noir.response :refer [redirect status]]
            [noir.session :as session]
            [noir.util.middleware :refer [app-handler]]
            [noir.util.route :refer [def-restricted-routes]]
            [ring.middleware.cookies :as middleware])
  (:import [java.text SimpleDateFormat]
           [java.util Locale]))

(def-restricted-routes app-routes
  (GET "/" [] "Hello world.")
  oauth-routes
  statuses-routes)

(def-restricted-routes test-routes
  (context "/test" []
    oauth-routes
    test-statuses-routes))

(def app (app-handler [app-routes])
(def for-test (app-handler [test-routes]))
views/statuses.clj
(ns <project-name>.views.statuses
  (:require [confluentwee.views.errors :refer [error-view]]
            [hiccup.core :refer [html]]
            [hiccup.def :refer [defhtml defelem]]
            [noir.response :as response]))

(defhtml index-html [response]
  "チョットマッテテネ")

(defhtml show-html [response]
  (let [body (:body response)
        user (:user body)]
    [:div.tweet [:div.user [:a {:href (str "https://twitter.com/" (:screen_name user))}
                               [:img.avatar {:src (:profile_image_url user) :alt (str "@" (:screen_name user))}]
                               [:strong.name {:title (str "@" (:screen_name user) " を表示")}
                                             (:name user)]
                               [:span.screen-name (:screen_name user)]]]
                [:div.text (:text body)]
                [:div.created-at (:created_at body)]
                [:div.actions]]))

(defn index-view [response]
  (if (<= 200 (:status response) 299)
    (update response :body #(html (index-html %)))
    (error-view response)))

(defn show-view [response]
  (if (<= 200 (:status response) 299)
    (update response :body #(html (show-html %)))
    (error-view response)))

前回から変わったことは以下の点です。

  • テスト用の Routes を作成した。
  • アクセストークンの Cookie をリクエスト時に直接渡している。
  • def-restricted-routesapp-handler
  • ハンドラ用のファイルに Routes を書いている。

テスト用の Routes

今回の変更で、まずはハンドラとビューとで処理を切り分ける事に成功しました。これにより、ハンドラとビューとで別々に単体テストを行えるようになります。しかし、本番用の Routes ではそれらを繋げる必要があるため、このままでは切り分けた意味がありません。そこで、まずはハンドラのテストを容易にするために、それ専用の Routes(for-test) を作成し、ハンドラだけを実行してその結果を見れるようにしました。その結果、まだ HTML 出力を行う前の段階のレスポンスマップをテスト時に見れるようになりました。ring に依存しているライブラリによるウェブアプリケーションのテストはハンドラにリクエストマップを渡せばいいだけなので、そういう意味ではシンプルに使えます。

手作り Cookie

テストコード中で、ハンドラに直接 Cookie を渡しています。これは、 lib-noir では外側のスコープからはハンドラ本体と同じように Cookie の状態を参照することができないからです。ただし、これはバグではなく、 binding マクロを使って、動的変数である Cookie の var をスレッドローカルな値として管理しているためです(clojure本体の仕様に沿っているだけと言ってもいいかもしれません)。いわゆる「あなたと私では見えているものが違う」ってやつですね。(?)
さて、そんな lib-noir の Cookie は以下のようなコードで動いています。

noir/cookies.clj
(declare ^:dynamic *cur-cookies*)
(declare ^:dynamic *new-cookies*)

; 中略

(defn noir-cookies [handler]
  (fn [request]
    (binding [*cur-cookies* (:cookies request)
              *new-cookies* (atom {})]
      (when-let [final (handler request)]
(assoc final :cookies (merge (:cookies final) @*new-cookies*))))))

ご覧のように、その動的スコープはミドルウェアである noir-cookies が生成しています。 noir.util.test に with-noir というマクロがあり、これを使えばテストコード側でも lib-noir のクッキーやセッションを使うことができます。しかし、繰り返しになりますがハンドラ本体側とテストコード側とで見えている Cookie の状態が違うため、今回のテストでは使えません。よって、テストコード側で直接リクエストマップに埋め込むという形にしています。

def-restricted-routesapp-handler

前回までは compojure.core/defroutes でルートを作っていたのに対して、今度は def-restricted-routesapp-handler なるもので定義しています。これらはlin-noir 用に defroutes をラップしたマクロと、それらのルートへの設定を色々できるようにした関数で、それぞれ noir.util.route と noir.util.middleware にあります。

noir/util/route.clj
(defmacro def-restricted-routes
  "accepts a name and one or more routes, prepends restricted to all
   routes and calls Compojure defroutes, eg:
   (def-restricted-routes private-pages
     (GET \"/profile\" [] (show-profile))
     (GET \"/my-secret-page\" [] (show-secret-page)))
   is equivalent to:
   (defroutes private-pages
     (GET \"/profile\" [] (restricted (show-profile)))
     (GET \"/my-secret-page\" [] (restricted (show-secret-page))))"
  [name & routes]
  (concat
   ['compojure.core/defroutes name]
   (clojure.walk/prewalk
    (fn [item#]
      (if (and (coll? item#)
               (symbol? (first item#))
               (some #{(resolve (first item#))}
                     [#'compojure.core/GET
                      #'compojure.core/POST
                      #'compojure.core/PUT
                      #'compojure.core/DELETE
                      #'compojure.core/HEAD
                      #'compojure.core/OPTIONS
                      #'compojure.core/PATCH
                      #'compojure.core/ANY]))
         (concat (take 3 item#) [(cons 'noir.util.route/restricted (drop 3 item#))])
         item#))
routes)))
noir/util/middleware.clj
(defn app-handler
  "creates the handler for the application and wraps it in base middleware:
  - wrap-request-map
  - site-defaults
  - wrap-multipart-params
  - wrap-noir-validation
  - wrap-noir-cookies
  - wrap-noir-flash
  - wrap-noir-session
  :base-url        - optional key to set the base URL for Hiccup templates
  :session-options - deprecated: use :session key in :ring-defaults instead
                     optional map specifying Ring session parameters, eg: {:cookie-attrs {:max-age 1000}}
  :store           - deprecated: use sesion-options instead!
  :multipart       - an optional map of multipart-params middleware options
  :middleware      - a vector of any custom middleware wrappers you wish to supply
  :ring-defaults   - pass in optional map for initializing ring-defaults, uses site-defaults if none passed
                     see https://github.com/ring-clojure/ring-defaults
                     for more details
  :formats         - optional vector containing formats that should be serialized and
                     deserialized, eg:
                     :formats [:json-kw :transit-json :edn]
                  available formats:
                  :json - JSON with string keys in :params and :body-params
                  :json-kw - JSON with keywordized keys in :params and :body-params
                  :yaml - YAML format
                  :yaml-kw - YAML format with keywordized keys in :params and :body-params
                  :edn - Clojure format
                  :yaml-in-html - yaml in a html page (useful for browser debugging)
                  :transit-json Transit over JSON format
                  :transit-msgpack Transit over Msgpack format
  :access-rules - a vector of access rules you wish to supply,
                  each rule should a function or a rule map as specified in wrap-access-rules, eg:
                  :access-rules [rule1
                                 rule2
                                 {:redirect \"/unauthorized1\"
                                  :rules [rule3 rule4]}]"
  [app-routes & {:keys [base-url session-options store multipart middleware access-rules formats ring-defaults]}]
  (letfn [(wrap-middleware-format [handler]
            (if formats (wrap-restful-format handler :formats formats) handler))]
    (-> (apply routes app-routes)
        (wrap-middleware middleware)
        (wrap-request-map)
        (wrap-defaults (dissoc (or ring-defaults site-defaults) :session))
        (wrap-base-url base-url)
        (wrap-middleware-format)
        (wrap-access-rules access-rules)
        (wrap-noir-validation)
        (wrap-noir-cookies)
        (wrap-noir-flash)
        (wrap-noir-session
          (update-in
            (or session-options (:session ring-defaults) (:session site-defaults))
[:store] #(or % (memory-store mem)))))))

lib-noir 独自のクッキーやセッションの設定はもちろん、 compojure には実装されていなかった Routes へのアクセスルールの適用も設定できるようになってます。追加の middleware も :middleware オプションの値としてベクターに包んで渡せば app-handler 側が勝手にやってくれます。また、 Routes も apply 経由でベクタの要素として展開されていくため、小分けして管理しやすい作りになっています。 lib-noir の各ミドルウェアを紹介していくと長くなるので、気になった方は各自 github で直接ご覧になってください。

ハンドラ用ファイルの中の Routes

各ハンドラ用ファイルの中に Routes を置き、 handler.clj 側で Routes を集約する構成にしているのには理由があります。それは外部から直接参照される Routes 以外のコードをできるだけ private な空間に置いておきたいからです。 Routes の var さえ public に参照可能であり、かつ、その Routes がハンドラのファイルの中に収まっていれば、他の var は private でも Routes 経由で参照していくことは十分に可能であるため、基本的にはほぼすべてのハンドラ関数を private な var にできます。

テスト結果

さて、それでは実際にテストしてみましょう

$ lein with-profile dev test

さて、ここで上手くいく場合と上手く行かない場合があります。あなたがこのコードでテストしようとして以下のようなエラーメッセージを発されることがあるかもしれません。

ERROR in (statuses-show-test) (cookies.clj:73)
Uncaught exception, not in assertion.
expected: nil
actual: java.lang.AssertionError: Assert failed: (every? valid-attr? attrs)
...

この Assertion は ring.middleware.cookies で行われています。ちょっとそのコードを覗いてみましょう。

ring/middleware/cookies.clj
(defn- valid-attr?
  "Is the attribute valid?"
  [[key value]]
  (and (contains? set-cookie-attrs key)
       (not (.contains (str value) ";"))
       (case key
         :max-age (or (instance? Interval value) (integer? value))
         :expires (or (instance? DateTime value) (string? value))
         true)))

(defn- write-attr-map
  "Write a map of cookie attributes to a string."
  [attrs]
  {:pre [(every? valid-attr? attrs)]}
  (for [[key value] attrs]
    (let [attr-name (name (set-cookie-attrs key))]
      (cond
       (instance? Interval value) (str ";" attr-name "=" (in-seconds value))
       (instance? DateTime value) (str ";" attr-name "=" (unparse rfc822-formatter value))
       (true? value)  (str ";" attr-name)
       (false? value) ""
:else (str ";" attr-name "=" value)))))

private な関数である write-attr-map 関数が、他の private 関数である valid-attr? 関数を使って、Cookie として書き出すことになる各属性が正しいかどうかを引数を受け取った段階でチェックしようとしています。この Assertion 自体は特に悪いわけでもなく、むしろ Cookie をセキュアにやりとりするのであれば正常な仕様であると言えます(普通にあちこちで違っている可能性のあるフォーマットを pre-Assert で確かめるのはそもそもどうなんだというのはさておき)。上記のメッセージが出た場合、この内の Expires 属性の日付フォーマットの部分で引っかかっている可能性があります(実際に私もそうでした)。具体的には以下の 2 点が原因となっています。

  • Twitter から送られてくる Cookie の Expires が RFC822 に準拠していない。
  • 日付を変換する際の Locale 設定が違う。

Twitterの独自フォーマットに振り回される

一番の原因は Twitter がネットワーク通信のメッセージフォーマットについての規格を策定した RFC822 に準拠していない日付文字列をセットしていることにあります。実際にどういうフォーマットで送られてきているかみてみましょう。

$ curl -I https://api.twitter.com/1.1/statuses/show.json
; 中略
set-cookie: guest_id=v1%3A148268524235475611; Domain=.twitter.com; Path=/; Expires=Tue, 25-Dec-2018 17:00:42 UTC

RFC822 では日付フォーマットを EEE, dd MMM yyyy HH:mm:ss Z に合わせるように規格を決めています。対して、 Twitter から返ってきてるのは EEE, dd-MMM-yyyy HH:mm:ss z です。これでは弾かれても無理はありませんね。

Localeゥ…

Twitter に原因の大部分があることは突き止めましたが、ではそれさえどうにかなればいいのかというとそうでもありません。Twitter 側がそのへんを修正してくれても、こちら側もある部分で Twitter 側に合わせなければいけません。それが Locale の問題です。 RFC822 の日付フォーマットは、米国の Locale で日付を読み込んだ場合にだけ正しさを発揮します。現に write-attr-map 関数がその pre-Assert のために依存している clj-time (Joda Timeを使って日付を処理しています) も米国の Locale を前提に RFC822 のフォーマットに日付を当てはめています。(マルチバイトを前提にしないと中々やりづらい日本の環境的には厳しいものがありますね…)

超法規的措置

では、これら 2 つの問題を確実に解決するにはどうすればいいでしょうか。本日付でここまで記事を書き上げる過程で、私は以下の方法で Expires の再設定を試みました。

  • 一度マップにして update-in 関数を適用して値を変えた後で、再度ヘッダに戻す
  • 頑張って正規表現でゴリ押し

結論から言うと、上記 2 パターンではどれも成功しませんでした。なので、一度その部分を忘れて、一度テストをパスして実際の運用にこぎつける方向に切り替えようと思います。そこで私は以下のように強引に pre-Assert をカットする道を選びました。

handler.clj
(ns confluentwee.handler
  (:require [compojure.core :refer [GET context routes]]
            [compojure.route :as route]
            [confluentwee.oauth :refer [oauth-routes]]
            [confluentwee.handlers.statuses :refer [statuses-routes test-statuses-routes]]
            [noir.cookies :as cookies]
            [noir.response :refer [redirect status]]
            [noir.session :as session]
            [noir.util.middleware :refer [app-handler]]
            [noir.util.route :refer [def-restricted-routes]]
            [ring.middleware.cookies :as middleware])
  (:import [java.text SimpleDateFormat]
           [java.util Locale]))

(def-restricted-routes app-routes
  (GET "/" [] "Hello world.")
  oauth-routes
  statuses-routes)

(def-restricted-routes test-routes
  (context "/test" []
    oauth-routes
    test-statuses-routes))

;いいか?絶対に真似するなよ?
(defn wrap-attr-map
  [handler]
  (fn [request]
    (with-redefs [middleware/write-attr-map (alter-meta! #'middleware/write-attr-map dissoc :pre)]
      (handler request))))

; 絶対にだぞ!?
(def app (-> (app-handler [app-routes])
              wrap-attr-map)
(def for-test (-> (app-handler [test-routes])
                   wrap-attr-map))

サラッとなんかミドルウェアを生やしていますが、 with-redefs マクロはそのベクタの中にある var の定義をそのスコープが有効な間だけ丸ごと書き換えてしまいます。一見悪いもののように見えますが、公式のドキュメントにもあるようにテスト時など、あえてコードをダウンさせる必要があるケースでは非常に有用です。実際、熟練した Clojure 使いの間では割とよく使われる手段のようです(最近知りました)。ただし、本来はあくまでもテストコードへの適用に留めるべきだと思います(使っておいてなんですが…w)

総括

今回この記事を書くにあたって、以下のような気付きがありました。

  • それぞれのコーディングスタイルには確かにそれで納得がいくだけの何か(?)がある。
  • ハンドラとビューはできるだけ綺麗に分けた方がよい。その方がテストも書きやすいし、 Routes も組み立てやすくなる。
  • clj-oauth は思ったよりも雑な作りをしている。
  • ついでに言うと Twitter も予想外のところで雑さを発揮している。
  • それらのおかげできちっと丁寧にやろうとしている誰かがとばっちりを食らう。
  • Locale の問題って辛いな…サム(誰)
  • ちょっと Kotlin とか Rust も書きたくなってきたな…
  • vim-clojure-static が意外と便利(vimclojureが要らなくなった)
  • VSCode の Clojure 拡張のメンテナもやりたい(その内)

他の機能の実装について

今回の記事では、ツイートを 1 つとってきて表示する機能の他に home_timeline や UserStream を叩いて直近のツイートの一覧を表示する機能などの実装も紹介しようと思ったのですが、そこはフロントエンドライブラリ(フレームワークも含む)との兼ね合いも必要になりそうだなと感じており、時間的にも今回は見送ることにしました。近い内に少しずつ、 React や om の勉強も兼ねて実装を進めていって、日を改めてまた Qiita かどっかで報告しようと思います。

それでは皆さん、よいお年を。

5
2
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
2