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,契約駆動にするかを別途検討しておきたい。


  1. ちなみにmalliはフィンランド語(開発元のMetosin社はフィンランドの会社)で「型」という意味らしい。なるほど ↩︎

  2. reititはフィンランド語で「道(route)」という意味らしい。なるほど。 ↩︎