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