Toyokumo Tech Blog

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

Clojureで作るAPI RESTful APIを追加する

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

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

tech.toyokumo.co.jp

前回記事ではRing Middlewareを追加してAPIとしての体裁を整えました。 今回はRESTful APIとして振る舞うために足りない仕組みの整備をしていきます。

RESTful APIに必要な残りの要素

実際のところこれまでの実装によって、ルーティングやJSONのエンコード/デコードなどAPIとしては十分な機能を備えるようになってきました。

その上でRESTful APIとして不足している機能としてはリクエストの

  • パスパラメーター(URLに含まれるパラメーター)
  • クエリパラメーター
  • リクエストボディ

の制御があります。以降ではこの3つをまとめてリクエストパラメーターと呼ぶことにします。

これらのリクエストパラメーターを制御する上で必要なことは2種類あります。

  1. APIが要求する型の形式(契約)を満たしているか
  2. データの重複排除など、データの内容が受け付けられるものか

これら2つは性質の異なるものであるため分けて考えた方がいいというのが筆者の私見です。

1つ目はクライアントのコードが満たすべき領域で開発上の問題であるため、 エラーレスポンスの受け取り手は開発者でありエンドユーザーではないはずです。 そのためある程度画一的かつリクエスト形式の修正に役立つ情報があれば良いはずです。

2つ目はユーザーの自由な入力を想定するものに対して、リクエスト形式は正しいもののビジネスロジックに依存してエラーとなっているものです。 APIのクライアントはこのエラーレスポンスを使ってユーザーに対してエラーの内容を伝えます。 従ってエラーの内容は一定以上詳細でかつ「次にどうすればいいのか」がわかるものであるべきです。

パスパラメーターが1番に該当し、クエリパラメーターとリクエストボディが2番に該当すると考えます。

以降ではこれら2つの検証と制御ができるようにしていきます。

パスパラメーターの制御

reititのcoercion機能を使います。

reititでは型を記述するための外部ライブラリとして複数の選択肢がありますが、 この連載では上記のreititのドキュメントでも使われており、 弊社でも使用しているprismatic/schemaを使用します。

何らかの会員管理をしているアプリケーションだとしてその人のページがあるとしましょう。 そんなパスを次のように定義してみます。

;; 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.coercion.schema]
   [reitit.ring :as ring]
   [reitit.ring.coercion :as rrc]
   [ring.middleware.defaults :as m.defautls]
   [schema.core :as s]))

;;; (略)

(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
                          ;; middlewareを2つ追加
                          rrc/coerce-exceptions-middleware
                          rrc/coerce-request-middleware]
             ;; /api以下では型定義に基づいて変換するようにするための設定
             :coercion reitit.coercion.schema/coercion}
     ["/hello" {:name ::hello
                :handler h/handler}]
     ["/goodbye" {:name ::goodbye
                  :handler h/handler}]
     ;; :idで/accountの後にくるのがパスパラメーターであることを示している
     ["/account/:id" {:name ::account-by-id
                      ;; :idはInteger(java.lang.Long)であることを宣言
                      :parameters {:path {:id s/Int}}
                      ;; PUTとDELETEがあると定義
                      :put {:handler h/handler}
                      :delete {:handler h/handler}}]]]))

追加したrouteに対応するHandlerを用意します。

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

(defmethod h/handler [::r/account-by-id :put]
  [req]
  (res/ok {:method :put
           :path-params (:path-params req)}))

(defmethod h/handler [::r/account-by-id :delete]
  [req]
  (res/ok {:method :delete
           :path-params (:path-params req)}))

新しくHandlerを作ったら忘れずにrequireしておきます。

;; src/cljapi/component/handler.clj
(ns cljapi.component.handler
  (:require
   [cljapi.handler.api.account] ; 追加
   [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]))

;;; (略)

ここまでかけたら再評価するかREPLを再起動して (go) した後でリクエストを投げて確認してみます。 レスポンスのJSONをみやすくするためにjqを使用しています。

$ curl -X PUT http://localhost:8000/api/account/123 |jq .
{
  "method": "put",
  "pathParams": {
    "id": "123"
  }
}

$ curl -X DELETE http://localhost:8000/api/account/123 |jq .
{
  "method": "delete",
  "pathParams": {
    "id": "123"
  }
}

$ curl -X PUT http://localhost:8000/api/account/abc |jq .
{
  "schema": {
    "id": "Int",
    "Keyword": "Any"
  },
  "errors": {
    "id": "(not (integer? \"abc\"))"
  },
  "type": "reitit.coercion/request-coercion",
  "coercion": "schema",
  "value": {
    "id": "abc"
  },
  "in": [
    "request",
    "path-params"
  ]
}

失敗時にはエラーの内容が返されており、リクエストのどこが間違っているのかわかります。

バリデーション

次にバリデーションをできるようにしていきます。 そのためにstructを使います。

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"}
        ;; 追加
        funcool/struct {:mvn/version "1.4.0"}}
 :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}}}

本項で使うためのルート定義を追加します。

;; 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.coercion.schema]
     [reitit.ring :as ring]
     [reitit.ring.coercion :as rrc]
     [ring.middleware.defaults :as m.defautls]
     [schema.core :as s]))

;;; (略)

(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
                          rrc/coerce-exceptions-middleware
                          rrc/coerce-request-middleware]
             :coercion reitit.coercion.schema/coercion}
     ["/hello" {:name ::hello
                :handler h/handler}]
     ["/goodbye" {:name ::goodbye
                  :handler h/handler}]
     ;; 追加
     ["/account" {:name ::account
                  :get {:handler h/handler}
                  :post {:handler h/handler}}]
     ["/account/:id" {:name ::account-by-id
                      :parameters {:path {:id s/Int}}
                      :put {:handler h/handler}
                      :delete {:handler h/handler}}]]]))

まずstructのドキュメントを参考にしながらプロジェクト独自のバリデーターを作成します。 これはエラーメッセージのカスタマイズなどをするためにも必要になります。

;; src/cljapi/validator.clj
(ns cljapi.validator
    (:require
     [clojure.string :as str]
     [struct.core :as st]))

(def required
  "structのrequiredに加えて、文字列の場合に空白のみの文字列を許容しないvalidator"
  {:message "必須入力項目です"
   :optional false
   :validate #(if (string? %)
                (boolean (seq (str/trim %)))
                (some? %))})

(def string
  [st/string
   :message "文字列である必要があります"
   :coerce str/trim])

(def string-shorter-than-256
  [st/max-count 255 :message "255文字以内で入力してください"])

(def email
  [st/email :message "メールアドレスの形式が正しくありません"])

これらを使ってバリデーション関数を実装します。

;; src/cljapi/validator.clj
(ns cljapi.validation.account
  (:require
   [cljapi.validator :as v]
   [struct.core :as st]))

(def ^:private get-account-schema
  {:search [v/string v/string-shorter-than-256]})

(def ^:private post-account-schema
  {:name [v/required v/string v/string-shorter-than-256]
   :email [v/required v/string v/email]})

(defn validate-get-account
  [{:keys [:params]}]
  (st/validate params get-account-schema {:strip true}))

(defn validate-post-account
  [{:keys [:params]}]
  (st/validate params post-account-schema {:strip true}))

バリデーションの対象にはクエリパラメーターとリクエストボディがあるのに :params だけを見ているのは、 すでに設定済みのRing middleware によって2つが :params にマージされるためです。

テストを書きながら使用例を見ていきます。

;; test/cljapi/validation/account_test.clj
(ns cljapi.validation.account-test
  (:require
   [cljapi.validation.account :refer [validate-get-account validate-post-account]]
   [clojure.string :as str]
   [clojure.test :refer [deftest testing is]]))

(deftest validate-get-account-test
  (testing "正常系"
    (is (= [nil
            {:search ""}]
           (validate-get-account {:params {:search ""}})))
    (is (= [nil
            {:search "foo"}]
           (validate-get-account {:params {:search "foo"}})))
    (is (= [nil
            {:search (str/join (repeat 255 "a"))}]
           (validate-get-account {:params {:search (str/join (repeat 255 "a"))}}))))
  (testing "異常系"
    (is (= [{:search "文字列である必要があります"}
            {}]
           (validate-get-account {:params {:search 123}})))
    (is (= [{:search "255文字以内で入力してください"}
            {}]
           (validate-get-account {:params {:search (str/join (repeat 256 "a"))}})))))

(deftest validate-post-account-test
  (testing "正常系"
    (is (= [nil
            {:name "トヨクモ"
             :email "hoge@example.com"}]
           (validate-post-account {:params {:name "トヨクモ"
                                            :email "hoge@example.com"}})))
    (is (= [nil
            {:name (str/join (repeat 255 "a"))
             :email "hoge@example.com"}]
           (validate-post-account {:params {:name (str/join (repeat 255 "a"))
                                            :email "hoge@example.com"}})))
    (is (= [nil
            {:name "トヨクモ"
             :email "hoge@example.com"}]
           (validate-post-account {:params {:name "トヨクモ"
                                            :email "hoge@example.com"
                                            :foo "bar"}}))
        "余計なキーは除外される"))
  (testing "異常系"
    (is (= [{:email "必須入力項目です"
             :name "必須入力項目です"}
            {}]
           (validate-post-account {:params {}})))
    (is (= [{:email "文字列である必要があります"
             :name "文字列である必要があります"}
            {}]
           (validate-post-account {:params {:name 1
                                            :email 2}})))
    (is (= [{:name "255文字以内で入力してください"
             :email "メールアドレスの形式が正しくありません"}
            {}]
           (validate-post-account {:params {:name (str/join (repeat 256 "a"))
                                            :email "hoge"}})))))

これを使ってHandlerを実装しましょう。

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

(defmethod h/handler [::r/account :get]
  [req]
  (let [[error values] (v.account/validate-get-account req)]
    (if error
      (res/bad-request error)
      (res/ok {:validated-values values}))))

(defmethod h/handler [::r/account :post]
  [req]
  (let [[error values] (v.account/validate-post-account req)]
    (if error
      (res/bad-request error)
      (res/ok {:validated-values values}))))

;; (略)

ここまでかけたら再評価するかREPLを再起動して (go) した後でリクエストを投げて確認してみます。

$ curl -X GET -i 'http://localhost:8000/api/account?search=abc'
HTTP/1.1 200 OK
Content-Type: application/json;charset=utf-8
Content-Length: 36
Server: Jetty(10.0.9)

{"validatedValues":{"search":"abc"}}

$ curl -X GET -i 'http://localhost:8000/api/account?search=aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaab'
HTTP/1.1 400 Bad Request
Content-Type: application/json;charset=utf-8
Content-Length: 55
Server: Jetty(10.0.9)

{"search":"255文字以内で入力してください"}

$ curl -X POST -i -H "Content-Type: application/json" -d '{"name" : "トヨクモ" , "email" : "foo@example.com"}' 'http://localhost:8000/api/account'
HTTP/1.1 200 OK
Content-Type: application/json;charset=utf-8
Content-Length: 69
Server: Jetty(10.0.9)

{"validatedValues":{"name":"トヨクモ","email":"foo@example.com"}}

$ curl -X POST -i -H "Content-Type: application/json" -d '{"name" : "" , "email" : "foo@example"}' 'http://localhost:8000/api/account'
HTTP/1.1 400 Bad Request
Content-Type: application/json;charset=utf-8
Content-Length: 103
Server: Jetty(10.0.9)

{"name":"必須入力項目です","email":"メールアドレスの形式が正しくありません"}

おわりに

ここまででリクエストパラメーターの制御をAPIサーバーに追加することができました。 これで十分RESTful APIサーバーとして機能を追加していくことができる準備が整いました。

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

次は開発環境にデータベースを追加して、DBを使った開発をしていくための準備します。


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

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