ClojureでAPIを書いてみた
作りたいものがあるので、引き続きClojure入門中。
やりたいこと
ここに書いた通り、データを登録するサービスをDOPスタイルで作りたい。 前回の記事(下記)では、Clojureからデータベースの操作をやってみた:
今回は Clojureでwebサーバーを書いてみたいので、POSTを受けて、リクエスト内容に応答するエンドポイントを実装する。
仕様を決める
まず頭の整理のために、仕様を OpenAPI 形式で書いてみる。 こんな感じで、好きな魚をPOSTするとメッセージが返るAPIを書きたい:
---
paths:
/v0/favorite-fish:
post:
operationId: favorite-fish-post
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
fish:
type: object
properties:
name:
type: string
example: マイワシ
description: Name of favorite fish
scientific_name:
type: string
example: Sardinops melanostictus
description: Species name in "Genus species" format
responses:
200:
content:
application/json:
schema:
summary: Echo back posted fish name
type: object
properties:
message:
type: string
example:
message: your favorite fish is マイワシ (Sardinops melanostictus), right?
検証項目が増えてしまうので、このファイルは今回作成するコードとは統合しない。
スキーマの定義
スキーマをDOPスタイルで定義したい。 データ形式でのスキーマ定義や、スキーマに基づくデータ抽出のためにMalli1というClojureライブラリを使う:
Malliを使いたいので依存に追加する:
{:deps
{metosin/malli
{:mvn/version "0.20.1"}}}
このままMalliをrequireしても依存が見つからない旨のエラーが出たので、REPLを再起動して deps.edn を再ロードした。
もう一度requireする:
(ns data-registry.schema
(:require [malli.core :as m]))
nil
↑できた。
スキーマは下記のように書ける:
(def fish-schema
[:map
[:name :string]
[:scientific_name :string]])
(def favorite-fish-request
[:map
[:fish fish-schema]])
(def favorite-fish-response
[:map
[:message :string]])
(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))
↑DOPスタイルでスキーマを定義できた。
なお、:stringの部分もMalliの機能。
REPLを起動してスキーマ検証できるか試してみる:
root@dbab3f1fb7c0:/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.schema :as s])
nil
user=> (def bad-request {:foo "bar"})
#'user/bad-request
user=> bad-request
{:foo "bar"}
user=> (s/validate-favorite-fish-request bad-request)
Execution error (ExceptionInfo) at data-registry.schema/validate-favorite-fish-request (schema.clj:22).
Invalid favorite-fish request
↑スキーマに合わないデータを渡すと、想定通り例外がスローされた。
validなデータを渡してみる:
user=> (def example-request {:fish {:name "foo" :scientific_name "bar"}})
#'user/example-request
user=> (s/validate-favorite-fish-request example-request)
{:fish {:name "foo", :scientific_name "bar"}}
↑OK!✅
ルータの実装
HTTPアダプタにはRingを、ルータにはReitit2を使う。
必要な依存を追加する:
{: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"}
+ org.clojure/tools.logging
+ {:mvn/version "1.3.1"}}}
↑ロギングも必要なので公式ライブラリを追加している。
OpenAPIスキーマで定義したエンドポイント仕様に対応するルータとハンドラをClojureで実装する:
(ns data-registry.routes
(:require
[reitit.ring :as ring]
[reitit.coercion.malli :as malli-coercion]
[ring.middleware.json :refer [wrap-json-body wrap-json-response]]
[data-registry.schema :as schema]
[clojure.tools.logging :as log]))
(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)}})
(catch Exception e
(log/error "Error in favorite-fish-handler" {:error (.getMessage e)})
{:status 500 :body {:error (.getMessage e)}})))
(defn wrap-json-with-keywords [handler]
(wrap-json-body handler {:key-fn keyword, :charset "utf-8"}))
(def routes
[["/v0/favorite-fish"
{:post {:summary "Register favorite fish"
:parameters {:body schema/favorite-fish-request}
:responses {200 {:body schema/favorite-fish-response}}
:handler favorite-fish-handler}}]])
(def app-router
(ring/ring-handler
(ring/router routes
{:data {:coercion malli-coercion/coercion
:middleware [wrap-json-with-keywords
wrap-json-response]}})))
↑Reititのおかげで、ルーティング定義とハンドラを宣言的に記述できる。
REPLからルータを確認してみる:
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.routes :as r] :reload)
nil
user=> r/routes
[["/v0/favorite-fish" {:post {:summary "Register favorite fish", :parameters {:body [:map [:fish [:map [:name :string] [:scientific_name :string]]]]}, :responses {200 {:body [:map [:message :string]]}}, :handler #object[data_registry.routes$favorite_fish_handler 0x169f4152 "data_registry.routes$favorite_fish_handler@169f4152"]}}]]
user=> (reitit.core/routes r/router)
[["/v0/favorite-fish" {:coercion #Coercion{:name :malli}, :middleware [#object[data_registry.routes$wrap_json_with_keywords 0x1b13e4fb "data_registry.routes$wrap_json_with_keywords@1b13e4fb"] #object[ring.middleware.json$wrap_json_response 0x327fd5c9 "ring.middleware.json$wrap_json_response@327fd5c9"]], :post {:summary "Register favorite fish", :parameters {:body [[:map [:fish [:map [:name :string] [:scientific_name :string]]]]]}, :responses {200 {:body [[:map [:message :string]]]}}, :handler #object[data_registry.routes$favorite_fish_handler 0x169f4152 "data_registry.routes$favorite_fish_handler@169f4152"]}}]]
user=>
↑ルータのコンパイルに成功し、ハンドラ関数のメモリアドレスが見えている。
サーバー
Ringで定義したハンドラをHTTPサーバで利用したい。 RingのJetty adapterを使うと、JVMのHTTPサーバJettyをそのまま利用できる:
{: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"}
org.clojure/tools.logging
{:mvn/version "1.3.1"}}}
サーバのコードはわずか5行:
(ns data-registry.core
(:require [ring.adapter.jetty :as jetty]
[data-registry.routes :as routes]))
(defn start-server []
(let [port (Integer/parseInt (or (System/getenv "DATA_REGISTRY_DEV_PORT") "3000"))]
(jetty/run-jetty routes/app-router {:port port})))
(defn main [&args]
(start-server))
REPLから起動してみる
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-18 21:03:34.638:INFO::main: Logging initialized @11477ms to org.eclipse.jetty.util.log.StdErrLog
nil
user=> (c/start-server)
2026-07-18 21:03:40.766: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-18 21:03:40.785:INFO:oejs.AbstractConnector:main: Started ServerConnector@316975be{HTTP/1.1, (http/1.1)}{0.0.0.0:31505}
2026-07-18 21:03:40.785:INFO:oejs.Server:main: Started @17625ms
↑起動できた。
このサーバーにリクエストを送ってみたい。 現在の開発環境は docker network 上に立てているので、下記のようにする:
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
9cc41376d1c8 the_network bridge local
$ yoshimoto-samonji % docker run --rm --network the_network curlimages/curl:latest curl -v repl:31505/v0/favorite-fish -d '{"fish": {"name": "foo", "scientific_name": "bar"}}' -H "Content-Type: application/json" && echo
* Host repl:31505 was resolved.
* IPv6: (none)
* IPv4: 172.20.0.5
* Trying 172.20.0.5:31505...
* Established connection to repl (172.20.0.5 port 31505) from 172.20.0.2 port 40034
% Total % Received % Xferd Average Speed Time Time Time Current
Dload Upload Total Spent Left Speed
0 0 0 0 0 0 0 0 0* usingHTTP/1.x
> POST /v0/favorite-fish HTTP/1.1
> Host: repl:31505
> User-Agent: curl/8.21.0
> Accept: */*
> Content-Type: application/json
> Content-Length: 51
>
} [51 bytes data]
* upload completely sent off: 51 bytes
< HTTP/1.1 200 OK
< Date: Sat, 18 Jul 2026 21:08:38 GMT
< Content-Type: application/json;charset=utf-8
< Transfer-Encoding: chunked
< Server: Jetty(9.4.51.v20230217)
<
{ [57 bytes data]
100 104 0 53 100 51 12814 12330 0
* Connection #0 to host repl:31505 left intact
{"message":"Your favorite fish is foo (bar), right?"}%
rindrics@pc-003406-2 yoshimoto-samonji
%
↑動いた✅
サーバー側には下記のログが出ていた:
2026-07-18 21:03:40.785:INFO:oejs.Server:main: Started @17625ms
Jul 18, 2026 9:08:38 PM clojure.tools.logging$eval11452$fn__11455 invoke
INFO: Request body: {:fish {:name foo, :scientific_name bar}}
↑問題なさそう。
せっかくなので魚の名前を入れてリクエスト:
% 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?"}
↑そんなにマイワシ好きじゃないけど。
学んだこと
- Ringを使うことで、HTTP処理を簡潔に書けた
- Reititを使うことで、ルーティングを簡潔に書けた
- Malliを使うことで、スキーマに基づいてリクエストからパラメータを抽出できた
- RingのJetty adapterを使うことで、HTTPサーバを簡潔に書けた
前回記事で検証したデータベースの操作と合わせると、これで作りたいサービス(データ登録サービス)に必要な技術要素は揃った。一方で、今回はAPIの仕様に関して、コードファーストでいくk,契約駆動にするかを別途検討しておきたい。