请求的结构
方法、URL、请求头和请求体,以及服务器实际收到的内容。
在上一课中,你在浏览器里输入一个 URL 发送了请求,其余的一切都由浏览器替你填好了。这一课把请求拆开,让你知道每一部分是什么,因为你写的每个测试都会有意设置其中一些部分,并检查服务器如何反应。
一个 HTTP 请求有四个部分:
- 方法:说明你想做哪种操作
- URL:说明要对哪台服务器上的哪个对象执行操作
- 请求头:携带关于这个请求的额外信息
- 可选的请求体:携带你要发送的数据
请求在网络上的样子
HTTP/1.1 是一种文本协议。在加密之前,一个请求只是几行纯文本。下面是 curl 为 https://api.testingapis.com/api/echo?color=red 发送的请求。你可以用 -v(详细模式)运行 curl 亲眼看到它:以 > 开头的行就是发出去的内容。
GET /api/echo?color=red HTTP/1.1
Host: api.testingapis.com
User-Agent: curl/8.18.0
Accept: */*第一行是请求行:方法、带查询字符串的路径,以及协议版本。之后的每一行都是一个请求头,由冒号分隔的名称和值组成。一个空行表示请求头结束。如果有请求体,它会出现在这个空行之后。较新的 HTTP 版本(HTTP/2 和 HTTP/3)以二进制格式发送同样的信息,但组成部分完全相同,所有工具也仍然以这种文本形式展示给你。
看看服务器收到了什么
了解请求最简单的方法,是把请求发给一台会告诉你它收到了什么的服务器。/api/echo 正是这样做的。它接受任何方法,并用一段对你请求的描述来回复。发送这个请求:
/api/echo?color=red&size=10下面是从 curl 发送时得到的回复,只保留了有意思的字段。如果你从本页发送,请求头会变成你浏览器的请求头。
{
"method": "GET",
"url": "http://api.testingapis.com/api/echo?color=red&size=10",
"path": "/api/echo",
"queryString": "?color=red&size=10",
"query": { "color": "red", "size": "10" },
"headers": {
"Accept": "*/*",
"Host": "api.testingapis.com",
"User-Agent": "curl/8.18.0",
"Accept-Encoding": "gzip, br",
"Cf-Ipcountry": "IN",
"X-Forwarded-For": "203.0.113.7, 172.71.198.178, 10.30.107.46",
"X-Forwarded-Proto": "https"
},
"contentType": null,
"contentLength": 0,
"body": null
}这里有两点值得再看一眼。服务器看到的 URL 是 http://,而不是 https://。它还收到了你从未发送过的请求头,比如 Cf-Ipcountry 和 X-Forwarded-For。你的请求在到达应用之前经过了 CDN 和负载均衡器,它们各自改写或添加了一些内容。X-Forwarded-For 中的第一个地址是你的(上面那个是示例)。这在生产系统中很正常,这也正是回显端点有用的原因:它展示的是实际到达的内容,而不是你以为自己发送的内容。
方法
方法就是动词。GET 请求数据,POST 发送数据来创建某样东西,PUT、PATCH 和 DELETE 修改或删除东西。浏览器地址栏只能发送 GET,所以其余的方法需要用别的工具。方法有专门的一课,即HTTP 方法,所以现在只需要知道:同一个 URL 可以根据方法的不同做不同的事。
URL
把这个 URL 拆成几个部分:
https://api.testingapis.com/api/Products?category=books&limit=5| 部分 | 值 | 含义 |
|---|---|---|
| 协议 | https | 如何连接。https 是加密连接中的 HTTP。 |
| 主机 | api.testingapis.com | 哪台服务器。它也会作为 Host 请求头发送。 |
| 路径 | /api/Products | 这台服务器上的哪个资源。 |
| 查询字符串 | category=books&limit=5 | 额外的参数,位于 ? 之后,用 & 分隔。 |
URL 还可以带端口(主机后面的 :8080)和片段(末尾的 #section)。在测试环境和本地环境中你会经常看到端口。片段根本不会到达服务器:它留在浏览器里。
路径参数和查询参数
值可以放在路径里,也可以放在查询字符串里。在 /api/Products/1 中,1 是一个路径参数。它标识一个特定的商品,文档会把这个路径写成 /api/Products/{id}。在 /api/Products?limit=5 中,limit 是一个查询参数。查询参数通常用来筛选、排序或分页,而且通常是可选的。这种划分是一种惯例而不是规则,所以每个 API 都要查看它的文档。
路径通常区分大小写,查询参数的名称通常也区分。这取决于服务器使用的框架,所以拿到任何新的 API 都可以快速测一下:用 /api/products 代替 /api/Products 发送,看看会发生什么。
百分号编码
有些字符在 URL 中有特定的作用。? 开始查询字符串,& 分隔参数,# 开始片段,/ 分隔路径段,而空格则完全不允许出现。要把这些字符当作数据发送,就要把它换成 % 加上它的十六进制字节值。空格变成 %20,& 变成 %26。这叫做百分号编码,也叫 URL 编码。
假设你想搜索“red shoes”,并按一个值恰好是文本 a&b 的标签来筛选。编码之后,一切正常:
/api/echo?q=red%20shoes&tag=a%26b{
"queryString": "?q=red%20shoes&tag=a%26b",
"query": { "q": "red shoes", "tag": "a&b" }
}现在不编码 & 符号再发送这个标签:
/api/echo?tag=a&b{
"queryString": "?tag=a&b",
"query": { "tag": "a", "b": "" }
}服务器现在看到了两个参数:值为 a 的 tag,以及一个名为 b 的空参数。没有错误,也没有警告。请求“成功”了,数据却是错的。这是那些靠拼接字符串来构建 URL 的客户端中真实存在的一类 bug,也正是测试时适合在搜索框里输入 & 符号、加号、斜杠和非英文文本的原因。如果需要手动转换,可以用本站的URL 编码工具。
请求头
请求头是描述请求的名称和值的组合。名称不区分大小写,所以 content-type 和 Content-Type 的意思相同。大多数客户端会自动添加若干请求头。你最常设置或检查的是这些:
| 请求头 | 表示什么 | 示例 |
|---|---|---|
Content-Type | 你发送的请求体的格式。 | application/json |
Accept | 你愿意接收的格式。 | application/json |
User-Agent | 哪个客户端发送了这个请求。 | curl/8.18.0 |
Authorization | 凭据,比如一个令牌。 | Bearer abc123 |
Host | 服务器上的哪个站点。客户端会根据 URL 填写它。 | api.testingapis.com |
当一个 API 能以多种格式响应时,Accept 就很重要。/api/formats/negotiate登录后查看 默认以 JSON 返回同一个用户,如果你要求,就以 XML 返回:
/api/formats/negotiate把这份 XML 和不带该请求头时得到的 JSON 比较一下。两者包含相同的七个字段,只是按各自格式的方式书写:roles 列表变成了重复的 role 元素,为 null 的 manager 变成了一个标有 xsi:nil 的空元素。当一个 API 提供多种格式时,要逐一测试,不要假设它们一致。这一课最初写成时,这个用户的 XML 版本少了三个字段。
Authorization 会在身份验证与授权中详细讲解。有一件事你现在就该知道:永远不要把真实的令牌贴到截图、bug 报告或共享的集合里。它们就是密码。
请求体
GET 请求通常没有请求体。POST、PUT 和 PATCH 通常有,因为它们要向服务器发送数据。请求体可以是任何格式,由 Content-Type 请求头告诉服务器如何读取它。如今大多数 API 的请求体都是 JSON。下面用 curl 向回显端点发送一个小小的 JSON 对象:
curl -X POST https://api.testingapis.com/api/echo \
-H "Content-Type: application/json" \
-d '{"title":"Dune","year":1965}'{
"method": "POST",
"path": "/api/echo",
"query": {},
"contentType": "application/json",
"contentLength": 28,
"body": "{\"title\":\"Dune\",\"year\":1965}",
"json": { "title": "Dune", "year": 1965 }
}服务器读取了请求头,看到 application/json,于是把这 28 个字节解析成一个对象。你也可以从这里发送同样的请求:
/api/echo{"title":"Dune","year":1965}当 Content-Type 和请求体不一致时
现在用 curl 发送同样的请求体,但去掉这个请求头:
curl -X POST https://api.testingapis.com/api/echo \
-d '{"title":"Dune"}'{
"contentType": "application/x-www-form-urlencoded",
"contentLength": 16,
"body": "{\"title\":\"Dune\"}",
"json": null,
"form": { "{\"title\":\"Dune\"}": "" }
}curl 的 -d 选项默认使用 application/x-www-form-urlencoded,也就是 HTML 表单使用的格式。服务器相信了这个请求头,尝试把请求体当作表单读取,结果得到一个以整段 JSON 文本为名称的表单字段。没有返回任何错误。很多真实的服务器也是这样处理的,另一些则会返回 415 Unsupported Media Type。 缺失或错误的 Content-Type 是请求“看起来没问题”却仍然失败的最常见原因之一,所以当服务器忽略你的数据时,首先要检查的就是它。
如果请求头正确但 JSON 损坏了,好的服务器会明确指出。下面是请求体为 {"title":"Dune",} (末尾多了一个逗号)时回显响应的一部分:
"json": null,
"jsonError": "The JSON object contains a trailing comma at the end which is not supported in this mode. Change the reader options. LineNumber: 0 | BytePositionInLine: 16."练习
用 /api/echo 回答每个问题。每次都要看响应,不要靠猜。
- 在浏览器地址栏发送
/api/echo?name=Zoë&city=São Paulo。浏览器在发送之前改了 URL 的哪些地方?服务器又把它解码成了什么? - 发送
/api/echo?id=1&id=2。服务器如何表示一个出现了两次的参数?不同的框架在这一点上做法不同,所以对你测试的任何 API 来说这都是个好问题。 - 发送一个带 JSON 请求体和
Content-Type: text/plain的 POST。服务器是否照样解析了 JSON? - 分别从浏览器和 curl 访问
/api/echo/user-agent。API 为什么会在意你用的是哪一个?