Metabase API 接口的 Malli Schema 编写实践:defendpoint 参数校验、响应验证与错误消息
本文基于 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.clj与src/metabase/collections/api.clj作为"最全面的 schema"范例,但在当前目录结构下这两个路径已不存在(src/metabase/warehouses 目录下没有api.clj)。从源码结构看,相关端点可能已迁移到其他*-rest命名空间,读者可以自行替换为同风格的 API 文件学习。
二、底层机制:defendpoint 宏与验证流程
理解 skill 中的每一条规则之前,先看清楚 Metabase API 的基础设施。所有 REST 端点都通过自定义宏 defendpoint 定义,实现在 src/metabase/api/macros.clj。一个端点最多有四类参数位置:
- Route Params(路由参数,如
api/user/id/5中的5) - Query Params(查询参数,如
api/users?sort=asc中的sort=asc) - Body Params(请求体,几乎总是从 JSON 解码为 EDN)
- Raw Request map(原始请求 map)
Skill 文档提醒:四类参数中,Raw Request 是优先级最低的位置,除非必要否则不要使用。
解码 → 验证 → 绑定
defendpoint 宏的核心调用链如下:
请求侧:decode-and-validate-params 先用 schema 解码器对参数解码(decode),再用 mr/validate 校验解码后的值;校验失败时抛出带 :status-code 400 的 ex-info,错误详情经 mr/explain 生成并用 me/humanize 处理成人类可读的错误消息(specific-errors 与 errors 两组键)。解码器由 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?。
从源码看,这"更好"的具体原因有两个:
:api/regex属性:如 ms/PositiveInt 定义为[:int {:min 1 ... :api/regex #"[1-9]\d*"}],这个正则会进入 API 文档生成流程,让第三方消费者能看到精确的约束。: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 |
BooleanValue 或 nil(区分"用户没给值"与"用户给了 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/Email、ms/Url、ms/UUIDString、ms/NanoIdString、ms/ValidLocale、ms/TemporalInstant 等更专用的类型,可直接按需取用。
重要规则:响应 schema 中的时间字段用 :any,不要用 ms/TemporalString! 原因见第九节的验证时机分析。
四、mr/def:命名 Schema 与 Malli 注册表
可复用、复杂的类型应注册为命名 schema。mr 是 metabase.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/validate与mr/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-L78 的 PUT /: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] |
X 或 nil |
[:enum "a" "b" "c"] |
枚举:必须是其中之一 |
[:or X Y] |
满足 X 或 Y |
[:and X Y] |
同时满足 X 和 Y |
[: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 中,::CronScheduleString 用 mu/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。这也解释了两条"反直觉"规则的存在意义:
- 响应里的
OffsetDateTime还不是字符串,写成ms/TemporalString必挂,必须写:any; - 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 文档给出四条路径:
- 看被调用的函数:
(api.macros/defendpoint :get "/:id"
[{:keys [id]}]
(t2/select-one :model/Field :id id)) ;; 返回一个 Field 实例
-
查 Toucan 模型定义:到
src/metabase/*/models/*.clj查看对应模型的字段结构。 -
用 REPL 检查(skill 文档给出的命令):
./bin/mage -repl '(require '\''metabase.xrays.core) (doc metabase.xrays.core/related)'
- 查测试:测试用例往往直接断言了期望的响应结构,是最可靠的"文档"。
十三、测试、工作流与经验法则
添加 schema 后的验证清单
- 合法请求能通过——用正确数据实测;
- 非法请求优雅失败——用错误类型实测,确认返回 400 与可读错误;
- 可选参数正常——带与不带可选参数各测一次;
- 错误消息清晰——检查校验错误响应的措辞是否有上下文。
工作流总结
- 读端点——理解它做什么;
- 识别参数——route、query、body;
- 添加参数 schema——优先用
ms中现成的类型; - 确定返回类型——查看实现;
- 定义响应 schema——内联或
mr/def命名; - 测试——确保端点工作正常且校验行为正确。
经验法则(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 仓库中上述源码的实现。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00