mcpField
A
add-malli-schemas

add-malli-schemas

github.com/metabase开发49.9k2.0k 次安装今天更新
代码仓库

关于 add-malli-schemas

面向 Metabase 代码库的技能,用于统一为 API 端点添加 Malli schema。它指导开发者验证参数和响应、复用 schema,并依据实际数据结构编写测试。

主要功能

  1. 01为路由、查询和请求体参数定义 schema
  2. 02定义内联及可复用的响应 schema
  3. 03介绍 Malli 内置 schema 类型
  4. 04说明验证时机与序列化流程
  5. 05测试有效和无效请求

适用场景

  1. 01为端点添加参数验证
  2. 02依据实现数据定义响应 schema
  3. 03处理可空、可选或时间字段
  4. 04通过端点测试检查 schema 行为

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/metabase/metabase --skill add-malli-schemas

安装后在 Claude Code 中输入/add-malli-schemas确认已加载。

详细介绍

为 API 端点添加 Malli Schema

此技能可帮助你高效、统一地为 Metabase 代码库中的 API 端点添加 Malli Schema。

参考文件(最佳示例)

  • src/metabase/warehouses_rest/api.clj - 最全面的 Schema 和自定义错误消息
  • src/metabase/api_keys/api.clj - 出色的响应 Schema
  • src/metabase/collections_rest/api.clj - 优秀的命名 Schema 模式
  • src/metabase/timeline/api/timeline.clj - 简洁明了的示例

快速检查清单

为端点添加 Malli Schema 时:

  • 路由参数有 Schema
  • 查询参数有 Schema,并在适当时设置 :optional true 和 :default
  • 请求体有 Schema(适用于 POST/PUT)
  • 已定义响应 Schema(使用路由字符串后面的 :-)
  • 尽可能使用 ms 命名空间中的现有 Schema 类型
  • 考虑为可复用或复杂类型创建命名 Schema
  • 为验证失败添加上下文相关的错误消息

基本结构

完整端点示例
(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))}
  )

常见 Schema 模式

  1. 路由参数(api/user/id/5 中的 5)
  2. 查询参数(api/users?sort=asc 中的 sort+asc)
  3. 请求体参数(请求体中的内容,几乎总是从 JSON 解码为 EDN)
  4. 原始请求映射

对于这 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]]]
响应 Schema
简单的内联响应:
(api.macros/defendpoint :get "/:id" :- [:map
                                        [:id pos-int?]
                                        [:name string?]]
  "Get a thing"
  ...)
可复用的命名 Schema:
(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"
  ...)

常见 Schema 类型

来自 metabase.util.malli.schema(别名为 ms)

优先使用 ms/* 命名空间中的 Schema,因为它们与我们的 API 基础设施配合得更好。

例如,使用 ms/PositiveInt,而不是 pos-int?。

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

重要: 对于响应 Schema,时间字段请使用 :any,不要使用 ms/TemporalString! 响应 Schema 在 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

除非绝对必要,否则避免使用序列 Schema。

分步指南:为端点添加 Schema

示例:为 GET /api/field/:id/related 添加返回 Schema

之前:

(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: 根据返回类型定义响应 Schema:

(mr/def ::RelatedEntity
  [:map
   [:tables [:sequential [:map [:id pos-int?] [:name string?]]]]
   [:fields [:sequential [:map [:id pos-int?] [:name string?]]]]])

步骤 3: 将响应 Schema 添加到端点:

(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?]]])

常见陷阱

不要:为可空字段漏掉 :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]]]
不要:在响应 Schema 中对 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

原因: 响应 Schema 会在内部 Clojure 数据结构序列化为 JSON 之前进行验证。OffsetDateTime 等 Java Time 对象会由 JSON 中间件转换为 ISO-8601 字符串,因此 Schema 需要接受原始 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 中间件会将集合序列化为数组,但 Schema 会在序列化之前进行验证。

不要:为重复使用的结构创建匿名 Schema

对在多个位置使用的 Schema,使用 mr/def:

(mr/def ::User
  [:map
   [:id pos-int?]
   [:email string?]
   [:name string?]])

查找返回类型

  1. 查看被调用的函数
(api.macros/defendpoint :get "/:id"
  [{:keys [id]}]
  (t2/select-one :model/Field :id id))  ;; Returns a Field instance
  1. 查看 Toucan 模型的结构

在 src/metabase/*/models/*.clj 中查找模型定义。

  1. 使用 clojure-mcp 或 REPL 进行检查
./bin/mage -repl '(require '\''metabase.xrays.core) (doc metabase.xrays.core/related)'
  1. 查看测试

测试通常会展示预期的响应结构。

了解架构验证时机

关键概念: 架构会在请求/响应生命周期的不同阶段进行验证:

请求参数架构(查询/正文/路由)
  • 在 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

工作流总结

  1. 阅读端点 - 了解其功能
  2. 识别参数 - 路由、查询、正文
  3. 添加参数架构 - 使用 ms 中的现有类型
  4. 确定返回类型 - 检查实现
  5. 定义响应架构 - 内联定义,或使用 mr/def 命名
  6. 测试 - 确保端点正常工作且验证正确

测试架构

添加架构后,请验证:

  1. 有效请求正常工作 - 使用正确的数据进行测试
  2. 无效请求能妥善失败 - 使用错误类型进行测试
  3. 可选参数正常工作 - 测试提供和不提供可选参数的情况
  4. 错误消息清晰易懂 - 检查验证错误响应

提示

  • 从简单开始 - 先使用基本类型,之后再细化
  • 重用架构 - 如果同一结构出现两次,就将其定义为命名架构
  • 明确指定类型 - 使用 ms/PositiveInt,而不是 pos-int?
  • 记录设计意图 - 为命名架构添加文档字符串
  • 遵循约定 - 查看同一命名空间中的类似端点
  • 检查实际数据 - 在序列化前使用 REPL 检查实际返回的数据

更多资源

  • Malli 文档
  • Metabase Malli 工具:src/metabase/util/malli/schema.clj
  • Metabase 架构注册表:src/metabase/util/malli/registry.cljc

评论

支持 Markdown