add-malli-schemas について
MetabaseのコードベースでAPIエンドポイントにMalliスキーマを追加するスキルです。パラメーターやレスポンスの検証、スキーマの再利用、実データに基づくテストを案内します。
主な機能
- 01ルート・クエリ・本文パラメーターのスキーマ
- 02インラインおよび再利用可能なレスポンススキーマ
- 03Malli組み込みスキーマ型の案内
- 04検証タイミングとシリアライズの説明
- 05有効・無効なリクエストのテスト
活用シーン
- 01エンドポイントにパラメーター検証を追加
- 02実装データからレスポンススキーマを定義
- 03nullable・任意・日時フィールドへの対応
- 04エンドポイントテストでスキーマ動作を確認
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ metabase/ metabase --skill add-malli-schemasインストール後、Claude Code で/add-malli-schemasを入力して読み込まれたか確認します。
詳細
API エンドポイントに Malli スキーマを追加する
このスキルでは、Metabase のコードベースにある API エンドポイントへ、Malli スキーマを効率的かつ一貫して追加する方法を説明します。
参考ファイル(優れた例)
src/metabase/warehouses_rest/api.clj- 最も包括的なスキーマとカスタムエラーメッセージsrc/metabase/api_keys/api.clj- 優れたレスポンススキーマsrc/metabase/collections_rest/api.clj- 優れた名前付きスキーマのパターンsrc/metabase/timeline/api/timeline.clj- すっきりとしたシンプルな例
クイックチェックリスト
エンドポイントに Malli スキーマを追加する際は、次の点を確認してください。
- ルートパラメーターにスキーマがある
- クエリパラメーターに適切な
:optional trueと:defaultを指定したスキーマがある - リクエストボディにスキーマがある(POST/PUT の場合)
- レスポンススキーマが定義されている(ルート文字列の後に
:-を使用) - 可能な場合は
ms名前空間の既存のスキーマ型を使用している - 再利用可能な型や複雑な型には、名前付きスキーマの作成を検討している
- バリデーション失敗時に、状況に応じたエラーメッセージを追加している
基本構造
エンドポイントの完全な例
(mr/def ::Color [:enum "red" "blue" "green"])
(mr/def ::ResponseSchema
[:map
[:id pos-int?]
[:name string?]
[:color ::Color]
[:created_at ms/TemporalString]])
(api.macros/defendpoint :post "/:name" :- ::ResponseSchema
"Create a resource with a given name."
[;; Route Params:
{:keys [name]} :- [:map [:name ms/NonBlankString]]
;; Query Params:
{:keys [include archived]} :- [:map
[:include {:optional true} [:maybe [:= "details"]]]
[:archived {:default false} [:maybe ms/BooleanValue]]]
;; Body Params:
{:keys [color]} :- [:map [:color ::Color]]
]
;; endpoint implementation, ex:
{:id 99
:name (str "mr or mrs " name)
:color ({"red" "blue" "blue" "green" "green" "red"} color)
:created_at (t/format (t/formatter "yyyy-MM-dd'T'HH:mm:ssXXX") (t/zoned-date-time))}
)
よく使うスキーマパターン
- ルートパラメーター(
api/user/id/5の5) - クエリパラメーター(
api/users?sort=ascのsort+ascの組み合わせ) - ボディパラメーター(リクエストボディの内容。ほとんどの場合、json から edn にデコードされます)
- 未加工のリクエストマップ
4 つの引数のうち、必要な場合を除き、未加工のリクエストの使用は優先度を下げてください。
ルートパラメーター
常に必須です。通常は ID を含むマップを指定します。
[{:keys [id]} :- [:map [:id ms/PositiveInt]]]
複数のルートパラメーターがある場合:
[{:keys [id field-id]} :- [:map
[:id ms/PositiveInt]
[:field-id ms/PositiveInt]]]
クエリパラメーター
{:optional true ...} と :default 値のプロパティを追加します。
{:keys [archived include limit offset]} :- [:map
[:archived {:default false} [:maybe ms/BooleanValue]]
[:include {:optional true} [:maybe [:= "tables"]]]
[:limit {:optional true} [:maybe ms/PositiveInt]]
[:offset {:optional true} [:maybe ms/PositiveInt]]]
リクエストボディ(POST/PUT)
{:keys [name description parent_id]} :- [:map
[:name ms/NonBlankString]
[:description {:optional true} [:maybe ms/NonBlankString]]
[:parent_id {:optional true} [:maybe ms/PositiveInt]]]
レスポンススキーマ
シンプルなインラインレスポンス:
(api.macros/defendpoint :get "/:id" :- [:map
[:id pos-int?]
[:name string?]]
"Get a thing"
...)
再利用する名前付きスキーマ:
(mr/def ::Thing
[:map
[:id pos-int?]
[:name string?]
[:description [:maybe string?]]])
(api.macros/defendpoint :get "/:id" :- ::Thing
"Get a thing"
...)
(api.macros/defendpoint :get "/" :- [:sequential ::Thing]
"Get all things"
...)
よく使うスキーマ型
metabase.util.malli.schema の型(ms としてエイリアス)
ms/* 名前空間のスキーマは API インフラストラクチャとの相性が良いため、優先して使用してください。
たとえば、pos-int? の代わりに ms/PositiveInt を使用します。
ms/PositiveInt ;; Positive integer
ms/NonBlankString ;; Non-empty string
ms/BooleanValue ;; String "true"/"false" or boolean
ms/MaybeBooleanValue ;; BooleanValue or nil
ms/TemporalString ;; ISO-8601 date/time string (for REQUEST params only!)
ms/OpaqueJSONObject ;; JSON object whose keys are not ours to declare; string-keyed
ms/PositiveNum ;; Positive number
ms/IntGreaterThanOrEqualToZero ;; 0 or positive
重要: レスポンススキーマでは、時間関連のフィールドに ms/TemporalString ではなく :any を使用してください!
レスポンススキーマは JSON シリアライズの前にバリデーションを行うため、Java Time オブジェクトを受け取ります。
Malli の組み込み型
:string ;; Any string
:boolean ;; true/false
:int ;; Any integer
:keyword ;; Clojure keyword
pos-int? ;; Positive integer predicate
[:maybe X] ;; X or nil
[:enum "a" "b" "c"] ;; One of these values
[:or X Y] ;; Schema that satisfies X or Y
[:and X Y] ;; Schema that satisfies X and Y
[:sequential X] ;; Sequential of Xs
[:set X] ;; Set of Xs
[:map-of K V] ;; Map with keys w/ schema K and values w/ schema V
[:tuple X Y Z] ;; Fixed-length tuple of schemas X Y Z
完全に必要な場合を除き、シーケンススキーマの使用は避けてください。
手順:エンドポイントにスキーマを追加する
例:GET /api/field/:id/related に戻り値のスキーマを追加する
変更前:
(api.macros/defendpoint :get "/:id/related"
"Return related entities."
[{:keys [id]} :- [:map [:id ms/PositiveInt]]]
(-> (t2/select-one :model/Field :id id) api/read-check xrays/related))
手順 1: 関数が返す値を確認します(xrays/related を参照してください)
手順 2: 戻り値の型に基づいてレスポンススキーマを定義します。
(mr/def ::RelatedEntity
[:map
[:tables [:sequential [:map [:id pos-int?] [:name string?]]]]
[:fields [:sequential [:map [:id pos-int?] [:name string?]]]]])
手順 3: エンドポイントにレスポンススキーマを追加します。
(api.macros/defendpoint :get "/:id/related" :- ::RelatedEntity
"Return related entities."
[{:keys [id]} :- [:map [:id ms/PositiveInt]]]
(-> (t2/select-one :model/Field :id id) api/read-check xrays/related))
高度なパターン
カスタムエラーメッセージ
(def DBEngineString
"Schema for a valid database engine name."
(mu/with-api-error-message
[:and
ms/NonBlankString
[:fn
{:error/message "Valid database engine"}
#(u/ignore-exceptions (driver/the-driver %))]]
(deferred-tru "value must be a valid database engine.")))
ドキュメント付きの列挙型
(def PinnedState
(into [:enum {:error/message "pinned state must be 'all', 'is_pinned', or 'is_not_pinned'"}]
#{"all" "is_pinned" "is_not_pinned"}))
複雑なネストされたレスポンス
(mr/def ::DashboardQuestionCandidate
[:map
[:id ms/PositiveInt]
[:name ms/NonBlankString]
[:description [:maybe string?]]
[:sole_dashboard_info
[:map
[:id ms/PositiveInt]
[:name ms/NonBlankString]
[:description [:maybe string?]]]]])
(mr/def ::DashboardQuestionCandidatesResponse
[:map
[:data [:sequential ::DashboardQuestionCandidate]]
[:total ms/PositiveInt]])
ページネーション付きレスポンスのパターン
(mr/def ::PaginatedResponse
[:map
[:data [:sequential ::Item]]
[:total integer?]
[:limit {:optional true} [:maybe integer?]]
[:offset {:optional true} [:maybe integer?]]])
よくある落とし穴
避ける:null 許容フィールドに :maybe を指定し忘れる
[:description ms/NonBlankString] ;; WRONG - fails if nil
[:description [:maybe ms/NonBlankString]] ;; RIGHT - allows nil
避ける:任意のクエリパラメーターに :optional true を指定し忘れる
[:limit ms/PositiveInt] ;; WRONG - required but shouldn't be
[:limit {:optional true} [:maybe ms/PositiveInt]] ;; RIGHT
避ける:既知のパラメーターに :default 値を指定し忘れる
[:limit ms/PositiveInt] ;; WRONG - required but shouldn't be
[:limit {:optional true :default 0} [:maybe ms/PositiveInt]] ;; RIGHT
避ける:ルートパラメーター、クエリパラメーター、ボディを混同する
;; WRONG - all in one map
[{:keys [id name archived]} :- [:map ...]]
;; RIGHT - separate destructuring
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
{:keys [archived]} :- [:map [:archived {:default false} ms/BooleanValue]]
{:keys [name]} :- [:map [:name ms/NonBlankString]]]
避ける:レスポンススキーマ内の Java Time オブジェクトに ms/TemporalString を使用する
;; WRONG - Java Time objects aren't strings yet
[:date_joined ms/TemporalString]
;; RIGHT - schemas validate BEFORE JSON serialization
[:date_joined :any] ;; Java Time object, serialized to string by middleware
[:last_login [:maybe :any]] ;; Java Time object or nil
理由: レスポンススキーマは、Clojure の内部データ構造が JSON にシリアライズされる前にバリデーションを行います。OffsetDateTime などの Java Time オブジェクトは、JSON ミドルウェアによって ISO-8601 文字列に変換されるため、スキーマでは変換前の Java オブジェクトを受け入れる必要があります。
避ける:データが実際にはセットであるのに [:sequential X] を使用する
;; WRONG - group_ids is actually a set
[:group_ids {:optional true} [:sequential pos-int?]]
;; RIGHT - matches the actual data structure
[:group_ids {:optional true} [:maybe [:set pos-int?]]]
理由: Toucan のハイドレーションメソッドは、セットを返すことがよくあります。JSON ミドルウェアはセットを配列にシリアライズしますが、スキーマのバリデーションはシリアライズ前に行われます。
避ける:再利用する構造に無名スキーマを作成する
複数箇所で使用するスキーマには mr/def を使用します。
(mr/def ::User
[:map
[:id pos-int?]
[:email string?]
[:name string?]])
戻り値の型を確認する
- 呼び出されている関数を確認する
(api.macros/defendpoint :get "/:id"
[{:keys [id]}]
(t2/select-one :model/Field :id id)) ;; Returns a Field instance
- 構造を確認するためにToucanのモデルを調べる
src/metabase/*/models/*.clj でモデル定義を確認します。
- clojure-mcpまたはREPLを使って調べる
./bin/mage -repl '(require '\''metabase.xrays.core) (doc metabase.xrays.core/related)'
- テストを確認する
テストには、期待されるレスポンスの構造が示されていることがよくあります。
スキーマ検証のタイミングを理解する
重要な概念: スキーマは、リクエスト/レスポンスのライフサイクルの異なる段階で検証されます。
リクエストパラメーターのスキーマ(Query/Body/Route)
- JSONの解析後に検証されます
- データはすでにデシリアライズされています(文字列、数値、ブール値)
- 日付/時刻の入力には
ms/TemporalStringを使用します - ブール値のクエリパラメーターには
ms/BooleanValueを使用します
レスポンスのスキーマ
- JSONのシリアライズ前に検証されます
- データはまだClojure形式です(Java Timeオブジェクト、セット、キーワード)
- Java Timeオブジェクトには
:anyを使用します - セットには
[:set X]を使用します - キーワードの列挙型には
[:enum :keyword]を使用します
シリアライズのフロー
Request: JSON string → Parse → Coerce → Handler
Response: Handler → Schema Check → Encode → Serialize → JSON string
ワークフローの概要
- エンドポイントを読む - 何をするのか理解します
- パラメーターを特定する - route、query、bodyを確認します
- パラメータースキーマを追加する -
msの既存の型を使用します - 戻り値の型を特定する - 実装を確認します
- レスポンススキーマを定義する - インライン、または
mr/defを使った名前付きスキーマにします - テストする - エンドポイントが動作し、正しく検証されることを確認します
スキーマをテストする
スキーマを追加したら、次の点を確認します。
- 有効なリクエストが動作する - 正しいデータでテストします
- 無効なリクエストが適切に失敗する - 型が間違ったデータでテストします
- オプションのパラメーターが動作する - オプションのパラメーターあり/なしでテストします
- エラーメッセージが明確である - 検証エラーのレスポンスを確認します
ヒント
- シンプルに始める - 基本的な型から始め、後で調整します
- スキーマを再利用する - 同じ構造が2回出てきたら、名前付きスキーマにします
- 具体的にする -
pos-int?の代わりにms/PositiveIntを使用します - 意図を記録する - 名前付きスキーマにdocstringを追加します
- 規約に従う - 同じ名前空間にある類似のエンドポイントを確認します
- 実際のデータを確認する - シリアライズ前に、REPLを使って実際に返される内容を調べます
その他のリソース
- Malliのドキュメント
- Metabase Malliユーティリティ:
src/metabase/util/malli/schema.clj - Metabaseスキーマレジストリ:
src/metabase/util/malli/registry.cljc