InvenioRDM REST API 使用详解
InvenioRDM 14.0.6 REST API 全生命周期实测:访问令牌三路径(/api/token 实测 404、CLI 与 UI applications 页签发)、草稿创建与编辑(PUT 无乐观锁、全量替换、字段错误不阻断写入)、文件三步上传(init/content/per-file commit)、发布 202 与 pids.oai 生成、检索最终一致与分页聚合、OAI-PMH 端点纠正(/oai2d、8 元数据前缀、已删除记录 500 缺陷)、删除 204 与 410 墓碑,含 20 条负向用例速查与 8 条 FAQ。
InvenioRDM REST API 使用详解
目标:在 《InvenioRDM Docker Compose 部署指南》 部署完成的 InvenioRDM 14.0.6 实栈上,用 curl 逐条实测 REST API 的完整生命周期——访问令牌获取、草稿创建与编辑、文件三步上传、发布、检索与下载、OAI-PMH 元数据收割、删除与墓碑,并收录全部负向用例与实测踩坑(PUT 草稿无乐观锁、403 而非 401、OAI 端点路径、已删除记录 OAI 500 等)。产品定位与选型见 《InvenioRDM 介绍与选型指南》,文中涉及的配置键(SITE_API_URL、词表 fixtures 等)在 《InvenioRDM 14.0.6 配置参数详解》 有逐段解释。本文所有命令输入均为实测,所有回显均为真实输出逐行照抄。
一、环境与 API 端点地图
实验环境沿用部署篇交付的 10 服务栈:应用服务 web-api(承载 /api/* REST 接口)与 web-ui(承载页面与 OAI-PMH 端点)两个 uWSGI 应用并列,前端 nginx 将宿主 https://127.0.0.1:8138 反代到栈内 443,自签证书故 curl 一律带 -k。管理员账号 root@my-site.com / q1w2e3r4(部署篇 4.7 节创建,持有 admin/system 角色)。开工前复核栈与 API 可用性:
$ curl -sk -o /dev/null -w '%{http_code}\n' https://127.0.0.1:8138/
200
$ curl -sk https://127.0.0.1:8138/api/records | python3 -c "import json,sys;d=json.load(sys.stdin);print('total=',d['hits']['total'])"
total= 2
InvenioRDM 的 REST API 端点按职责分组如下(本文实测覆盖全部加粗项):
| 端点 | 方法 | 用途 |
|---|---|---|
/api/records | GET/POST | 检索已发布记录 / 创建草稿(注意:创建走这里,不是 /records/draft) |
/api/records/<pid> | GET/DELETE | 读取已发布记录 / 删除已发布记录(墓碑) |
/api/records/<pid>/draft | GET/PUT/DELETE | 读取 / 全量更新草稿 / 删除草稿 |
/api/records/<pid>/draft/actions/publish | POST | 发布草稿 |
/api/records/<pid>/draft/files | GET/POST | 文件清单 / 上传初始化(init) |
/api/records/<pid>/draft/files/<key>/content | PUT/GET | 上传字节流 / 读取 |
/api/records/<pid>/draft/files/<key>/commit | POST | 单文件提交 |
/api/records/<pid>/draft/pids/doi | POST | 预留 DOI(本栈未配 DataCite 客户端凭据,不展开) |
/api/records/<pid>/versions | GET | 版本列表 |
/api/records/<pid>/files/<key>/content | GET | 已发布文件下载 |
/api/records/<pid>/access/*、/communities、/requests 等 | — | 权限授予、社群挂接、请求流,草稿 201 响应的 links 里有全套入口 |
/oai2d | GET | OAI-PMH 收割端点(挂在 web-ui,不在 /api/ 前缀下) |
一个容易误解的结构性事实:「草稿」与「已发布记录」共用同一个 pid。POST /api/records 直接返回 201 与草稿 JSON;发布动作不改变 pid,只是让同一 pid 从「仅 /draft 路径可见」变为「裸 /api/records/<pid> 可读」。v14 不存在 POST /api/records/draft 这种旧版路径,实测回显:
$ curl -sk -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{}' https://127.0.0.1:8138/api/records/draft
{"message":"The method is not allowed for the requested URL.","status":405}
二、访问令牌获取:三条路径实测
InvenioRDM 的 REST 写操作需要个人访问令牌(Personal Access Token,HTTP 头 Authorization: Bearer <token>)。v14.0.6 默认发行版的令牌获取路径实测如下,结论先行:没有开箱可用的「用户名密码换 token」REST 端点,签发要经 CLI 或 Web UI。
2.1 官方 token 端点不存在(负向实测)
很多 API 文档习惯先试 /api/token。本栈实测该路径与 oauth2 变体均为 Flask 风格 404:
$ curl -sk -X POST https://127.0.0.1:8138/api/token
{"message":"The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.","status":404}
$ curl -sk -X POST https://127.0.0.1:8138/api/oauth2/token
{"message":"The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.","status":404}
原因:invenio-oauth2server 的授权码/密码流端点未随 app-rdm 默认蓝图挂载到 /api 前缀;会话 cookie 直接调 API 又会被 CSRF 拦截(部署期实测:无 Referer 报 Referer checking failed - no Referer.,补 Referer 后报 CSRF token missing or incorrect.)。所以不要尝试用 curl 模拟登录换 token,走下面两条正路。
2.2 路径一:invenio CLI 签发(本文主路径)
在应用服务内执行 invenio tokens create(工程目录下):
$ docker compose -f docker-compose.full.yml exec web-api invenio tokens create -n d5-api-demo -u root@my-site.com
[2026-11-01 15:30:14,726] DEBUG in entrypoint: Loading config for entry point invenio_app_rdm.config
3Mn1J6pCKcjpGfwT7HYjhi7fzWFcCnfDWHlYNQQJkg6nP93H9j6Dhqt6QX9w
要点:
- 令牌明文只在创建回显中出现一次,数据库只存哈希,丢了只能重签。
- v14 旧版常用的
deposits:read/deposits:writescope 已移除,带-s指定会直接报错:
$ docker compose -f docker-compose.full.yml exec web-api invenio tokens create -n scope-test -u root@my-site.com -s deposits:read
File "/opt/invenio/src/.venv/lib/python3.14/site-packages/invenio_oauth2server/validators.py", line 48, in validate_scopes
raise ScopeDoesNotExists(value)
invenio_oauth2server.errors.ScopeDoesNotExists
不带 -s 即签发无受限 scope 的令牌,对 root(superuser)足够。
- 签发后立即可用。用
invenio tokens list想核对会报Error: No such command 'list'.——v14 CLI 没有列表子命令,管理已有令牌要走 UI。
2.3 路径二:Web UI 的 Applications 页
会话登录流程(cookie jar + CSRF)实测:
$ curl -sk -c jar.txt https://127.0.0.1:8138/login/ | grep -o 'name="csrf_token"[^>]*value="[^"]*"'
# (取 value,91 字符)
$ curl -sk -b jar.txt -c jar.txt -o /dev/null -w '%{http_code} %{redirect_url}\n' \
-d "email=root@my-site.com&password=q1w2e3r4&csrf_token=<CSRF>" https://127.0.0.1:8138/login/
302 https://127.0.0.1:8138/
登录后访问令牌管理页。注意路径:老版文档常见的 /account/settings/token/new 在 v14 实测 404(<title>Page not found | My Site</title>),/account/settings/token/、/account/settings/tokens 同样 404;实际入口是 /account/settings/applications/:
$ curl -sk -b jar.txt -o /dev/null -w '%{http_code}\n' https://127.0.0.1:8138/account/settings/applications/
200
$ grep -oiE 'personal access token|New token' applications.html | sort -u
New token
Personal access token
该页为 React 客户端渲染,页面源码含 "Personal access token" 与 "New token" 控件文案,浏览器打开后可图形化签发/撤销令牌(侧栏实测链接组:applications / notifications / password / profile / security)。
2.4 令牌的使用与鉴权负向用例
所有写操作带 Authorization: Bearer <token>。两个值得记下的鉴权行为:
- 无凭证的写请求返回 403 而不是 401:
$ curl -sk -X POST -H 'Content-Type: application/json' -d '{}' https://127.0.0.1:8138/api/records
{"status": 403, "message": "Permission denied."}
- 匿名可读端点不校验无效令牌:带一个乱写的 Bearer 头查列表,照样 200(token 被当作不存在,按匿名处理):
$ curl -sk -H 'Authorization: Bearer FAKE-TOKEN-xyz' 'https://127.0.0.1:8138/api/records?size=1' -o /dev/null -w '%{http_code}\n'
200
写脚本做集成测试时,401/403 语义与常见 OAuth 直觉不同,断言状态码时按 403 写。
三、创建草稿:POST /api/records
请求体(存为 d5-body-create.json)——先给一份「字段格式全部正确」的完整示例:
{
"metadata": {
"title": "QuZuo InvenioRDM REST API Demo",
"creators": [{"person_or_org": {"given_name": "Zuo", "family_name": "Qu", "type": "personal"}}],
"publication_date": "2026-11-01",
"publisher": "QuZuo",
"resource_type": {"id": "dataset"},
"languages": [{"id": "eng"}],
"descriptions": [{"description": "REST API lifecycle demo record for the D5 document.", "type": {"id": "description"}}],
"rights": [{"id": "cc-by-4.0"}],
"version": "1.0.0"
},
"access": {"record": "public", "files": "public"}
}
$ curl -sk -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
--data-binary @d5-body-create.json https://127.0.0.1:8138/api/records
实测 201,响应节选(完整体见取证文件 d5-01-create.json):
{
"id": "8cc0v-7t275",
"revision_id": 5,
"is_published": false,
"is_draft": true,
"status": "draft",
"links": {
"self": "https://127.0.0.1/api/records/8cc0v-7t275/draft",
"record": "https://127.0.0.1/api/records/8cc0v-7t275",
"publish": "https://127.0.0.1/api/records/8cc0v-7t275/draft/actions/publish",
"files": "https://127.0.0.1/api/records/8cc0v-7t275/draft/files",
"versions": "https://127.0.0.1/api/records/8cc0v-7t275/versions"
},
"metadata": {
"resource_type": {"id": "dataset", "title": {"en": "Dataset", "ar": "…", "de": "Datensatz", "…": "…"}},
"creators": [{"person_or_org": {"type": "personal", "name": "Qu, Zuo", "given_name": "Zuo", "family_name": "Qu"}}],
"title": "QuZuo InvenioRDM REST API Demo",
"publisher": "QuZuo",
"publication_date": "2026-11-01",
"languages": [{"id": "eng", "title": {"en": "English"}}],
"version": "1.0.0",
"rights": [{"id": "cc-by-4.0", "title": {"en": "Creative Commons Attribution 4.0 International"},
"description": {"en": "The Creative Commons Attribution license allows re-distribution and re-use of a licensed work on the condition that the creator is appropriately credited."},
"icon": "cc-by-icon",
"props": {"url": "https://creativecommons.org/licenses/by/4.0/legalcode", "scheme": "spdx"}}]
},
"access": {"record": "public", "files": "public", "embargo": {"active": false, "reason": null}, "status": "metadata-only"},
"files": {"enabled": true, "order": [], "count": 0, "total_bytes": 0, "entries": {}},
"errors": [{"field": "metadata.descriptions", "messages": ["Unknown field."]}]
}
逐项解读这份回显:
- 词表回填:
resource_type.id、languages.id、rights.id只提交 id,服务端从已载入的词表(部署篇 4.5 节invenio rdm fixtures的产物)回填完整多语言对象。creators里没写name,服务端按Family, Given规则自动拼出"name": "Qu, Zuo"。词表未载入时这些 id 会校验失败——这正是部署期「400 Invalid value dataset」坑(已收录部署篇)的根源。 rights是许可证的正确字段名。曾用license字段提交,服务端静默丢弃(201 成功但 GET 回来没有该字段),发布时才因必填缺失报错。- 本实例的 schema 拒绝
metadata.descriptions:201 响应里直接带errors: [{"field": "metadata.descriptions", "messages": ["Unknown field."]}],且 GET 草稿确认该字段未入库。也就是说InvenioRDM 建草稿即使 201,也可能带字段级错误——务必检查响应体的errors数组,或创建后 GET 草稿核对字段存留(见第四节)。 links.self指向/draft:草稿态下所有读写都要走 draft 路径。access.status此时是metadata-only(还没有文件),发布后变为open。
草稿可见性实测(两条路径状态码不同,容易混淆):
$ curl -sk https://127.0.0.1:8138/api/records/8cc0v-7t275
{"status": 404, "message": "The persistent identifier does not exist."}
$ curl -sk https://127.0.0.1:8138/api/records/8cc0v-7t275/draft
{"status": 403, "message": "Permission denied."}
匿名 GET 裸 pid 报 404(发布前该 pid 对公共检索不存在),GET /draft 报 403(路径存在但无权限)。带正确 Bearer 则 /draft 返回 200 全量草稿。
四、编辑草稿:PUT 无乐观锁 + 全量替换(本文最重要发现)
草稿编辑用 PUT(不是 PATCH),语义是全量替换。v14.0.6 实测给出两个对集成开发相当硬核的结论。
4.1 正常编辑
请求体(d5-body-put.json,改标题、加 version、附送一个不存在的 keywords 字段试探):
{"revision":5,
"metadata":{"title":"QuZuo InvenioRDM REST API Demo (edited)",
"creators":[{"person_or_org":{"given_name":"Zuo","family_name":"Qu","type":"personal"}}],
"publication_date":"2026-11-01","publisher":"QuZuo",
"resource_type":{"id":"dataset"},"keywords":["rest-api","quzuo"],"version":"1.0.1"},
"access":{"record":"public","files":"public"}}
$ curl -sk -X PUT -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
--data-binary @d5-body-put.json https://127.0.0.1:8138/api/records/8cc0v-7t275/draft
HTTP 200,revision_id=11
errors=[{"field":"metadata.keywords","messages":["Unknown field."]},
{"field":"files.enabled","messages":["Missing uploaded files. To disable files for this record please mark it as metadata-only."]}]
响应 200;keywords 进 errors 数组并被丢弃(GET 回来 metadata keys 里没有它);files.enabled 报错是因为此刻还没有文件——该错误同样不阻断写入。
4.2 负向实测:故意用错误的 revision 且字段残缺
$ curl -sk -X PUT -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"revision":1,"metadata":{"title":"wrong-rev"}}' \
https://127.0.0.1:8138/api/records/8cc0v-7t275/draft
HTTP/2 200
200 响应体节选:
{"id": "8cc0v-7t275", "status": "draft", "revision_id": 9,
"metadata": {"title": "wrong-rev"},
"errors": [
{"field": "metadata.resource_type", "messages": ["Missing data for required field."]},
{"field": "metadata.creators", "messages": ["Missing data for required field."]},
{"field": "metadata.publication_date", "messages": ["Missing data for required field."]},
{"field": "files.enabled", "messages": ["Missing uploaded files. To disable files for this record please mark it as metadata-only."]}]}
随后 GET 草稿确认:title 真的变成了 wrong-rev,metadata 只剩 ['title'] 一个键——第三节的完整元数据被这次请求整体覆盖丢失。三个结论:
revision字段完全不参与并发校验。请求里写 1、写 999、不写,服务端都照单全收(发布端点同样如此,见第五节实测{"revision":999}→ 202)。revision_id只是记录版本号计数器,每次 PUT 递增(8→9→11),v14.0.6 的 REST API 没有乐观锁,不存在 409 冲突保护。- PUT 是全量替换不是增量合并。请求体没带的 metadata 字段一律被删。
- 必填字段缺失、字段名非法都只进
errors数组,HTTP 状态码仍是 200,数据照常落库。校验的真正闸口在发布动作(第五节)。
由此得出的客户端纪律(写自动化脚本必须遵守):
- 编辑草稿永远按「GET 草稿 → 本地修改 → 全量 PUT 回传」流程,绝不能只 PUT 差异字段;
- 每次 PUT 后检查响应体的
errors数组,有内容就说明有字段被丢弃或缺失,立即修正重传; - 不要指望 revision 防并发覆盖,多客户端协作需自行在应用层加锁。
另外部署期实测还有一个连带坑(记录于此):PUT 草稿会把已 init 未 commit 的文件传输重置,之后对该文件 commit 会 404,需要重新走一遍 init→PUT content→commit;而 init 对已存在的 key 再 POST 会报 400(见第五节用例)。所以正确顺序是:元数据一次 PUT 到位,再传文件。
五、文件上传:三步曲
InvenioRDM 文件上传是经典 invenio-files-rest 三步协议:init(登记清单)→ PUT content(传字节)→ commit(逐文件提交)。实验文件:
$ printf 'InvenioRDM REST API demo file. Created 2026-11-01.\n' > d5-demo.csv
$ stat -c %s d5-demo.csv; md5sum d5-demo.csv
51
3abf6ca2e625101b90ed8093a9efa2bc
5.1 init:登记文件清单
body 必须是顶层 JSON 数组(不是 {"files":[...]} 包裹,空 body 直接 400):
$ curl -sk -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '[{"key":"api-demo.csv","size":51,"checksum":"md5:3abf6ca2e625101b90ed8093a9efa2bc"}]' \
https://127.0.0.1:8138/api/records/8cc0v-7t275/draft/files
实测 201,响应(完整体 d5-06-files-init.json):
{"enabled": true,
"links": {"self": "https://127.0.0.1/api/records/8cc0v-7t275/draft/files",
"archive": "https://127.0.0.1/api/records/8cc0v-7t275/draft/files-archive"},
"entries": [{"key": "api-demo.csv", "checksum": "md5:3abf6ca2e625101b90ed8093a9efa2bc", "size": 51,
"status": "pending", "transfer": {"type": "L"},
"links": {"self": "…/draft/files/api-demo.csv",
"content": "…/draft/files/api-demo.csv/content",
"commit": "…/draft/files/api-demo.csv/commit"}}],
"default_preview": null, "order": []}
注意响应是顶层结构(enabled/entries/links/order/default_preview 直接平铺),不是嵌在 files 键下。status: "pending" 表示等待传输。
空 body 与重复 key 的负向回显:
$ curl -sk -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '' \
https://127.0.0.1:8138/api/records/zpfcy-23698/draft/files
{"status": 400, "message": "A validation error occurred.", "errors": [{"field": "_schema", "messages": ["No files to upload."]}]}
$ curl -sk -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '[{"key":"api-demo.csv","size":51,"checksum":"md5:3abf6ca2e625101b90ed8093a9efa2bc"}]' \
https://127.0.0.1:8138/api/records/8cc0v-7t275/draft/files
{"status": 400, "message": "File with key api-demo.csv already exists."}
5.2 传字节:PUT content
$ curl -sk -X PUT -H "Authorization: Bearer $TOK" -H 'Content-Type: application/octet-stream' \
--data-binary @d5-demo.csv \
https://127.0.0.1:8138/api/records/8cc0v-7t275/draft/files/api-demo.csv/content
HTTP 200(响应体为该文件的 entry JSON)
Content-Type 用 application/octet-stream;响应返回单文件 entry。
5.3 commit:逐文件提交
commit 是 per-file 端点、方法是 POST。对集合级路径 …/draft/files/commit 发 POST 实测 405:
$ curl -sk -X POST -H "Authorization: Bearer $TOK" https://127.0.0.1:8138/api/records/8cc0v-7t275/draft/files/commit
{"message":"The method is not allowed for the requested URL.","status":405}
$ curl -sk -X POST -H "Authorization: Bearer $TOK" \
https://127.0.0.1:8138/api/records/8cc0v-7t275/draft/files/api-demo.csv/commit
实测 200,响应(完整体 d5-10-commit.json):
{"key": "api-demo.csv", "status": "completed",
"checksum": "md5:3abf6ca2e625101b90ed8093a9efa2bc", "size": 51,
"mimetype": "text/csv", "storage_class": "L",
"file_id": "5ce381aa-69c9-46e7-9caa-6b45eadffbb1",
"bucket_id": "26ca367e-3216-48de-8d1d-8178cc94c8ad",
"version_id": "00cabbe6-cd3e-4555-b031-517f6ff663f5",
"links": {"self": "…/draft/files/api-demo.csv", "content": "…/draft/files/api-demo.csv/content",
"commit": "…/draft/files/api-demo.csv/commit"}}
status 从 pending 变 completed,服务端校验了 md5 并识别出 mimetype: text/csv。
5.4 文件未 commit 就发布(负向)
init 后跳过 commit 直接发布,实测 400:
$ curl -sk -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"revision":10}' https://127.0.0.1:8138/api/records/8cc0v-7t275/draft/actions/publish
{"status": 400, "message": "A validation error occurred.",
"errors": [{"field": "files", "messages": ["One or more files have not completed their transfer, please wait."]}]}
这是全生命周期里第一个真正阻断的校验——草稿阶段的所有 errors 都只是提示,发布阶段才是硬闸门。
六、发布:POST draft/actions/publish
三个文件步骤齐备后发布(此刻草稿 revision_id=11):
$ curl -sk -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"revision":11}' https://127.0.0.1:8138/api/records/8cc0v-7t275/draft/actions/publish
HTTP 202
响应节选(完整体 d5-12-publish.json):
{"id": "8cc0v-7t275", "revision_id": 4, "is_published": true, "is_draft": false, "status": "published",
"pids": {"oai": {"identifier": "oai:my-site.com:8cc0v-7t275", "provider": "oai"}},
"metadata": {"title": "QuZuo InvenioRDM REST API Demo (edited)", "version": "1.0.1",
"resource_type": {"id": "dataset"}, "creators": [{"person_or_org": {"name": "Qu, Zuo"}}], "…": "…"},
"access": {"record": "public", "files": "public", "status": "open"},
"files": {"enabled": true, "count": 1, "total_bytes": 51,
"entries": {"api-demo.csv": {"key": "api-demo.csv", "checksum": "md5:3abf6ca2e625101b90ed8093a9efa2bc",
"size": 51, "ext": "csv", "mimetype": "text/csv", "storage_class": "L"}}},
"deletion_status": {"is_deleted": false, "status": "P"},
"stats": {"this_version": {"views": 0, "unique_views": 0, "downloads": 0, "unique_downloads": 0, "data_volume": 0.0}, "…": "…"},
"links": {"self": "https://127.0.0.1/api/records/8cc0v-7t275", "draft": "…/draft", "versions": "…/versions", "…": "…"}}
要点:
- 202 Accepted(不是 200/201),发布后的索引写入由 Celery 异步完成(见第七节延迟)。
pids.oai.identifier在发布时生成,格式oai:<SITE_URL域名>:<pid>,即第八节 OAI-PMH 的 identifier。- 已发布记录的
revision_id与草稿的 revision_id 是两套独立计数:草稿此刻是 11,发布响应里是 4。别拿草稿号去猜发布记录的版本号。 - 再次发布同一 pid 实测 404(发布后 draft 资源消失):
$ curl -sk -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"revision":11}' https://127.0.0.1:8138/api/records/8cc0v-7t275/draft/actions/publish
{"status": 404, "message": "Not found."}
- 发布端点同样不校验 revision:另一份 metadata-only 草稿用
{"revision":999}发布照样 202(见第十节用例表)。 - 想编辑已发布记录的内容,走「新草稿版本」路径:GET 已发布记录后对
links.draft做 PUT(发布后/draft会重新出现,指向 v2 草稿),本篇聚焦 v1 生命周期不展开。
6.1 metadata-only 记录(无文件发布)
不传文件、在请求体里显式关闭文件位即可发布纯元数据记录(回应第四节 files.enabled 的提示语 "mark it as metadata-only"):
$ curl -sk -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' -d '{
"metadata":{"title":"Metadata Only Demo","creators":[{"person_or_org":{"given_name":"Zuo","family_name":"Qu","type":"personal"}}],
"publication_date":"2026-11-01","resource_type":{"id":"dataset"},"publisher":"QuZuo"},
"access":{"record":"public","files":"public"},"files":{"enabled":false}}' \
https://127.0.0.1:8138/api/records
HTTP 201 id=my1r5-cmp07 revision_id=5
$ curl -sk -X POST -H "Authorization: Bearer $TOK" -H 'Content-Type: application/json' \
-d '{"revision":999}' https://127.0.0.1:8138/api/records/my1r5-cmp07/draft/actions/publish
HTTP 202
发布后 files 结构为 {"enabled": false, "order": [], "count": 0, "total_bytes": 0, "entries": {}}。
七、检索、读取与下载
7.1 检索:最终一致性
发布返回 202 后立即检索:
$ curl -sk 'https://127.0.0.1:8138/api/records?q=edited&size=10' | python3 -c "import json,sys;d=json.load(sys.stdin);print('total=',d['hits']['total'],'ids=',[h['id'] for h in d['hits']['hits']])"
total= 1 ids= ['8cc0v-7t275']
本次回放即时命中;但同一栈的部署期实测中,发布后检索曾出现约 20 秒延迟(OpenSearch 索引经 Celery 任务异步写入)。结论:检索是最终一致的,自动化流程发布后应轮询检索结果而不是假设即时可见;直接 GET /api/records/<pid> 读数据库则是即时的(见 7.2)。
7.2 读取已发布记录(匿名)
$ curl -sk https://127.0.0.1:8138/api/records/8cc0v-7t275 -o d5-15-anon-get.json -w 'HTTP %{http_code}\n'
HTTP 200
$ python3 -c "import json;d=json.load(open('d5-15-anon-get.json'));print(d['metadata']['title'],'|',d['status'])"
QuZuo InvenioRDM REST API Demo (edited) | published
access.record=public 的记录发布后匿名可读,无需令牌。
7.3 分页、排序与聚合
$ curl -sk 'https://127.0.0.1:8138/api/records?size=1' | python3 -c "import json,sys;d=json.load(sys.stdin);print(d['links']['self'],'|',d['links']['next']);print('aggs=',list(d['aggregations'].keys()))"
https://127.0.0.1/api/records?page=1&size=1&sort=newest | https://127.0.0.1/api/records?page=2&size=1&sort=newest
aggs= ['publication_date', 'access_status', 'file_type', 'resource_type']
- 分页参数
page/size,响应links.self/links.next直接给出下一页完整 URL;默认排序newest,可sort=mostviewed等。 aggregations返回四个分面桶(resource_type桶内含各类型doc_count,如 Dataset=2),可直接做前端筛选侧栏。- 检索语法支持字段限定与布尔组合,如
q=metadata.title:demo AND access_status:Open。
7.4 版本列表
$ curl -sk -H "Authorization: Bearer $TOK" https://127.0.0.1:8138/api/records/x3kgj-3rt46/versions -o d5-28-versions.json -w 'HTTP %{http_code}\n'
HTTP 200
响应为 hits 列表,每个版本含 self/parent/reserve_doi 等 links;同一 pid 家族由 parent.id(本例 8qsb3-ncn81)归组。
7.5 文件下载
$ curl -sk -D d5-16-dl-head.txt -o d5-16-dl.csv -w 'HTTP %{http_code} size=%{size_download}\n' \
https://127.0.0.1:8138/api/records/8cc0v-7t275/files/api-demo.csv/content
HTTP 200 size=51
$ cat d5-16-dl.csv
InvenioRDM REST API demo file. Created 2026-11-01.
响应头实测(节选 d5-16-dl-head.txt):
HTTP/2 200
server: nginx/1.31.8
content-type: text/plain; charset=utf-8
content-length: 51
content-md5: 3abf6ca2e625101b90ed8093a9efa2bc
content-disposition: inline
etag: "md5:3abf6ca2e625101b90ed8093a9efa2bc"
last-modified: Sun, 01 Nov 2026 15:32:34 GMT
x-ratelimit-limit: 500
x-ratelimit-remaining: 490
strict-transport-security: max-age=31556926; includeSubDomains
细节:etag 与 content-md5 都直接暴露文件 md5,可用作完整性校验与条件请求;content-disposition: inline(浏览器内联预览,附件下载语义走 UI 端点);x-ratelimit-* 头说明 API 有全局限流(默认 500/窗口),批量脚本注意 retry-after。
八、OAI-PMH 元数据收割:端点在 /oai2d,不在 /api/
部署期曾按直觉探测 /api/oai2h、/api/oai2d、/api/oai,全部 404。本篇把这最后一个疑点闭环:OAI-PMH server 挂在 web-ui 服务的 /oai2d 根路径,不带 /api 前缀:
$ for p in /api/oai2d /api/oai2h /api/oai; do curl -sk -o /dev/null -w "$p %{http_code}\n" "https://127.0.0.1:8138$p?verb=Identify"; done
/api/oai2d 404
/api/oai2h 404
/api/oai 404
$ curl -sk -o /dev/null -w '/oai2d %{http_code}\n' 'https://127.0.0.1:8138/oai2d?verb=Identify'
/oai2d 200
8.1 Identify
$ curl -sk 'https://127.0.0.1:8138/oai2d?verb=Identify'
完整回显:
<?xml version='1.0' encoding='UTF-8'?>
<OAI-PMH xmlns="http://www.openarchives.org/OAI/2.0/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.openarchives.org/OAI/2.0/ http://www.openarchives.org/OAI/2.0/OAI-PMH.xsd">
<responseDate>2026-11-01T15:33:36Z</responseDate>
<request verb="Identify">https://127.0.0.1/oai2d</request>
<Identify>
<repositoryName>My Site</repositoryName>
<baseURL>https://127.0.0.1/oai2d</baseURL>
<protocolVersion>2.0</protocolVersion>
<adminEmail>info@my-site.com</adminEmail>
<earliestDatestamp>2026-11-01T13:52:01Z</earliestDatestamp>
<deletedRecord>no</deletedRecord>
<granularity>YYYY-MM-DDThh:mm:ssZ</granularity>
</Identify>
</OAI-PMH>
repositoryName/adminEmail 来自实例配置(配置篇的 THEME_SITENAME、OAISERVER_ADMIN_EMAILS);baseURL 回显 https://127.0.0.1/oai2d 不带 8138 端口,是 SITE_UI_URL 未含端口的连带现象(部署篇已记录),生产在标准 443 反代下无碍。
8.2 ListMetadataFormats 与 ListSets
$ curl -sk 'https://127.0.0.1:8138/oai2d?verb=ListMetadataFormats' | grep -o '<metadataPrefix>[^<]*'
<metadataPrefix>marcxml
<metadataPrefix>oai_dc
<metadataPrefix>dcat
<metadataPrefix>marc21
<metadataPrefix>datacite
<metadataPrefix>oai_datacite
<metadataPrefix>datacite4
<metadataPrefix>oai_datacite4
$ curl -sk 'https://127.0.0.1:8138/oai2d?verb=ListSets' | grep -o '<setSpec>[^<]*'
<setSpec>openaire
<setSpec>openaire_data
8 个元数据前缀(OAI 核心 oai_dc、DataCite 系 datacite/oai_datacite/datacite4/oai_datacite4、MARC 系 marcxml/marc21、DCAT);2 个集合(OpenAIRE 兼容视图,本例记录都带 setSpec: openaire_data)。
8.3 GetRecord
$ curl -sk 'https://127.0.0.1:8138/oai2d?verb=GetRecord&identifier=oai:my-site.com:8cc0v-7t275&metadataPrefix=oai_dc'
<GetRecord>
<record>
<header>
<identifier>oai:my-site.com:8cc0v-7t275</identifier>
<datestamp>2026-11-01T15:32:47Z</datestamp>
<setSpec>openaire_data</setSpec>
</header>
<metadata>
<oai_dc:dc xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:oai_dc="http://www.openarchives.org/OAI/2.0/oai_dc/">
<dc:creator>Qu, Zuo</dc:creator>
<dc:date>2026-11-01</dc:date>
<dc:identifier>oai:my-site.com:8cc0v-7t275</dc:identifier>
<dc:publisher>QuZuo</dc:publisher>
<dc:rights>info:eu-repo/semantics/openAccess</dc:rights>
<dc:title>QuZuo InvenioRDM REST API Demo (edited)</dc:title>
<dc:type>info:eu-repo/semantics/other</dc:type>
</oai_dc:dc>
</metadata>
</record>
</GetRecord>
identifier 就是发布响应里的 pids.oai.identifier。datacite4 前缀输出 DataCite Kernel 4.5 schema,实测节选(对另一条记录 x3kgj-3rt46):
<resource xmlns="http://datacite.org/schema/kernel-4" xsi:schemaLocation="http://datacite.org/schema/kernel-4 http://schema.datacite.org/meta/kernel-4.5/metadata.xsd">
<alternateIdentifiers>
<alternateIdentifier alternateIdentifierType="oai">oai:my-site.com:x3kgj-3rt46</alternateIdentifier>
</alternateIdentifiers>
<creators>
<creator>
<creatorName nameType="Personal">Qu, Zuo</creatorName>
<givenName>Zuo</givenName>
<familyName>Qu</familyName>
</creator>
</creators>
<titles><title>QuZuo InvenioRDM API Demo Dataset</title></titles>
<publisher>QuZuo</publisher>
<publicationYear>2026</publicationYear>
<dates>
<date dateType="Issued">2026-11-01</date>
<date dateType="Updated">2026-11-01</date>
</dates>
<resourceType resourceTypeGeneral="Dataset"></resourceType>
<version>1.0.0</version>
</resource>
两个供收割程序注意的实测细节:命名空间是默认 xmlns 而非 datacite: 前缀(XPath 解析要按命名空间写,别按前缀匹配);<resourceType> 元素文本为空、仅 resourceTypeGeneral="Dataset" 属性有值。
8.4 OAI 负向用例与一个产品缺陷
非法 verb 与不存在 identifier 都按 OAI 协议返回错误元素(HTTP 仍 200):
$ curl -sk 'https://127.0.0.1:8138/oai2d?verb=Foo' | grep -o '<error[^>]*>[^<]*'
<error code="badArgument">This is not a valid OAI-PMH verb:Foo
$ curl -sk 'https://127.0.0.1:8138/oai2d?verb=GetRecord&identifier=oai:my-site.com:nonexist&metadataPrefix=oai_dc' | grep -o '<error[^>]*>[^<]*'
<error code="idDoesNotExist">No matching identifier
但对「已删除记录」的 GetRecord 不走协议错误,直接 500(第九节删除 8cc0v-7t275 后实测):
$ curl -sk -o /dev/null -w '%{http_code}\n' 'https://127.0.0.1:8138/oai2d?verb=GetRecord&identifier=oai:my-site.com:8cc0v-7t275&metadataPrefix=oai_dc'
500
# 响应体是 HTML 错误页:<meta name="generator" content="InvenioRDM 14.0">、<title>Internal server error | My Site</title>
web-ui 服务日志(docker compose -f docker-compose.full.yml logs web-ui 节选):
GET /oai2d?verb=GetRecord&identifier=oai:my-site.com:8cc0v-7t275&metadataPrefix=oai_dc => generated 8341 bytes in 10 msecs (HTTP/2.0 500)
对照同参数下未删除记录 → 200。Identify 声明 deletedRecord=no(不输出墓碑记录),ListIdentifiers 也确实不含已删记录(实测 grep 计数 0),但按 identifier 定点取已删记录时服务端未处理墓碑、抛未捕获异常成 500。属 14.0.6 产品缺陷,收割方按「ListRecords/ListIdentifiers 全量增量收割、不定点重放旧 identifier」的策略规避。
同份日志里另有两条与 API 行为无关但值得留档的 WARNING:
[2026-11-01 15:35:11,954] WARNING in api: NotFoundError(404, 'index_not_found_exception', 'no such index [my-site-stats-record-view]', …)
[2026-11-01 15:35:11,962] WARNING in api: NotFoundError(404, 'index_not_found_exception', 'no such index [my-site-stats-file-download]', …)
stats 索引未创建(第七节响应里 stats 全 0 的根因),属独立统计功能的初始化范畴,不影响记录 API 本身。
九、删除与墓碑
9.1 删除草稿
未发布草稿直接删 /draft:
$ curl -sk -X DELETE -H "Authorization: Bearer $TOK" -o /dev/null -w 'HTTP %{http_code}\n' \
https://127.0.0.1:8138/api/records/zsb0g-yx662/draft
HTTP 204
$ curl -sk -H "Authorization: Bearer $TOK" -w 'HTTP %{http_code}\n' -o /dev/null \
https://127.0.0.1:8138/api/records/zsb0g-yx662/draft
HTTP 404
204 No Content(空响应体),草稿彻底消失、再 GET 404——草稿删除是物理删除,无墓碑。
9.2 删除已发布记录(软删除 + 410 墓碑)
$ curl -sk -X DELETE -H "Authorization: Bearer $TOK" -o /dev/null -w 'HTTP %{http_code}\n' \
https://127.0.0.1:8138/api/records/8cc0v-7t275
HTTP 204
$ curl -sk -w 'HTTP %{http_code}\n' https://127.0.0.1:8138/api/records/8cc0v-7t275
{"status": 410, "message": "The record has been deleted."}
三个实测语义:
- DELETE 成功返回 204;
- 之后 GET 返回 410 Gone 而非 404——已发布记录是软删除,留墓碑(发布响应里
deletion_status.status:"P"即 Pending→Deleted 的对应机制);带管理员 Bearer 也一样 410,本实例未开启 include_deleted 视图参数; - 检索端约 30 秒后从索引移除:
$ curl -sk 'https://127.0.0.1:8138/api/records?q=edited' | python3 -c "import json,sys;print(json.load(sys.stdin)['hits']['total'])"
{'total': 0}
OAI 端的表现见 8.4(列表不含、定点 GetRecord 500)。metadata-only 演示记录 my1r5-cmp07 用同样流程删除,回显一致(204→410)。收工前库存量:published 记录 zpfcy-23698、x3kgj-3rt46 两条,total=2;两条墓碑不再出现在任何列表端点。
十、坑与负向用例速查表
本篇与部署期在同一实栈上的全部负向实测汇总(状态码均为真实回显):
| # | 场景 | 请求 | 实测结果 |
|---|---|---|---|
| 1 | 无凭证写操作 | POST /api/records(无 Authorization) | 403 Permission denied.(不是 401) |
| 2 | 伪造 Bearer 读公开端点 | GET /api/records?size=1 | 200(按匿名处理,不报错) |
| 3 | token 端点 | POST /api/token、/api/oauth2/token | 404(默认发行版未挂载) |
| 4 | 旧版草稿路径 | POST /api/records/draft | 405 |
| 5 | 旧 scope 签发令牌 | invenio tokens create -s deposits:read | CLI 抛 ScopeDoesNotExists |
| 6 | 非法 metadata 字段 | keywords / descriptions / license | 201/200 但字段进 errors 并静默丢弃(descriptions 在本实例也被拒 "Unknown field.") |
| 7 | PUT 草稿错误 revision | {"revision":1,...} | 200 照常覆盖——无乐观锁、无 409 |
| 8 | PUT 草稿字段残缺 | 只传 title | 200,其余 metadata 全量丢失 |
| 9 | 重复 init 同 key | POST draft/files | 400 File with key api-demo.csv already exists. |
| 10 | 空 body init | POST draft/files(空) | 400 No files to upload. |
| 11 | 集合级 commit | POST draft/files/commit | 405(commit 是 per-file、方法 POST) |
| 12 | 文件未 commit 发布 | POST actions/publish | 400 One or more files have not completed their transfer |
| 13 | 重复发布 | 对已发布 pid 再 publish | 404 Not found. |
| 14 | publish 错误 revision | {"revision":999} | 202 照常发布 |
| 15 | 匿名读草稿 | GET /api/records/<pid>(草稿期) | 404;GET <pid>/draft → 403 |
| 16 | OAI 路径 | /api/oai2d、/api/oai2h、/api/oai | 404;正确为 /oai2d |
| 17 | OAI 非法 verb / 不存在 id | verb=Foo / identifier=nonexist | 协议错误 badArgument / idDoesNotExist(HTTP 200) |
| 18 | OAI 取已删除记录 | GetRecord 墓碑 identifier | HTTP 500(产品缺陷,见 8.4) |
| 19 | 删除草稿 | DELETE <pid>/draft | 204,再 GET 404(物理删除) |
| 20 | 删除已发布 | DELETE <pid> | 204,再 GET 410(墓碑,匿名与管理员同) |
十一、FAQ
Q1:能不能用 curl 模拟登录拿 cookie 直接调 API?
不推荐且实测受阻:会话 cookie 调 /api 被 CSRF 拦(Referer checking failed → 补 Referer 后 CSRF token missing or incorrect.)。程序化访问一律用 Bearer 令牌。
Q2:令牌怎么给受限用户做最小权限?
v14 默认 scope 集不含 deposits:*,invenio tokens create 不带 -s 签发的是无受限 scope 令牌(继承用户角色)。要做细粒度授权需启用 invenio-oauth2server 的应用(applications)流程,UI 的 /account/settings/applications/ 页即其入口。
Q3:为什么我 PUT 之后字段没了?
PUT 是全量替换(第四节)。标准流程:GET 草稿 → 修改 → 全量 PUT → 检查响应 errors → 再动文件。
Q4:发布 202 之后检索查不到?
索引异步(第七节)。GET /api/records/<pid> 即时可读;列表检索轮询到出现为止,或消费 Celery 队列完成信号。
Q5:文件传完为什么发布报 400?
每个文件都要单独 POST commit(5.3/5.4),status: completed 后才行;批量文件逐个 commit。
Q6:OAI 收割程序按 /api/oai2d 写的全 404?
端点在 /oai2d(第八节)。且不要定点重放已删除记录的 identifier(会 500),走 ListRecords 增量 + resumptionToken 翻页。
Q7:删除能撤销吗?记录还能找回吗?
REST 层没有 unreserve/undelete 端点实测通过;已发布记录删除后留 410 墓碑,恢复需走管理侧(invenio CLI / 数据库层),生产删除动作前务必确认合规流程(请求流 request_deletion links 是面向用户的删除申请通道,非直接删除)。
Q8:403 和 404 混用怎么断言? 按第十节表记:写权限失败 403、资源不存在 404、墓碑 410、方法不对 405、校验阻断 400、异步受理 202。Invenio 的语义与 REST 教科书有出入(尤其 401→403、草稿 404/403 分裂),集成测试断言以本表实测为准。
十二、小结
InvenioRDM 14.0.6 的 REST API 以「同一 pid 的草稿-发布双态」为核心模型:POST /api/records 建草稿、PUT /draft 全量编辑、文件 init/content/commit 三步、POST actions/publish 202 发布、/api/records?q= 最终一致检索、/oai2d 对外收割、DELETE 区分草稿物理删(204→404)与已发布墓碑(204→410)。集成时最需要内化的三条纪律:令牌走 CLI/UI 签发(没有密码换 token 端点)、PUT 无乐观锁且全量替换(GET-修改-PUT-查 errors)、校验闸门在发布不在草稿(201/200 也可能带字段错误)。这些行为官方文档语焉不详,全部来自本篇在同一实栈上的逐条 curl 实测。
延伸阅读:产品能力边界与版本线见 《InvenioRDM 介绍与选型指南》;本文实验栈的搭建与初始化(词表 fixtures、文件 location、管理员与令牌前置)见 《InvenioRDM Docker Compose 部署指南》;文中 SITE_UI_URL、OAISERVER_ADMIN_EMAILS、限流与安全响应头等配置键的取值与覆盖机制见 《InvenioRDM 14.0.6 配置参数详解》。