Toyokumo Tech Blog

トヨクモ株式会社の開発本部のブログです。

Clojureで作るAPI RingMiddlewareを追加してAPIらしくする

[連載]Clojureで作るAPIの11記事目です。

前回の記事はこちらです。

tech.toyokumo.co.jp

前回記事ではルーターを追加して設定しました。 今回はその上にRing Middlewareを追加してAPIとしての体裁を整えていきます。

なお、Ring Middlewareについては次の記事で詳細に解説しています。 以降では次の記事をご理解いただいた前提で進めていきますので、まだの方は目を通しておいてください。

tech.toyokumo.co.jp

APIリクエストをロギングする

まずはリクエストとレスポンスをロギングできるようにします。そのためにRing-loggerを使います。

github.com

;; ./deps.edn
{:paths ["src" "resources"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        info.sunng/ring-jetty9-adapter {:mvn/version "0.17.6" :exclusions [org.slf4j/slf4j-api]}
        org.clojure/tools.logging {:mvn/version "1.2.4"}
        spootnik/unilog {:mvn/version "0.7.30"}
        com.stuartsierra/component {:mvn/version "1.1.0"}
        aero/aero {:mvn/version "1.1.6"}
        metosin/reitit {:mvn/version "0.5.18"}
        metosin/ring-http-response {:mvn/version "0.9.3"}
        ring/ring-devel {:mvn/version "1.9.5"}
        ;; 追加
        ring-logger/ring-logger {:mvn/version "1.1.1"}}
 :aliases {:dev {:extra-paths ["dev"]}
           :test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.68.1059"}}
                  :main-opts ["-m" "kaocha.runner"]}
           :build {:deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"}}
                   :ns-default build}}}

Handlerを修正してRing-loggerを適用します。 今回はリクエストとレスポンスのログはパスに依らず毎回出力されて欲しいのでトップレベルのMiddlewareとして追加しています。

;; ./src/cljapi/component/handler.clj
(ns cljapi.component.handler
  (:require
   [cljapi.handler.api.greeting]
   [cljapi.handler.health]
   [cljapi.router :as router]
   [com.stuartsierra.component :as component]
   [reitit.ring :as ring]
   [ring.logger :as m.logger]
   [ring.middleware.lint :as m.lint]
   [ring.middleware.reload :as m.reload]
   [ring.middleware.stacktrace :as m.stacktrace]))

(def ^:private dev-middlewares
  "開発時だけ有効化する"
  [[m.reload/wrap-reload {:dirs ["src"]
                          :reload-compile-errors? true}]
   m.lint/wrap-lint
   [m.stacktrace/wrap-stacktrace {:color? true}]])

(defn- build-handler
  [profile]
  (let [common-middlewares [m.logger/wrap-with-logger]
        middlewares (if (= profile :prod)
                      common-middlewares
                      ;; 開発用のMiddlewareは先に適用する
                      (apply conj dev-middlewares common-middlewares))]
    (ring/ring-handler
     router/router
     nil
     {:middleware middlewares})))

(defrecord Handler [handler profile]
  component/Lifecycle
  (start [this]
    (assoc this :handler (build-handler profile)))
  (stop [this]
    (assoc this :handler nil)))

REPLを立ち上げてgoを評価してcurl http://localhost:8000/api/helloを実行すると次のようなログが出ていることが確認できます。

INFO [2022-07-25 18:01:45,496] qtp1942383383-47 - ring.logger {:request-method :get, :uri "/api/hello", :server-name "localhost", :ring.logger/type :starting}
DEBUG [2022-07-25 18:01:45,496] qtp1942383383-47 - ring.logger {:request-method :get, :uri "/api/hello", :server-name "localhost", :ring.logger/type :params, :params nil}
INFO [2022-07-25 18:01:45,497] qtp1942383383-47 - ring.logger {:request-method :get, :uri "/api/hello", :server-name "localhost", :ring.logger/type :finish, :status 200, :ring.logger/ms 1}

定番のMiddlewareをまとめて入れる

Ring-Defaultsを使って定番のMiddlewareをまとめて入れます。

github.com

;; ./deps.edn
{:paths ["src" "resources"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        info.sunng/ring-jetty9-adapter {:mvn/version "0.17.6" :exclusions [org.slf4j/slf4j-api]}
        org.clojure/tools.logging {:mvn/version "1.2.4"}
        spootnik/unilog {:mvn/version "0.7.30"}
        com.stuartsierra/component {:mvn/version "1.1.0"}
        aero/aero {:mvn/version "1.1.6"}
        metosin/reitit {:mvn/version "0.5.18"}
        metosin/ring-http-response {:mvn/version "0.9.3"}
        ring/ring-devel {:mvn/version "1.9.5"}
        ring-logger/ring-logger {:mvn/version "1.1.1"}
        ;; 追加
        ring/ring-defaults {:mvn/version "0.3.3"}}
 :aliases {:dev {:extra-paths ["dev"]}
           :test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.68.1059"}}
                  :main-opts ["-m" "kaocha.runner"]}
           :build {:deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"}}
                   :ns-default build}}}

Ring-Defaultsで入る機能はAPIとしての動作にだけ働けばいいので /api 下で機能するように適用します。 そのためにrouterを編集します。

;; ./src/cljapi/router.clj
(ns cljapi.router
  (:require
   [cljapi.handler :as h]
   [reitit.ring :as ring]
   [ring.middleware.defaults :as m.defautls]))

(def ^:private ring-defaults-config
  (-> m.defautls/api-defaults
      ;; ロードバランサーの後ろで動いていると想定して、
      ;; X-Forwarded-For と X-Forwarded-Proto に対応させる
      (assoc :proxy true)))

(def router
  (ring/router
   [["/health" {:name ::health
                :handler h/handler}]
    ["/api" {:middleware [[m.defautls/wrap-defaults ring-defaults-config]]}
     ["/hello" {:name ::hello
                :handler h/handler}]
     ["/goodbye" {:name ::goodbye
                  :handler h/handler}]]]))

コメントでも書きましたが、多くの場合ロードバランサーの下で動かすことになると思うので、 X-Forwarded-ForヘッダX-Forwarded-Protoヘッダ に対応させています。

JSONの入出力に対応する

/api/helloのHandlerを次のように変更してリクエストしてみます。

;; ./src/cljapi/handler/api/greeting.clj
(ns cljapi.handler.api.greeting
  (:require
   [cljapi.handler :as h]
   [cljapi.router :as r]
   [ring.util.http-response :as res]))

(defmethod h/handler [::r/hello :get]
  [_]
  (res/ok {:greeting "Hello cljapi!!"}))

(defmethod h/handler [::r/goodbye :get]
  [_]
  (res/ok {:greeting "Goodbye!"}))
$ curl http://localhost:8000/api/hello -H "accept: application/json"
{
"servlet":"org.eclipse.jetty.servlet.ServletHandler$Default404Servlet-43135ea4",
"message":"Ring lint error: specified :body must a String, ISeq, File, or InputStream, but {:greeting "Hello cljapi!!"} was not",
"url":"/api/hello",
"status":"500"
}

エラーが返ってきます。

Handlerからはレスポンスマップとして {:status 200 :headers {} :body {:greeting "Hello cljapi!!"}} を返しています。 このbodyを自動的にJSONにしてくれる機能はまだないので、acceptヘッダに応じてJSONを返すことができるようにするMiddlewareを適用します。

そのためにMuuntajaを使います。

github.com

;; ./deps.edn
{:paths ["src" "resources"]
 :deps {org.clojure/clojure {:mvn/version "1.11.1"}
        info.sunng/ring-jetty9-adapter {:mvn/version "0.17.6" :exclusions [org.slf4j/slf4j-api]}
        org.clojure/tools.logging {:mvn/version "1.2.4"}
        spootnik/unilog {:mvn/version "0.7.30"}
        com.stuartsierra/component {:mvn/version "1.1.0"}
        aero/aero {:mvn/version "1.1.6"}
        metosin/reitit {:mvn/version "0.5.18"}
        metosin/ring-http-response {:mvn/version "0.9.3"}
        ring/ring-devel {:mvn/version "1.9.5"}
        ring-logger/ring-logger {:mvn/version "1.1.1"}
        ring/ring-defaults {:mvn/version "0.3.3"}
        ;; 追加
        metosin/muuntaja {:mvn/version "0.6.8"}
        camel-snake-kebab/camel-snake-kebab {:mvn/version "0.4.3"}
        org.clojure/core.memoize {:mvn/version "1.0.257"}}
 :aliases {:dev {:extra-paths ["dev"]}
           :test {:extra-deps {lambdaisland/kaocha {:mvn/version "1.68.1059"}}
                  :main-opts ["-m" "kaocha.runner"]}
           :build {:deps {io.github.clojure/tools.build {:git/tag "v0.8.2" :git/sha "ba1a2bf"}}
                   :ns-default build}}}

muuntajaに加えてcamel-snake-kebabcore.memoizeを加えています。

routerを変更してmunntajaを加えます。

;; ./src/cljapi/router.clj
(ns cljapi.router
  (:require
   [camel-snake-kebab.core :as csk]
   [cljapi.handler :as h]
   [clojure.core.memoize :as memo]
   [muuntaja.core :as muu]
   [muuntaja.middleware :as muu.middleware]
   [reitit.ring :as ring]
   [ring.middleware.defaults :as m.defautls]))

(def ^:private ring-defaults-config
  (-> m.defautls/api-defaults
      ;; ロードバランサーの後ろで動いていると想定して、
      ;; X-Forwarded-For と X-Forwarded-Proto に対応させる
      (assoc :proxy true)))

(def ^:private memoized->camelCaseString
  "実装上kebab-case keywordでやっているものをJSONにするときにcamelCaseにしたい。
   バリエーションはそれほどないはずなのでキャッシュする"
  (memo/lru csk/->camelCaseString {} :lru/threshold 1024))

(def ^:private muuntaja-config
  "https://cljdoc.org/d/metosin/muuntaja/0.6.8/doc/configuration"
  (-> muu/default-options
      ;; JSONにencodeする時にキーをcamelCaseにする
      (assoc-in [:formats "application/json" :encoder-opts]
                {:encode-key-fn memoized->camelCaseString})
      ;; JSON以外のacceptでリクエストされたときに返らないように制限する
      (update :formats #(select-keys % ["application/json"]))
      muu/create))

(def router
  (ring/router
   [["/health" {:name ::health
                :handler h/handler}]
    ["/api" {:middleware [[m.defautls/wrap-defaults ring-defaults-config]
                          [muu.middleware/wrap-format muuntaja-config]
                          muu.middleware/wrap-params]}
     ["/hello" {:name ::hello
                :handler h/handler}]
     ["/goodbye" {:name ::goodbye
                  :handler h/handler}]]]))

ClojureのMapのキーを書くときは :kebab-case-keyword と書きたいですが、 JSONのキーとしては "camelCase" であるのが望ましいです。 そこで :encode-key-fn としてcamel-snake-kebabの ->camelCaseString を使うようにします。 ただし、キーに使う種類のバリエーションはさほど多くないでしょうから、core.memoizeを使ってキャッシュするようにしています。

実行して確認してみます。評価して (user/go)を評価するのを忘れないでください。

$ curl http://localhost:8000/api/hello -H "accept: application/json"
{"greeting":"Hello cljapi!!"}

JSONが返ってきていることが確認できます。

おわりに

ここまででJSONを返すAPIサーバーとして動作させることができるようになりました。

コードはGitHubのリポジトリに上げてあります。 11_RingMiddlewareを追加してAPIらしくするというタグからご覧ください。

次はAPIを追加しながらRESTful APIとしての振る舞いを確認していきます。


トヨクモでは一緒に働いてくれる技術が好きなエンジニアを募集しております。

採用に関する情報を公開しております。 気になった方はこちらからご応募ください。