Clojureで実装済みのAPIを契約駆動スタイルに移行してみた

Clojureを使ったDOPスタイルの開発でも、API契約駆動で開発を進められるか検証した。

DOPスタイルでHTTPサービスを作りたいため、Clojureに入門している。 先日の記事では、ClojureライブラリMalliのスキーマ定義・データ検証機能を活かしてHTTPリクエストハンドラを書いた:

上の記事では、検証観点を絞るためにOpenAPIスキーマは手書きしただけで、Clojureコードとは統合しなかった。 今回は、既に動作しているAPIを、OpenAPIスキーマをSSOTとしたAPI契約駆動開発へ移行できるかを検証する。

対照的なアプローチとして考えられるコードファースト開発も検証対象となり得るが、以下の理由から除外した:

  • コードファーストアプローチが実装可能なことはMalliのスキーマ表現能力から自明であるため
  • API契約駆動開発ではAPIコンシューマ側がOpenAPIスキーマを定義することも可能であり、複数のチームが関与する開発においても役立つ可能性があるため

なお、本サイトにおける契約駆動開発の先行事例としては、R言語で実装したサービス(Plumberパッケージ利用)がある:

もちろんAPI契約駆動には表現力的なデメリットもあるが、パース処理のライブラリ化などは問題のパターンをある程度見てから挑戦することにする。

設計

OpenAPIスキーマをパースするタイミングについては、大きく「ビルド時」「実行時」2通りの方法がある。 実行時にパースする場合には下記のペナルティがあるため、ビルド時にスキーマをパースすることにした。

  • スタートアップオーバーヘッド: サービス起動時にスキーマをパースする処理が加わり、起動時間が増加する
  • レイテンシ増加: キャッシュなしでスキーマを繰り返しパースすると、リクエスト処理のレイテンシが増加する(パース済スキーマをメモリ保持する場合にはメモリオーバーヘッドとなる)
  • 配信オーバーヘッド: OpenAPIスキーマをサービスに同梱するか通信で取得する必要がある。後者ではネットワークIOが発生する

やってみる

YAML形式のOpenAPIスキーマをパースするので、必要な依存を追加する:

{:paths ["src"]
 :deps
 {metosin/malli
  {:mvn/version "0.20.1"} 
  
  metosin/reitit
  {:mvn/version "0.7.0"} 
  
  ring/ring-core
  {:mvn/version "1.10.0"}

  ring/ring-json
  {:mvn/version "0.5.1"}

  ring/ring-jetty-adapter
  {:mvn/version "1.10.0"}

+  clj-yaml/clj-yaml
+  {:mvn/version "0.4.0"}
+  
  org.clojure/tools.logging
  {:mvn/version "1.3.1"}}}

OpenAPIスキーマは下の記事で定義したこちらを使う。

OpenAPIスキーマのパース

パース用のClojureスクリプトは下記:

#!/usr/bin/env clj

(require '[clj-yaml.core :as yaml])

(defn json-schema->malli
  "Convert JSON Schema into Malli schema"
  [schema]
  (let [type (get schema :type)]
    (cond
      (= type "object")
      (let [properties (get schema :properties {})
            fields (map (fn [[prop-name prop-schema]]
                          [(keyword prop-name) (json-schema->malli prop-schema)])
                        properties)]
        (into [:map] fields))

      (= type "string") :string
      (= type "integer") :int
      (= type "number") :number
      (= type "boolean") :boolean
      (= type "array")
      (let [items (get schema :items)]
        (if items
          [:vector (json-schema->malli items)]
          :vector))

      :else :any)))

(defn extract-schemas [openapi]
  (let [paths (get openapi :paths)
        schemas (atom {})]
    (doseq [[path path-item] paths]
      (doseq [[_method operation] path-item]
        (when (map? operation)
          ; request schema
          (when-let [req-body (get operation :requestBody)]
            (when-let [json-schema (get-in req-body [:content :application/json :schema])]
              (let [schema-name (keyword (str (second (re-find #"/([^/]+)$" (str path))) "-request"))]
                (println "Found request schema:" schema-name)
                (swap! schemas assoc schema-name (json-schema->malli json-schema)))))

          ; response schema
          (when-let [responses (get operation :responses)]
            (doseq [[status response] responses]
              (when-let [json-schema (get-in response [:content :application/json :schema])]
                (let [status-str (or status "200")
                      schema-name (keyword (str (second (re-find #"/([^/]+)$" (str path))) "-" status-str))]
                  (println "Found response schema:" schema-name)
                  (swap! schemas assoc schema-name (json-schema->malli json-schema)))))))))
    @schemas))

(defn -main [& _args]
  (let [openapi (yaml/parse-string (slurp "schema/openapi.yaml"))
        schemas (extract-schemas openapi)
        edn-output (with-out-str (pr schemas))]
    (spit "resources/schemas.edn" edn-output)
    (println "Generated resources/schemas.edn")
    (println schemas)))

(-main)

↑1回のアクセスで値が決まらないarrayとobjectに関しては再帰を使っている。 検証用の単発スクリプトなのでひとまずこれでよしとする。

実行してみる:

% ls schema/openapi.yaml
schema/openapi.yaml
% mkdir resources
% clj scripts/generate-from-openapi.clj
WARNING: Implicit use of clojure.main with options is deprecated, use -M scripts/generate-from-openapi.clj
Found request schema: :favorite-fish-request
Found response schema: :favorite-fish-200
Generated resources/schemas.edn
{:favorite-fish-request [:map [:fish [:map [:name :string] [:scientific_name :string]]]], :favorite-fish-200 [:map [:message :string]]}

↑Malli形式のスキーマが生成された。

Malliスキーマを読み込んで名前付きスキーマとして定義

(ns data-registry.schema
  (:require [malli.core :as m]))

- (def fish-schema 
-   [:map
-    [:name :string]
-    [:scientific_name :string]])

- (def favorite-fish-request 
-   [:map
-    [:fish fish-schema]])

- (def favorite-fish-response 
-   [:map
-    [:message :string]])

+ (defonce schemas
+   (try
+     (read-string (slurp "resources/schemas.edn")) 
+     (catch Exception _
+       {})))

+ (doseq [[kw-name schema] schemas]
+   (let [sym-name (symbol (name kw-name))]
+     (eval `(def ~sym-name ~schema))))

(defn validate-favorite-fish-request
  "Validate favorite-fish request, throw if invalid"
  [data] 
  (if-let [error (m/explain favorite-fish-request data)]
    (throw (ex-info "Invalid favorite-fish request" error))
    data))

(eval)されている構文クオート中にあるチルダ(~)はunquote。Common Lispなどにおける,と同じ機能。 これによって、パース時に動的に作ったキーワードや値が展開される。

パースが雑ではあるが、前回記事に対してここまでの差分を適用するだけで、API契約駆動なコードとなる。

おまけ: レスポンスの検証

せっかくなので、DOPの基本的プラクティスである入出力の検証もやってみる。

レスポンスの検証用の関数を追加する:

  (defn validate-favorite-fish-request
    "Validate favorite-fish request, throw if invalid"
    [data]
    (if-let [error (m/explain favorite-fish-request data)]
      (throw (ex-info "Invalid favorite-fish request" error))
      data))

+ (defn validate-response
+   "Validate response data, throw if invalid"
+   [schema data]
+   (if-let [error (m/explain schema data)]
+     (throw (ex-info "Response validation failed" error))
+     data))

これをハンドラ内で利用する:

  (defn favorite-fish-handler
    "POST /v0/favorite-fish"
    [req]
    (try
      (log/info "Request body:" (:body req))
      (let [body (:body req)
-           n    (get-in body [:fish :name])
-           sn   (get-in body [:fish :scientific_name])]
-       {:status 200
-        :body {:message (format "Your favorite fish is %s (%s), right?" n sn)}})
+           validated-body (schema/validate-favorite-fish-request body)
+           n    (get-in validated-body [:fish :name])
+           sn   (get-in validated-body [:fish :scientific_name])
+           response {:message (format "Your favorite fish is %s (%s), right?" n sn)}
+           validated-response (schema/validate-response schema/favorite-fish-200 response)]
+       {:status 200 :body validated-response})
      (catch Exception e
        (log/error "Error in favorite-fish-handler" {:error (.getMessage e)})
        {:status 500 :body {:error (.getMessage e)}})))

↑レスポンスの検証でレファレンスとなっているschema/favorite-fish-200はOpenAPIスキーマのパースによって生成されたMalliスキーマ。 ちなみにリクエストの検証は前回呼び出し忘れたっぽい。

サーバーを立ち上げて使ってみる:

root@4e526447fbce:/workspace# clj

rlwrap: warning: could not set locale
warnings can be silenced by the --no-warnings (-n) option
Clojure 1.12.5
user=> (require '[data-registry.core :as c])
2026-07-21 17:37:42.099:INFO::main: Logging initialized @3389ms to org.eclipse.jetty.util.log.StdErrLog
nil
user=> (c/start-server)
2026-07-21 17:37:47.143:INFO:oejs.Server:main: jetty-9.4.51.v20230217; built: 2023-02-17T08:19:37.309Z; git: b45c405e4544384de066f814ed42ae3dceacdd49; jvm 25.0.3+9-LTS
2026-07-21 17:37:47.162:INFO:oejs.AbstractConnector:main: Started ServerConnector@189f5ed8{HTTP/1.1, (http/1.1)}{0.0.0.0:31505}
2026-07-21 17:37:47.162:INFO:oejs.Server:main: Started @8452ms

validなデータをPOSTすると:

% docker run --rm --network yoshimoto-samonji_yoshimoto-samonji curlimages/curl:latest curl repl:31505/v0/favorite-fish -s -d '{"fish": {"name": "マイワシ", "scientific_name": "Sardinops melanostictus"}}' -H "Content-Type: application/json" && echo                  
{"message":"Your favorite fish is マイワシ (Sardinops melanostictus), right?"}

↑成功レスポンスが返るが、

invalidなデータをPOSTすると:

% docker run --rm --network yoshimoto-samonji_yoshimoto-samonji curlimages/curl:latest curl repl:31505/v0/favorite-fish -s -d '{"fish": {"name": "マイワシ", "scientific_name_updated": "Sardinops melanostictus"}}' -H "Content-Type: application/json" && echo
{"error":"Invalid favorite-fish request"}

↑新たに追加したバリデーションによってエラーが返る ✅

落穂拾い: 静的解析エラーの解消

上のリファクタリングによって、実はエディタに静的解析エラーが出ていたが、下記のように参照を明示することで解決できた:

  (defn validate-favorite-fish-request
    "Validate favorite-fish request, throw if invalid"
    [data]
-   (if-let [error (m/explain favorite-fish-request data)]
-     (throw (ex-info "Invalid favorite-fish request" error))
-     data))
+   (let [schema (get schemas :favorite-fish-request)]
+     (if-let [error (m/explain schema data)]
+       (throw (ex-info "Invalid favorite-fish request" error))
+       data)))

  (defn validate-response [schema data]
    "Validate response data, throw if invalid"
-   (if-let [error (m/explain schema data)]
-     (throw (ex-info "Response validation failed" error))
-     data))
+   (let [schema (get schemas schema-key)]
+     (if-let [error (m/explain schema data)]
+       (throw (ex-info "Response validation failed" error))
+       data)))

レスポンス検証のレファレンスはMalli形式で参照した:

  (defn favorite-fish-handler
    "POST /v0/favorite-fish"
    [req]
    (try
      (log/info "Request body:" (:body req))
      (let [body (:body req)
            validated-body (schema/validate-favorite-fish-request body)
            n    (get-in validated-body [:fish :name])
            sn   (get-in validated-body [:fish :scientific_name])
            response {:message (format "Your favorite fish is %s (%s), right?" n sn)}
-           validated-response (schema/validate-response schema/favorite-fish-200 response)]
+           validated-response (schema/validate-response :favorite-fish-200 response)]
        {:status 200 :body validated-response})
      (catch Exception e
        (log/error "Error in favorite-fish-handler" {:error (.getMessage e)})
        {:status 500 :body {:error (.getMessage e)}})))

学んだこと

  • APIの動作を保ったまま、手書きのコードをAPI契約駆動のスタイルに移行できた
  • リクエスト/レスポンスの検証をDOPスタイルでスマートに書けた

課題

  • OpenAPIスキーマのパースは、表現力の点で課題が出るかもしれない。しばらく運用してみて、ライブラリへの分離などを検討するかも