PostgREST 资源表示(Resource Representation):基于 HTTP 内容协商的响应格式控制完全指南
【免费下载链接】postgrestREST API for any Postgres database项目地址: https://gitcode.com/GitHub_Trending/po/postgrest
PostgREST 将 Postgres 数据库暴露为 REST API,其资源表示遵循 RFC 7231 的 HTTP 内容协商机制:同一个 API 端点,可以根据请求中Accept头的内容,以 JSON、CSV 等不同格式返回数据。本文以 resource_representation.rst 为主体,结合仓库源码(MediaType.hs、Plan/Negotiate.hs)与测试用例,系统讲解响应格式协商、内置媒体类型处理器、单数/复数资源控制、null 剥离以及请求体媒体类型,帮助读者掌握精确控制 PostgREST 输入输出格式的完整能力。
核心机制:HTTP 内容协商(RFC 7231)
PostgREST 遵循 RFC 7231 第 5.3 节规定的 HTTP 内容协商机制来交付资源表示。这意味着同一个 API 端点可以响应不同格式(如 JSON 或 CSV),具体格式由客户端请求决定,服务端无需为每种格式提供不同 URL。
在源码层面,内容协商由 Plan/Negotiate.hs 中的negotiateContent函数实现:
-- | Do content negotiation. i.e. choose a media type based on the -- intersection of accepted/produced media types. negotiateContent :: AppConfig -> ApiRequest -> QualifiedIdentifier -> [MediaType] -> MediaHandlerMap -> Bool -> Either ApiRequestError ResolvedHandler其核心逻辑是:把请求头Accept中列出的媒体类型与目标资源"能产出"的媒体类型求交集,选择第一个匹配项(firstAcceptedPick = listToMaybe $ mapMaybe matchMT accepts)。如果交集为空(Nothing),则返回MediaTypeError,对应 HTTP 415 状态码。
客户端的Accept头在 ApiRequest.hs 中被解析:
iAcceptMediaType = maybe [MTAny] (map MediaType.decodeMediaType . parseHttpAccept) $ lookupHeader "accept"注意两个细节:
- 未发送
Accept头时,默认值是[MTAny](即*/*); - 一个
Accept头可以包含多个媒体类型(逗号分隔),PostgREST 会按顺序逐一尝试匹配。
响应格式(Response Format):通过 Accept 头指定
使用Accept请求头来指定响应可接受的格式(可以指定一种或多种):
curl "http://localhost:3000/people" \ -H "Accept: application/json"关于列顺序的说明
响应的列顺序不保证与select子句中指定的顺序一致。例如,带资源嵌入(resource embedding)的请求:
http://localhost:3000/films?select=directors(last_name,id),title返回结果可能是:
[ { "title": "title", "directors": { "id": 5, "last_name": "name" } } ]这与 JSON Schema 规范(2020-12 版)对对象的定义一致——"object: An unordered set of properties mapping a string to an instance",即 JSON 对象是字符串到实例的无序映射。因此,依赖响应中字段顺序的客户端代码是不可靠的,应始终通过字段名访问属性。资源嵌入的完整用法参见 resource_embedding.rst。
内置媒体类型处理器(Builtin Media Type Handlers)
PostgREST 为常见标准媒体类型提供了内置处理器:
| 媒体类型 | 适用端点 | 说明 |
|---|---|---|
text/csv与application/json | 所有 API 端点 | 表/视图见 tables_views.rst,函数见 functions.rst |
application/openapi+json | 根端点(/) | 返回 OpenAPI 文档,见 openapi.rst |
application/geo+json | 相关端点 | GeoJSON 格式 |
*/* | 所有端点 | 对 API 端点解析为application/json,对根端点解析为application/openapi+json |
供应商媒体类型(Vendor Media Types)
PostgREST 还支持以下供应商(vendor)媒体类型处理器:
application/vnd.pgrst.plan:返回执行计划(EXPLAIN),见 media_type_handlers.rst 中的计划相关章节;application/vnd.pgrst.object与application/vnd.pgrst.array:控制单数/复数响应与 null 剥离,详见下文"单数或复数"与"剥离空值"两节。
在源码 MediaType.hs 中,这些媒体类型被建模为 Haskell 代数数据类型:
data MediaType = MTApplicationJSON | MTGeoJSON | MTTextCSV | MTTextPlain | MTTextXML | MTOpenAPI | MTUrlEncoded | MTOctetStream | MTAny | MTOther Text -- vendored media types | MTVndArrayJSONStrip | MTVndSingularJSON Bool | MTVndPlan MediaType MTVndPlanFormat [MTVndPlanOption]其中MTVndPlan携带格式(PlanJSON/PlanText)与选项(analyze、verbose、settings、buffers、wal),并可通过for="..."参数指定目标媒体类型。
无法识别的媒体类型会报错
任何无法识别的媒体类型都会导致错误。例如:
curl "http://localhost:3000/people" \ -H "Accept: unknown/unknown"返回:
HTTP/1.1 415 Unsupported Media Type {"code":"PGRST107","details":null,"hint":null,"message":"None of these media types are available: unknown/unknown"}该错误码在 Error.hs 中定义:code MediaTypeError{} = "PGRST107",对应 HTTP 415 状态。从negotiateContent的实现可以看到,MediaTypeError中携带的是客户端 Accept 的完整媒体类型列表(map MediaType.toMime accepts),便于排错。
如需扩展可接受的媒体类型,可以使用自定义媒体类型处理器(custom media types),详见 media_type_handlers.rst。
单数或复数响应(Singular or Plural)
默认情况下,PostgREST总是以数组形式返回 JSON 结果,即使只有一条记录。例如请求/items?id=eq.1返回:
[ { "id": 1 } ]这对某些客户端代码可能不够方便。要返回不被数组包裹的单数对象(第一条结果),在Accept头中指定vnd.pgrst.object:
curl "http://localhost:3000/items?id=eq.1" \ -H "Accept: application/vnd.pgrst.object+json"此时返回:
{ "id": 1 }注意:vnd.pgrst.object和vnd.pgrst.array属于供应商媒体类型,具有特殊处理逻辑,不能被自定义媒体类型处理器覆盖。这一点在 Plan/Negotiate.hs 中有明确注释:"all the vendored media types have special handling as they have media type parameters, they cannot be overridden",对应测试见 CustomMediaSpec.hs。
空结果时的行为差异
当请求单数响应但未找到任何记录时,服务端返回错误消息和 406 Not Acceptable 状态码,而不是通常的空数组 + 200:
{ "code": "PGRST116", "message": "Cannot coerce the result to a single JSON object", "details": "The result contains 0 rows", "hint": null }源码中的判定逻辑位于 MainTx.hs:
failNotSingular :: MediaType -> ResultSet -> DbHandler () failNotSingular mediaType RSStandard{rsQueryTotal=queryTotal} = when (elem mediaType [MTVndSingularJSON True, MTVndSingularJSON False] && queryTotal /= 1) $ do lift SQL.condemn throwError $ Error.ApiRequestErr . Error.SingularityError $ toInteger queryTotal即:只要媒体类型是单数 JSON(无论是否剥离 null),且查询总行数queryTotal不等于 1,就抛出SingularityError。该错误的 HTTP 状态码为 406(见 Error.hs),错误码为PGRST116,details字段动态携带实际行数("The result contains " <> show n <> " rows",见 Error.hs)。这也意味着:查询返回多于 1 行时同样会触发该错误(queryTotal /= 1),单数响应要求查询结果恰好为一行。
为什么不用/stories/1这种嵌套 URL?
很多 API 用特殊的嵌套 URL 约定来区分单数和复数资源,如/stories与/stories/1。PostgREST 使用/stories?id=eq.1的原因在于:
- 单数资源(对 PostgREST 而言)是由主键确定的一行,而主键可以是复合主键(跨越多个列);
- 常见的嵌套 URL 只考虑了简单且绝大多数为数值型主键的特例,这些所谓的"人工键"(artificial keys)通常由对象关系映射(ORM)库自动引入;
- 诚然,PostgREST 可以检测到"对所有构成主键的列都有等值条件"从而自动转换为单数,但这可能导致格式的意外变化——客户端仅仅因为多过滤了一个列,响应格式就从对象变成数组,从而破坏已有客户端代码;
- 因此,PostgREST 选择让手动显式指定单数/复数,把这一选择与 URL 格式解耦,行为可预测。
剥离空值(Stripped Nulls)
默认情况下,PostgREST 返回所有JSON null 值。例如请求/projects?id=gt.10返回:
[ { "id": 11, "name": "OSX", "client_id": 1, "another_col": "val" }, { "id": 12, "name": "ProjectX", "client_id": null, "another_col": null }, { "id": 13, "name": "Y", "client_id": null, "another_col": null } ]在大结果集上,值为null的未使用键会白白浪费带宽。要移除它们,把nulls=stripped作为application/vnd.pgrst.array的参数:
curl "http://localhost:3000/projects?id=gt.10" \ -H "Accept: application/vnd.pgrst.array+json;nulls=stripped"此时返回:
[ { "id": 11, "name": "OSX", "client_id": 1, "another_col": "val" }, { "id": 12, "name": "ProjectX" }, { "id": 13, "name": "Y" } ]源码中的实现细节
在 MediaType.hs 的decodeMediaType中,nulls参数通过解析媒体类型参数获得:
("application", "vnd.pgrst.object+json", _) -> MTVndSingularJSON strippedNulls ("application", "vnd.pgrst.object", _) -> MTVndSingularJSON strippedNulls ("application", "vnd.pgrst.array+json", _) -> checkArrayNullStrip ("application", "vnd.pgrst.array", _) -> checkArrayNullStrip ... strippedNulls = fromMaybe "false" (params !? "nulls") == "stripped" checkArrayNullStrip = if strippedNulls then MTVndArrayJSONStrip else MTApplicationJSON关键点:
- 参数名不区分大小写(按 RFC 7231 规范归一化为小写);
nulls参数只有精确等于stripped时才生效;application/vnd.pgrst.array+json未带nulls=stripped时,等价于普通的application/json(checkArrayNullStrip返回MTApplicationJSON);application/vnd.pgrst.object+json;nulls=stripped表示"单数响应 + 剥离 null"的组合,MTVndSingularJSON Bool中的Bool正是用于记录是否剥离 null。
对应响应时的Content-Type由 MediaType.hs 的toMime生成:
toMime MTVndArrayJSONStrip = "application/vnd.pgrst.array+json;nulls=stripped" toMime (MTVndSingularJSON True) = "application/vnd.pgrst.object+json;nulls=stripped" toMime (MTVndSingularJSON False) = "application/vnd.pgrst.object+json"该功能的完整行为有专门测试覆盖,见 NullsStripSpec.hs,其中分别验证了application/vnd.pgrst.array+json;nulls=stripped与application/vnd.pgrst.array;nulls=stripped(省略+json后缀)两种写法。
补充:供应商媒体类型对 OpenAPI 的影响
在 Response/OpenAPI.hs 中可以看到,PostgREST 生成的 OpenAPI 文档把produces与consumes声明为[MTApplicationJSON, MTVndSingularJSON True, MTVndSingularJSON False, MTTextCSV],即文档明确告知客户端:端点可产出/消费 JSON、单数 JSON(含剥离 null 变体)与 CSV。
请求体(Request Body)
服务端处理以下请求体媒体类型:
application/jsonapplication/x-www-form-urlencodedtext/csv
对于表/视图(见 tables_views.rst),上述媒体类型适用于POST、PATCH和PUT方法;对于函数(见 functions.rst),适用于POST方法。
对于函数,还有三种额外的请求体媒体类型:
application/octet-streamtext/plaintext/xml
这些二进制/文本/XML 类型的请求体用于向函数传递单一未命名参数(single unnamed argument),详见 functions.rst 中的相关章节。
源码中的请求体解析
请求体的Content-Type头在 ApiRequest.hs 中被解析(默认application/json),实际解析逻辑位于 ApiRequest/Payload.hs:
(MTTextCSV, _) -> do json <- csvToJson <$> first BS.pack (CSV.decodeByName reqBody) note "All lines must have same number of fields" $ payloadAttributes (JSON.encode json) json (MTUrlEncoded, True) -> Right $ ProcessedUrlEncoded params (S.fromList $ fst <$> params) ... (MTTextPlain, True) -> Right $ RawPay reqBody (MTTextXML, True) -> Right $ RawPay reqBody (MTOctetStream, True) -> Right $ RawPay reqBody (ct, _) -> Left $ "Content-Type not acceptable: " <> MediaType.toMime ct从中可以确认:
- CSV 请求体会被解析为 JSON(使用
CSV.decodeByName,要求所有行的字段数一致,否则报错); application/x-www-form-urlencoded被解析为参数映射;text/plain、text/xml、application/octet-stream仅对函数调用(True表示是过程调用)以原始字节形式(RawPay)传递;- 不支持的
Content-Type返回"Content-Type not acceptable: ..."错误。
小结
PostgREST 的资源表示能力可归纳为一张速查表:
| 需求 | 写法 |
|---|---|
| 指定响应格式 | Accept: application/json、Accept: text/csv、Accept: application/geo+json |
| 单数响应 | Accept: application/vnd.pgrst.object+json |
| 剥离 null 的数组 | Accept: application/vnd.pgrst.array+json;nulls=stripped |
| 单数 + 剥离 null | Accept: application/vnd.pgrst.object+json;nulls=stripped |
| 查看执行计划 | Accept: application/vnd.pgrst.plan+json(需启用 db-plan,见 media_type_handlers.rst) |
| 请求体格式 | Content-Type: application/json、application/x-www-form-urlencoded、text/csv(函数另支持text/plain、text/xml、application/octet-stream) |
需要记住的三个关键行为:
- 列顺序不保证:不要依赖 JSON 响应中字段的排列顺序;
- 单数响应是显式行为:
vnd.pgrst.object在结果不为恰好一行时返回 406(PGRST116),不会静默降级; - 供应商媒体类型不可被自定义处理器覆盖:
vnd.pgrst.*系列由 PostgREST 内置逻辑独占处理。
通过合理组合Accept头与媒体类型参数,你可以在不改动数据库与业务代码的前提下,为不同客户端(浏览器、移动端、数据管道)提供最贴合其需求的资源表示。
【免费下载链接】postgrestREST API for any Postgres database项目地址: https://gitcode.com/GitHub_Trending/po/postgrest
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考