跳转到内容
getnextpdf.com

稳定性: 测试版

通过 REST 端到端渲染一张发票

通过 NextPDF Connect 的 Representational State Transfer(REST,表述性状态转移)接口,把一张发票从 JSON 一路推进到本地验证过的 PDF,一次一笔连接层交换。这份 recipe(示例)会把渲染工作提交到 POST /api/v1/jobs,以相同的 Idempotency-Key 重放提交,借此展示重复也安全的路径,轮询 GET /api/v1/jobs/{id},从 GET /api/v1/jobs/{id}/result 下载 PDF,用 qpdf --check 检查字节,最后删除已完成的工作。

下方的每一段响应都是从一套真实的 core 分级 Connect 部署(RoadRunner 下的 nextpdf/server,绑定到 http://localhost:8080)逐字捕获而来的。唯一的替换是 API 密钥,以 $NEXTPDF_CONNECT_TOKEN 环境变量的形式呈现;工作标识符、请求标识符、时间戳、标头和正文字节都完全是服务器返回的内容。诸如 DateX-Request-Id 之类的标头值在你的部署上当然会有所不同。

这份 recipe 只驱动一份文档,方便你完整地阅读每一次交换。若需处理多份文档、有界并发以及由 Retry-After 驱动的轮询循环,请参阅批量生成 PDF 并跟踪进度,它使用同一套工作接口。

服务器端使用标准的 Connect 发行版:

Terminal window
composer require nextpdf/server

这份 recipe 的客户端只需要 curl 加上 qpdf,因此你可以把它移植到任何 HTTP 客户端。先为你的部署导出各项取值:

/docs/connect/quickstart/
export NEXTPDF_CONNECT_URL="http://localhost:8080"
export NEXTPDF_CONNECT_TOKEN="npk_live_{kid}_{secret}" # your real key
# Key provisioning and server startup live in the quickstart:

异步工作接口把提交与获取分离开:你提交一个渲染请求,收到一条工作记录,并在工作进入 completed 时取回结果。渲染请求本身是一个有序的 operations 数组——正是那些在每种传输上支撑 Connect 工具调用的操作类型(set_fontadd_textadd_tableadd_imageadd_page)——再加上文档级字段(page_sizeorientationtitleauthor)。

有两处契约细节塑造了你即将阅读的这段记录:

  • 幂等提交。Idempotency-Key 作为键的提交在第一次返回 201 Created,在重放时返回 200 OK 并带有同一条工作记录,因此网络重试绝不会渲染两次。
  • 提交可能已经是终止状态。 当前版本会在回应 POST 之前就地处理该工作,因此提交响应可能已经带有 status: "completed"——正如下方所示。轮询直至终止的契约才是稳定的 API 形态:写好轮询循环,并在任何一次尝试(包括第一次)中接受终止状态。

在提交任何内容之前,你可以先确认你的部署对外暴露了什么:GET /api/v1/capabilities 会返回你的 API 密钥所属分级可以使用的操作目录。在这里捕获的 core 分级部署上,它只列出了 core 操作;真正权威的目录始终是运行中服务器自身的响应,而非本页。

交换方法与路径捕获到的状态
提交渲染工作POST /api/v1/jobs201 Created
重放相同的提交POST /api/v1/jobs(相同的 Idempotency-Key200 OK
轮询工作记录GET /api/v1/jobs/{id}200 OK
下载 PDFGET /api/v1/jobs/{id}/result200 OKapplication/pdf
删除已完成的工作DELETE /api/v1/jobs/{id}204 No Content

每个 /api/v1/* 请求都通过一枚承载令牌(bearer token)进行身份验证:Authorization: Bearer npk_live_{kid}_{secret}。成功的 JSON 响应共享 { "data": ..., "meta": ... } 信封;你据以行动的字段位于 data 之下。

把渲染请求写入 invoice.json。它是一份朴素、确定性的操作列表——一行加粗的抬头、一行开票信息,以及一张明细表格:

{
"page_size": "A4",
"orientation": "portrait",
"title": "Invoice INV-2026-0042",
"author": "Aurora Fixtures Ltd.",
"operations": [
{ "type": "set_font", "family": "helvetica", "style": "B", "size": 16 },
{ "type": "add_text", "text": "Invoice INV-2026-0042" },
{ "type": "set_font", "family": "helvetica", "style": "", "size": 10 },
{ "type": "add_text", "text": "Issued 2026-07-08 by Aurora Fixtures Ltd. Payment is due within 30 days.", "width": 0, "line_height": 5 },
{ "type": "add_table", "html": "<table><tr><th>Item</th><th>Qty</th><th>Unit price</th><th>Amount</th></tr><tr><td>Cable tray, 300 mm</td><td>12</td><td>18.40</td><td>220.80</td></tr><tr><td>Mounting kit</td><td>4</td><td>9.75</td><td>39.00</td></tr><tr><td>Site delivery</td><td>1</td><td>25.00</td><td>25.00</td></tr><tr><td>Total (EUR)</td><td></td><td></td><td>284.80</td></tr></table>" }
]
}

这里的发票字段是样例数据。每个操作的权威参数形态,就是你的部署所报告的那一份——在 MCP 上,tools/list 会为这个请求所用的每种操作类型返回完整的输入 schema(模式)。

Terminal window
curl -sS -i -X POST "$NEXTPDF_CONNECT_URL/api/v1/jobs" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: inv-2026-0042" \
--data-binary @invoice.json

服务器回应 201 Created

HTTP/1.1 201 Created
Cache-Control: no-store
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
Content-Type: application/json; charset=utf-8
Referrer-Policy: no-referrer
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Powered-By: NextPDF Connect
X-Ratelimit-Remaining: 99
X-Request-Id: 019f3fd6-c6ed-727c-958e-2fa4370ba97e
Date: Wed, 08 Jul 2026 03:47:48 GMT
Transfer-Encoding: chunked
{
"data": {
"job_id": "job_9bd0808960f10eb568484acc",
"status": "completed",
"created_at": "2026-07-08T03:47:48+00:00",
"started_at": "2026-07-08T03:47:48+00:00",
"completed_at": "2026-07-08T03:47:48+00:00",
"result_url": "/api/v1/jobs/job_9bd0808960f10eb568484acc/result"
},
"meta": {
"request_id": "019f3fd6-c6ed-727c-958e-2fa4370ba97e",
"timestamp": "2026-07-08T03:47:48+00:00",
"duration_ms": 63.31,
"api_version": "v1"
}
}

在这次捕获中,工作已经是终止状态——status"completed"result_url 已存在——因为当前版本会在回应之前就地渲染。不要依赖这一点:把提交响应当作第一次轮询结果,并像对待任何其他轮询那样根据 data.status 分支处理。

重试完全相同的命令——相同的 Idempotency-Key、相同的正文:

Terminal window
curl -sS -i -X POST "$NEXTPDF_CONNECT_URL/api/v1/jobs" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: inv-2026-0042" \
--data-binary @invoice.json

服务器返回 200 OK——而非 201——带有相同的 job_id,且不会发生第二次渲染(把 meta.duration_ms 与第一次响应作对比):

HTTP/1.1 200 OK
Cache-Control: no-store
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
Content-Type: application/json; charset=utf-8
Referrer-Policy: no-referrer
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Powered-By: NextPDF Connect
X-Ratelimit-Remaining: 99
X-Request-Id: 019f3fd6-c756-7226-bc85-b255d75bd449
Date: Wed, 08 Jul 2026 03:47:48 GMT
Transfer-Encoding: chunked
{
"data": {
"job_id": "job_9bd0808960f10eb568484acc",
"status": "completed",
"created_at": "2026-07-08T03:47:48+00:00",
"started_at": "2026-07-08T03:47:48+00:00",
"completed_at": "2026-07-08T03:47:48+00:00",
"result_url": "/api/v1/jobs/job_9bd0808960f10eb568484acc/result"
},
"meta": {
"request_id": "019f3fd6-c756-7226-bc85-b255d75bd449",
"timestamp": "2026-07-08T03:47:48+00:00",
"duration_ms": 1.03,
"api_version": "v1"
}
}
Terminal window
curl -sS -i "$NEXTPDF_CONNECT_URL/api/v1/jobs/job_9bd0808960f10eb568484acc" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN"
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
Content-Type: application/json; charset=utf-8
Referrer-Policy: no-referrer
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Powered-By: NextPDF Connect
X-Ratelimit-Remaining: 98
X-Request-Id: 019f3fd7-0038-7131-b71e-4c69e637233b
Date: Wed, 08 Jul 2026 03:48:02 GMT
Transfer-Encoding: chunked
{
"data": {
"job_id": "job_9bd0808960f10eb568484acc",
"status": "completed",
"created_at": "2026-07-08T03:47:48+00:00",
"started_at": "2026-07-08T03:47:48+00:00",
"completed_at": "2026-07-08T03:47:48+00:00",
"result_url": "/api/v1/jobs/job_9bd0808960f10eb568484acc/result"
},
"meta": {
"request_id": "019f3fd7-0038-7131-b71e-4c69e637233b",
"timestamp": "2026-07-08T03:48:02+00:00",
"duration_ms": 0.24,
"api_version": "v1"
}
}

这次轮询显示的是一条终止记录,因此既没有 Retry-After 标头,也没有 poll_url 字段。而当工作仍处于 pendingrunning 时,服务器会在每次轮询时都设置 Retry-After(一个 2 秒的间隔)——请遵循它,而不要在密集循环中轮询。

Terminal window
curl -sS -D result-headers.txt \
"$NEXTPDF_CONNECT_URL/api/v1/jobs/job_9bd0808960f10eb568484acc/result" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN" \
-o invoice-inv-2026-0042.pdf
HTTP/1.1 200 OK
Cache-Control: no-store
Content-Disposition: attachment; filename="job-job_9bd0808960f10eb568484acc.pdf"
Content-Length: 3663
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
Content-Type: application/pdf
Referrer-Policy: no-referrer
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Powered-By: NextPDF Connect
X-Ratelimit-Remaining: 98
X-Request-Id: 019f3fd7-0056-711e-acad-9f7ac5f21ba7
Date: Wed, 08 Jul 2026 03:48:02 GMT

正文是 PDF 二进制内容——在这次捕获中为 3,663 字节,与 Content-Length 标头一致——在此从略。它被写入 invoice-inv-2026-0042.pdf

一个带 Content-Type: application/pdf200,就其本身而言,并不能证明正文是一份格式良好的 PDF。用 qpdf 运行一次结构检查:

Terminal window
qpdf --check invoice-inv-2026-0042.pdf

针对上面下载的文件所捕获到的输出:

checking invoice-inv-2026-0042.pdf
PDF Version: 2.0
File is not encrypted
File is not linearized
No syntax or stream encoding errors found; the file may still contain
errors that qpdf cannot detect

qpdf 自己的措辞就是那条诚实的边界:这是一次语法与流的检查,而非针对任何标准的符合性判定。

Terminal window
curl -sS -i -X DELETE "$NEXTPDF_CONNECT_URL/api/v1/jobs/job_9bd0808960f10eb568484acc" \
-H "Authorization: Bearer $NEXTPDF_CONNECT_TOKEN"
HTTP/1.1 204 No Content
Cache-Control: no-store
Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'none'
Content-Type: application/json; charset=utf-8
Referrer-Policy: no-referrer
Vary: Accept-Encoding
X-Content-Type-Options: nosniff
X-Frame-Options: DENY
X-Powered-By: NextPDF Connect
X-Ratelimit-Remaining: 97
X-Request-Id: 019f3fd7-0084-722f-bf54-3dabead8aeea
Date: Wed, 08 Jul 2026 03:48:02 GMT

删除之后,工作记录及其存储的结果都不复存在;随后对该工作的 GET 会返回 404

  • 根据 data.status 分支,而不要只看 HTTP 状态。 提交、重放和轮询都会带着一条工作记录返回 2xx;生命周期状态位于 data.statuspendingrunningcompletedfailedcancelled)。
  • 重放的键搭配不同的正文会得到 409 Conflict 幂等的 200 重放只有在正文与原始提交一致时才会发生。切勿把一把键重用于不同的内容。
  • 完成前访问 /result 会得到 409 只在轮询显示 completed 之后再下载。这个 409 是需要审视的正常响应,而非传输故障——正是每份 Connect recipe 都遵循的那套传输层与状态层分离(参阅 recipe 惯例)。
  • 工作按所有者作用域隔离。 用一把 API 密钥提交的工作,对另一把密钥不可见:跨所有者的 GET 返回 404 而非 403。用你提交时所用的凭据去轮询。
  • progress 可能不存在。 捕获到的记录没有 progress 字段,因为该工作已经是终止状态。当服务器为某个非终止工作跟踪进度时,data.progress 是一个从 0 到 100 的整数;请把缺失的字段视为未知,而非零。
  • failed 的工作会带有 data.error 记录它;不要盲目重新提交。

一个渲染工作的成本是一次提交、至多寥寥几次轮询和一次下载。捕获到的 meta.duration_ms 值说明了这一点:在提交时用 63.31 毫秒渲染发票,用 1.03 毫秒完成那次未做任何实际渲染的幂等重放,以及亚毫秒级的状态读取。请按服务器的 Retry-After 节奏轮询,而不要用密集循环;状态读取虽廉价但并非免费,而且速率限制器会把它计入预算(观察捕获到的标头中 X-Ratelimit-Remaining 的递减)。对于批量处理,请对在途工作数量设界,而不要一次性提交所有内容——批量 recipe 实现了那个循环。

  • 只把承载令牌放在 Authorization 标头里。 绝不放进查询字符串、日志行或已提交的文件。上面的记录正是出于这一原因用一个环境变量作了替换。
  • 在信任下载的字节之前先验证它们。 第 5 步是流程的一部分,而非可选附加项:在归档或转发之前,确认响应是一份 PDF(至少要有 %PDF 标头,用 qpdf --check 做结构检查)。
  • 删除你不再需要的已完成工作。 第 6 步会把存储的结果从服务器移除;否则,一个已完成的工作会一直可供下载,直到服务器的工作垃圾回收将其清除。
  • 使用最小权限的密钥。 这套流程需要一把 core 分级的渲染密钥,仅此而已。

这份 recipe 不提出任何规范性标准主张。它会使用 Connect 异步工作 REST 端点,并读取服务器所定义的工作记录字段。qpdf --check 那一步只确认结构完整性——“the file may still contain errors that qpdf cannot detect” 是 qpdf 自己的告诫,已在上面逐字引用。判定是否符合某项标准(PDF/A-4、PDF/UA)是独立验证器的职责,且属于不同的接口——那条边界请参阅运行具名标准检查