首页
/ Metabase API 接口的 Malli Schema 编写实践:defendpoint 参数校验、响应验证与错误消息

Metabase API 接口的 Malli Schema 编写实践:defendpoint 参数校验、响应验证与错误消息

2026-09-07 17:19:25作者:钟日瑜

本文基于 Metabase 仓库中的开发者技能文档 add-malli-schemas,系统讲解如何在 Metabase 的 REST API 端点上统一添加 Malli schema:如何为路由参数、查询参数、请求体和响应分别编写校验规则,如何借助 ms/* 工具库与 mr/def 注册表复用命名类型,以及 JSON 序列化前后的验证时机差异如何影响 schema 选型。读完本文,你可以独立完成"为一个 Metabase API 端点补齐完整 schema"的工作,并理解 defendpoint 宏底层的解码、验证与编码机制。

一、Skill 定位与参考文件

这份技能文档面向一个非常具体的开发任务:在 Metabase 代码库中为 API 端点高效、统一地添加 Malli schema,覆盖参数校验、验证时机(validation timing)和错误处理(error handling)三个层面。它提供了一份快速检查清单、一个完整端点示例、四类参数的模式模板、常用 schema 类型速查、进阶模式、常见陷阱、以及逐步工作流。

添加 schema 时的快速检查清单(Checklist):

  • [ ] 路由参数(Route params)有 schema
  • [ ] 查询参数(Query params)在需要时带有 :optional true:default
  • [ ] 请求体(Request body)有 schema(针对 POST/PUT)
  • [ ] 定义了响应 schema(写在路由字符串之后的 :- 位置)
  • [ ] 尽可能使用 ms 命名空间中已有的 schema 类型
  • [ ] 考虑为可复用或复杂的类型创建命名 schema
  • [ ] 为校验失败添加有上下文的错误消息

文档列出了四个"最佳范例文件"作为参考。经与当前仓库核对:

  • src/metabase/api_keys/api.clj —— 优秀的响应 schema 范例(当前仓库中确实存在)
  • src/metabase/timeline/api/timeline.clj —— 简洁干净的示例(当前仓库中确实存在)
  • 文档同时引用了 src/metabase/warehouses/api.cljsrc/metabase/collections/api.clj 作为"最全面的 schema"范例,但在当前目录结构下这两个路径已不存在(src/metabase/warehouses 目录下没有 api.clj)。从源码结构看,相关端点可能已迁移到其他 *-rest 命名空间,读者可以自行替换为同风格的 API 文件学习。

二、底层机制:defendpoint 宏与验证流程

理解 skill 中的每一条规则之前,先看清楚 Metabase API 的基础设施。所有 REST 端点都通过自定义宏 defendpoint 定义,实现在 src/metabase/api/macros.clj。一个端点最多有四类参数位置:

  1. Route Params(路由参数,如 api/user/id/5 中的 5
  2. Query Params(查询参数,如 api/users?sort=asc 中的 sort=asc
  3. Body Params(请求体,几乎总是从 JSON 解码为 EDN)
  4. Raw Request map(原始请求 map)

Skill 文档提醒:四类参数中,Raw Request 是优先级最低的位置,除非必要否则不要使用

解码 → 验证 → 绑定

defendpoint 宏的核心调用链如下:

请求侧decode-and-validate-params 先用 schema 解码器对参数解码(decode),再用 mr/validate 校验解码后的值;校验失败时抛出带 :status-code 400ex-info,错误详情经 mr/explain 生成并用 me/humanize 处理成人类可读的错误消息(specific-errorserrors 两组键)。解码器由 decode-transformer 组合而成,依次是 string-transformer(处理查询串的类型解码)、json-transformer(处理 JSON body)、default-value-transformer(应用 :default 默认值)——这正是查询参数里 {:default false} 能生效的原因。

响应侧validate-and-encode-response 先对 handler 的返回值做 schema 校验,通过后才执行 encoder 编码。校验失败会抛出 status-code 400 的 "Invalid response" 异常,并附带经拼写检查与 humanize 处理后的解释。值得注意的一个实现细节是:动态变量 *enable-response-validation* 的取值为 (not config/is-prod?)(见 macros.clj L340-L346),也就是说响应校验默认只在非生产环境执行;生产环境会跳过校验,但响应仍会按 schema 进行编码。

decoder 和 encoder 都通过 src/metabase/api/macros.clj 中的缓存函数生成,宏注释明确说明"schema 函数只生成一次并复用,以获得更好的性能",底层依赖下一节介绍的 mr 缓存体系。

三、ms/* 命名空间:Metabase 的可复用请求 Schema 工具库

metabase.util.malli.schema(习惯上别名为 ms)是 Metabase 自己沉淀的一组 schema,源码位于 src/metabase/util/malli/schema.clj。Skill 文档强调:优先使用 ms/* 中的 schema,因为它们与 Metabase 的 API 基础设施配合更好——例如应该用 ms/PositiveInt 而不是裸的 pos-int?

从源码看,这"更好"的具体原因有两个:

  1. :api/regex 属性:如 ms/PositiveInt 定义为 [:int {:min 1 ... :api/regex #"[1-9]\d*"}],这个正则会进入 API 文档生成流程,让第三方消费者能看到精确的约束。
  2. :description:error/fn 属性:几乎所有 schema 都用 mu/with-api-error-message 包装。该函数定义在 src/metabase/util/malli.cljc L54-L68,其 docstring 解释得很清楚:它给 schema 添加 :description(被 API 文档的 describe 流程使用)和 :error/fn(被 humanize 流程使用,即 defendpoint 生成校验错误响应的路径)。

Skill 文档列出的常用类型速查(均已在源码中核对存在):

Schema 含义
ms/PositiveInt 正整数,如 L126-L134
ms/NonBlankString 非空字符串,如 L90-L105,同时带有 :json-schema {:type "string" :minLength 1}
ms/BooleanValue 字符串 "true"/"false" 或布尔值,JSON 解码保证产出 true/false,见 L261-L270
ms/MaybeBooleanValue BooleanValuenil(区分"用户没给值"与"用户给了 false"),见 L272-L279
ms/TemporalString 可被 metabase.util.date-2/parse 解析的 ISO-8601 日期/时间字符串(仅用于 REQUEST 参数!),见 L241-L247
ms/Map 任意 map(开放 schema,不约束键)
ms/JSONString 合法的序列化 JSON 字符串
ms/IntGreaterThanOrEqualToZero 0 或正整数,见 L107-L115

两点核对说明:其一,skill 文档速查表还列有 ms/PositiveNum,但当前版本的 schema.clj 中已没有该名称的 def,从源码结构看整数约束已由 ms/Int/ms/PositiveInt/ms/NegativeInt/ms/IntGreaterThanOrEqualToZero 覆盖;其二,schema.clj 中还额外提供了 ms/Emailms/Urlms/UUIDStringms/NanoIdStringms/ValidLocalems/TemporalInstant 等更专用的类型,可直接按需取用。

重要规则:响应 schema 中的时间字段用 :any,不要用 ms/TemporalString 原因见第九节的验证时机分析。

四、mr/def:命名 Schema 与 Malli 注册表

可复用、复杂的类型应注册为命名 schema。mrmetabase.util.malli.registry 的别名,实际文件为 src/metabase/util/malli/registry.cljc(skill 文档写作 registry.clj,当前仓库中扩展名为 .cljc)。

  • mr/def:"Like clojure.spec.alpha/def",把 schema 注册进全局注册表;带 docstring 的重载会经由 -with-doc 把文档合并为 :description 属性。
  • register! 注册后会 reset! cache {}——注册表任何变更都会清空验证器缓存,保证一致性。
  • validator / explainer 提供缓存的校验器与解释器,defendpoint 内部正是调用 mr/validatemr/explain。其中 explainer 做了性能优化:对 99.9% 的合法值直接走轻量 validator,只有非法值才调用较重的 explainer。
  • 注册表还包含一个自定义的 :ref 实现 cached-ref-schema。源码注释解释了动机:Malli 0.2.0 每次引用命名 schema 都会重新分配 Schema 对象,嵌套引用会指数级放大内存占用;这个定制让无特殊属性的 :ref 复用缓存对象,把引用成本降到"指针级"。这意味着重复使用命名 schema(::Thing)不仅可读性好,也是被这套基础设施认真优化过的正确做法

五、完整端点示例

Skill 文档给出的"教科书式"完整端点,四类参数位置一次看全:

(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 写在路由字符串 "/:name" 之后的 :- 位置(:- ::ResponseSchema);
  • 路由、查询、Body 是三个独立的解构绑定,各自挂各自的 schema;
  • include{:optional true} + [:maybe [:= "details"]] 表示"可不传,传了必须是 \"details\"";
  • archived{:default false}ms/BooleanValue 保证无论客户端传 "true" 字符串还是布尔值,handler 里拿到的都是真正的 true/false

六、四类参数的 Schema 模式

路由参数

总是必填,通常就是一个带 ID 的 map:

[{: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]]]

真实仓库中的同类写法可见 src/metabase/channel/api/channel.clj L36-L45,查询参数 include_inactive 使用了 {:optional true} [:maybe {:default false} :boolean] 的组合。

请求体(POST/PUT)

{:keys [name description parent_id]} :- [:map
                                         [:name        ms/NonBlankString]
                                         [:description {:optional true} [:maybe ms/NonBlankString]]
                                         [:parent_id   {:optional true} [:maybe ms/PositiveInt]]]

一个当前仓库中的真实示例:src/metabase/api_keys/api.clj L65-L78PUT /:id 端点,路由参数用 ms/PositiveInt,body 的两个字段均为 {:optional true} [:maybe ...],正好演示"部分更新"接口的参数模式。

响应 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"
  ...)

api_keys/api.clj 中还展示了第三种形态:响应 schema 直接引用别的命名空间中的注册 schema,如 [:id ::api-keys.schema/id][:name ::api-keys.schema/name]——同一个 schema 在参数侧与响应侧共用一份定义。

另外,仓库通过 clj-kondo 规则 :metabase/validate-defendpoint-has-response-schema 在 lint 层面推动"端点必须有响应 schema":api_keys/api.clj 中尚缺响应 schema 的端点上都带有 #_{:clj-kondo/ignore [:metabase/validate-defendpoint-has-response-schema]} 豁免注释,并留有 "please add a response schema to this API endpoint" 的 TODO——这正是本 skill 存在并被持续执行的现实背景。

七、Malli 内置类型速查

类型 含义
:string 任意字符串
:boolean true/false
:int 任意整数
:keyword Clojure keyword
pos-int? 正整数谓词
[:maybe X] Xnil
[:enum "a" "b" "c"] 枚举:必须是其中之一
[:or X Y] 满足 XY
[:and X Y] 同时满足 XY
[:sequential X] X 的序列
[:set X] X 的集合
[:map-of K V] 键满足 K、值满足 V 的 map
[:tuple X Y Z] 定长元组

Skill 文档提醒:尽量避免使用序列(sequence)类 schema,除非完全必要——结合第六节的陷阱可以看到,序列与集合的混淆是实际出错的来源之一。

八、逐步演练:为一个真实端点补响应 Schema

GET /api/field/:id/related 为例,这是 skill 文档给出的完整工作流。

改动前:

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

Step 1:确认 handler 实际返回什么(去看 xrays/related 的实现)。

Step 2:根据返回结构定义响应 schema:

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

Step 3:把 :- ::RelatedEntity 挂到路由之后:

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

三步的核心思想:先弄清返回值的真实数据结构,再写 schema,而不是凭想象写一个"看起来差不多"的 map。

九、进阶模式

自定义错误消息

对业务性强的约束,用 mu/with-api-error-message[:fn {:error/message ...}] 提供上下文化消息:

(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.")))

注意 deferred-tru 是 i18n 延迟翻译形式(metabase.util.i18n),错误消息因此可以进入多语言体系。

带文档的枚举

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

注册表用法还有一个真实仓库内的完整范例:src/metabase/util/cron.clj L16-L53 中,::CronScheduleStringmu/with-api-error-message 包装 cron 表达式校验,::ScheduleMap 展示命名 schema 内部再引用 ::CronHour/::CronMinute 的小 schema,最后对外暴露 [:ref ::CronScheduleString] 形式供其他命名空间引用。

十、核心概念:Schema 的验证时机

这是 skill 文档标注为 CRITICAL 的知识点,也是 defendpoint 源码最能印证的部分:

请求参数 Schema(Query/Body/Route)

  • 在 JSON 解析之后验证;
  • 数据已经完成反序列化(字符串、数字、布尔);
  • 日期/时间输入用 ms/TemporalString
  • 布尔查询参数用 ms/BooleanValue

响应 Schema

  • 在 JSON 序列化之前验证;
  • 数据仍是 Clojure 形态(Java Time 对象、set、keyword);
  • Java Time 对象用 :any
  • set 用 [:set X]
  • keyword 枚举用 [:enum :keyword]

序列化流程

Request:  JSON string → Parse → Coerce → Handler
Response: Handler → Schema Check → Encode → Serialize → JSON string

对照 validate-and-encode-response 的实现即可逐环节对应:handler 返回值先过 schema check(mr/validate),再进入 encoder(mc/encoder + encode-transformer),最终由中间件序列化为 JSON。这也解释了两条"反直觉"规则的存在意义:

  1. 响应里的 OffsetDateTime 还不是字符串,写成 ms/TemporalString 必挂,必须写 :any
  2. Toucan 水合(hydration)常返回 set,JSON 中间件会把 set 序列化为数组,但 schema 看到的是原始 set,所以必须写 [:set X] 而非 [:sequential X]

十一、常见陷阱(Don'ts)

1. 可空字段忘记 :maybe

[:description ms/NonBlankString]              ;; WRONG - nil 时会校验失败
[:description [:maybe ms/NonBlankString]]     ;; RIGHT - 允许 nil

2. 可选查询参数忘记 :optional true

[:limit ms/PositiveInt]                                 ;; WRONG - 变成必填
[:limit {:optional true} [:maybe ms/PositiveInt]]       ;; RIGHT

3. 已知参数忘记 :default

[:limit ms/PositiveInt]                                ;; WRONG - 变成必填
[:limit {:optional true :default 0} [:maybe ms/PositiveInt]]  ;; RIGHT

4. 路由参数、查询参数、Body 混在一个 map 里

;; WRONG - 全部塞进一个 map
[{:keys [id name archived]} :- [:map ...]]

;; RIGHT - 分开解构
[{:keys [id]} :- [:map [:id ms/PositiveInt]]
 {:keys [archived]} :- [:map [:archived {:default false} ms/BooleanValue]]
 {:keys [name]} :- [:map [:name ms/NonBlankString]]]

5. 响应 schema 里对 Java Time 对象使用 ms/TemporalString

;; WRONG - Java Time 对象此时还不是字符串
[:date_joined ms/TemporalString]

;; RIGHT - schema 在 JSON 序列化之前执行验证
[:date_joined :any]                     ;; Java Time 对象,由中间件序列化为字符串
[:last_login [:maybe :any]]             ;; Java Time 对象或 nil

6. 数据实际是 set 却用 [:sequential X]

;; WRONG - group_ids 实际是 set
[:group_ids {:optional true} [:sequential pos-int?]]

;; RIGHT - 与实际数据结构一致
[:group_ids {:optional true} [:maybe [:set pos-int?]]]

7. 复用结构写成匿名 schema

被多处使用的结构要用 mr/def 命名注册:

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

十二、如何确定返回类型

写响应 schema 前必须先回答"handler 到底返回什么",skill 文档给出四条路径:

  1. 看被调用的函数
(api.macros/defendpoint :get "/:id"
  [{:keys [id]}]
  (t2/select-one :model/Field :id id))  ;; 返回一个 Field 实例
  1. 查 Toucan 模型定义:到 src/metabase/*/models/*.clj 查看对应模型的字段结构。

  2. 用 REPL 检查(skill 文档给出的命令):

./bin/mage -repl '(require '\''metabase.xrays.core) (doc metabase.xrays.core/related)'
  1. 查测试:测试用例往往直接断言了期望的响应结构,是最可靠的"文档"。

十三、测试、工作流与经验法则

添加 schema 后的验证清单

  1. 合法请求能通过——用正确数据实测;
  2. 非法请求优雅失败——用错误类型实测,确认返回 400 与可读错误;
  3. 可选参数正常——带与不带可选参数各测一次;
  4. 错误消息清晰——检查校验错误响应的措辞是否有上下文。

工作流总结

  1. 读端点——理解它做什么;
  2. 识别参数——route、query、body;
  3. 添加参数 schema——优先用 ms 中现成的类型;
  4. 确定返回类型——查看实现;
  5. 定义响应 schema——内联或 mr/def 命名;
  6. 测试——确保端点工作正常且校验行为正确。

经验法则(Tips)

  • 从简单开始——先用基础类型,之后再精化;
  • 复用 schema——同一结构出现两次,就做成命名 schema;
  • 具体化——用 ms/PositiveInt 而不是 pos-int?
  • 记录意图——给命名 schema 加 docstring;
  • 遵循惯例——看同一命名空间里相近端点怎么写;
  • 检查真实数据——序列化之前,用 REPL 检查实际返回的东西。

十四、参考文件汇总

文件 作用
.claude/skills/add-malli-schemas/SKILL.md 本文对应的技能文档(规范来源)
src/metabase/util/malli/schema.clj ms/* 可复用 schema 库
src/metabase/util/malli/registry.cljc mr/def、注册表、缓存 validator/explainer
src/metabase/util/malli.cljc mu/with-api-error-message 等工具
src/metabase/api/macros.clj defendpoint 宏与解码/验证/编码流程
src/metabase/api_keys/api.clj 端点 schema 的实际范例
src/metabase/timeline/api/timeline.clj 简洁的端点 schema 范例
src/metabase/util/cron.clj 命名 schema + 自定义错误消息范例

Malli 本身是第三方库(metosin/malli),其向量语法与 value transformation 机制可在其官方文档中进一步阅读;本文所有具体行为描述均基于当前 Metabase 仓库中上述源码的实现。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388