主题
Go 请求处理:把不可信输入变成明确结果
一个请求到达 Handler 时,参数还只是客户端发来的字节。能解码不代表合法,合法不代表有权限,响应写成功也不代表客户端完整收到了。请求处理要把这些边界分开,才知道错误该在哪里被拒绝。
本文从一个只接收 count 的 JSON 接口开始,再扩展到上传、下载和 SSE。示例运行于 Go 1.27.2、Linux/amd64;这一版本已经提供 encoding/json/v2,下文会明确区分它和原来的 encoding/json,不把两者默认行为混在一起。
参数从哪里来,就从哪里明确读取
| 来源 | Go 常用入口 | 应检查什么 |
|---|---|---|
| 路径 | r.PathValue("id") | ID 格式、范围、对象权限 |
| Query | r.URL.Query()、Values.Get | 是否允许重复值、默认值、白名单 |
| Header | r.Header.Get、mime.ParseMediaType | 内容类型、凭据格式、可信来源 |
| JSON Body | json.Decoder 或 v2 解码函数 | 大小、结构、未知字段、额外内容 |
| 普通表单 | ParseForm、PostForm | 来源优先级、字段重复、总量 |
| Multipart | MultipartReader 或 ParseMultipartForm | 总量、单文件量、字段和文件数、临时文件 |
绑定只是把文本转成字段,例如把 "12" 转成整数。校验还要判断整数是否在范围内;业务授权则判断当前身份能否修改目标对象。直接绑定数据库模型,容易让客户端写入本不该修改的 role、owner_id、approved 等字段。单独定义请求 DTO,把允许修改的字段逐个传入业务层,边界更清楚。
Query 的 Get 只取一个值。若接口要求某参数只能出现一次,就检查 len(r.URL.Query()["limit"]),不要悄悄忽略重复值。ParseForm 会合并不同来源的表单数据;安全相关字段要明确从哪一处读,不让查询参数和请求体互相覆盖。Request 的表单 API
正在绘制图示…
查看图源代码
flowchart LR
A[请求字节] --> B[限制大小与读取时间]
B --> C[检查 Content-Type]
C --> D[解码结构]
D --> E[字段与跨字段校验]
E --> F[身份和对象权限检查]
F --> G[执行业务]
G --> H[编码响应并提交]
B -. 超限 .-> X[明确的错误响应]
D -. 格式错误 .-> X
E -. 内容不合法 .-> X
F -. 不允许 .-> X
实际顺序会因接口调整,例如应尽早做廉价鉴权,避免未授权用户触发大文件解析。图表达的是不同检查的职责,而不是要求所有系统固定按同一个顺序执行。
JSON 接口先解决三个问题:多大、几份、什么字段
本例只接受一个 JSON 对象,count 必须显式提供且在 0 到 10 之间。开头先检查媒体类型,再限制实际读取量:
go
media, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || media != "application/json" {
http.Error(w, "JSON required", 415)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 128)不能只检查 Content-Length:请求可能没有声明长度,声明值也不能代替实际读取边界。MaxBytesReader 会在读超限时返回 *http.MaxBytesError,可以用 errors.As 或当前版本的 errors.AsType 判断,映射为 413。MaxBytesReader
Decoder.Decode 只读一个 JSON 值。读完第一个对象后,再 Decode 一次并要求得到 io.EOF,才能拒绝尾随的第二个值。此处继续读取也使“合法对象后附加大量空白”不能绕过总大小检查。网络读取时间仍需由 Server 配置或请求策略限制,字节上限不会阻止客户端一秒只发一个字节。
本次实测:
text
{"count":0} -> 204
{} -> 422
{"count":null} -> 422
{"count":11} -> 422
{"count":0,"extra":1} -> 400
{"count":0}{} -> 400表面上六个输入都“像 JSON”,失败原因却不同。未知字段由 DisallowUnknownFields 拒绝;两个连续对象被第二次 Decode 发现;缺失、null 和越界是本接口的业务约束。本文选择 422 表示内容校验失败,实际 API 可以统一使用 400,但要保持契约一致。
缺失、null、零值不是一回事
定义 Count int 时,{} 和 {"count":0} 解码后都会得到 0。若“用户没填写”和“用户明确填 0”有不同含义,普通 int 不够表达。
go
type input struct {
Count *int `json:"count"`
}对一个新创建的结构体,本次结果是:
text
{} present=false
{"count":null} present=false
{"count":0} present=true指针区分了缺失/空与显式 0,却没有区分缺失和 null。PATCH 场景若约定“缺失是不修改、null 是清空、数字是赋值”,需要带存在标记的自定义类型,或者先用 map[string]json.RawMessage 检查成员是否存在,再判断它是否为 null。解码前也不要随意复用带旧数据的对象:未出现的成员可能保留已有值。encoding/json.Unmarshal
omitempty 控制输出,不替你定义输入语义
本次结构体包含整数零值、指向零值的指针、nil 切片和零时间。代码中的两个时间字段只差一个标签:
go
zero := 0
v := struct {
Count int `json:"count,omitempty"`
Present *int `json:"present,omitempty"`
Tags []string `json:"tags"`
When time.Time `json:"when,omitempty"`
Omit time.Time `json:"omit,omitzero"`
}{Present: &zero}
b, err := json.Marshal(v)
if err != nil { panic(err) }
fmt.Println(string(b))v1 实测输出:
json
{"present":0,"tags":null,"when":"0001-01-01T00:00:00Z"}其中 count int 带 omitempty 被省略;present *int 非 nil,所以保留 0;nil 切片默认是 null;time.Time 是结构体,v1 的 omitempty 不把它当空,所以零时间仍出现。给另一个时间字段加 omitzero 后,它被省略。omitzero 按零值判断,并能使用类型的 IsZero 方法。Marshal 的标签规则
相同结构体交给 v2(导入名为 jsonv2),只换编码调用:
go
b, err = jsonv2.Marshal(v)
if err != nil { panic(err) }
fmt.Println(string(b))本次得到:
json
{"count":0,"present":0,"tags":[],"when":"0001-01-01T00:00:00Z"}差别不只有 nil 切片。v2 的 omitempty 按 JSON 表示是否为空判断,数字 0 和 false 不属于它要省略的 JSON 空值;要按 Go 零值省略,用 omitzero。迁移时如果只改 import,不改契约测试,就可能让客户端突然收到以前不存在的字段。
| 行为 | encoding/json 默认 | encoding/json/v2 默认 |
|---|---|---|
| nil slice / map 编码 | null | 空数组 / 空对象 |
| 结构体字段名称匹配 | 接受大小写不敏感匹配 | 区分大小写 |
| 对象重复名称 | 接受,后值覆盖或合并,视目标类型而定 | 拒绝 |
| 未知结构体成员 | 忽略;可用 Decoder 拒绝 | 忽略;可用 RejectUnknownMembers(true) 拒绝 |
omitempty 判断 | Go 中规定的一组“空值” | JSON 的 null、空字符串、空数组、空对象 |
本次 {"n":1,"n":2} 解码到整数成员,v1 得到 2,v2 报错。不要据此认为 v1 对任何目标类型都简单覆盖:结构体、map 等有合并细节。对安全敏感接口,重复键还可能让签名端、网关与业务端看见不同解释,应统一解析策略。v1 Migrating to v2、v2 Unmarshal
Go 1.27 的 v1 API 默认由新实现支撑,但保留 v1 行为;导入 v2 才选择 v2 默认语义。不能把实现更新误读成旧接口的所有默认行为都变了。Go 1.27 发布说明
时间和大整数要在契约里说清楚
time.Time 的 JSON 表示采用带时区的 RFC 3339 字符串,精度可到纳秒。它不是任意的 YYYY-MM-DD hh:mm:ss。对外接口应规定是否统一 UTC、允许的精度、是否接受偏移时区;日期“2026-10-13”与一个绝对时刻也不是同一种业务数据。Time.MarshalJSON
解码到 any 时,v1 默认把 JSON 数字变成 float64。下列差异来自同一份输入,唯一变化是解码目标:
go
in := []byte(`{"id":9007199254740993}`)
var generic map[string]any
var typed struct { ID int64 `json:"id"` }
if err := json.Unmarshal(in, &generic); err != nil { panic(err) }
if err := json.Unmarshal(in, &typed); err != nil { panic(err) }
fmt.Printf("any: %T %.0f\n", generic["id"], generic["id"])
fmt.Println("int64:", typed.ID)text
any: float64 9007199254740992
int64: 9007199254740993原数是 9007199254740993,第一种已经丢精度。Go 内部有固定结构时,直接解码到 int64。必须保留动态结构时,给 Decoder 调 UseNumber,得到 json.Number 后再显式转整数并检查错误。跨 JavaScript 等语言传输的长 ID,通常把它定义成字符串更稳妥;仅仅让 Go 正确解析,不能修复另一端 Number 的精度限制。RFC 8259 §6
JSON 性能先测真实形状,再考虑换实现
请求体较小时,网络、数据库和权限查询可能比序列化更贵。先确定 JSON 是否真是瓶颈,再比较相同业务语义下的实现。类型化 DTO 能避免动态类型断言,也让字段约束更清楚;流式 Decoder 便于接 io.Reader,但不能据此断言它总是零分配或比 Unmarshal 快。
本次只测一个包含整数、短字符串、两元素字符串数组的小对象,每次解码到新结构体。Go 1.27.2、Linux/amd64、AMD EPYC 9V74,同一命令测三次,200ms/次:
| 路径 | 三次 ns/op | B/op | allocs/op |
|---|---|---|---|
| v1,类型化结构体 | 701.0 / 593.6 / 610.1 | 96 | 3 |
| v2,类型化结构体 | 492.9 / 484.7 / 471.3 | 96 | 3 |
这组数据说明该输入、该机器上的 v2 路径更快,不证明所有 JSON 都更快,也不代表整个接口的吞吐提升同样比例。两套 API 的默认语义存在差异,正式迁移还要验证重复键、零值、大小写及历史客户端。完整基准在文末,不使用缓存对象来掩盖真实分配。
文件上传:内存阈值不等于总大小上限
ParseMultipartForm(maxMemory) 中的参数主要决定文件内容保留在内存中的量,超出部分可落临时文件;它不是整个请求的硬上限。要限制总上传量,先套 MaxBytesReader,还要限制单文件大小、文件数、字段数和处理时间。ParseMultipartForm、multipart.Reader.ReadForm
常见的两条路径:
| 方式 | 适合场景 | 必须收尾的资源 |
|---|---|---|
ParseMultipartForm + FormFile | 表单较小,需要随机访问字段和文件 | 关闭打开的文件,MultipartForm.RemoveAll() 清临时文件 |
MultipartReader + NextPart | 大文件、逐段处理、直接转存 | 每个 Part 及时关闭;自行控制每段和总量 |
客户端提交的文件名与 Content-Type 都不可信。保存时生成服务端对象名,使用固定允许目录,别把上传名直接拼到文件路径;路径清理本身也不是访问控制。按业务检查实际内容,下载时再设置受控媒体类型与 Content-Disposition。病毒扫描、图片解码的 CPU/内存消耗,同样需要预算,不能只限制原始文件字节数。
本次上传测试发送内容 hello,处理函数只计算字节数并丢弃,不保存任意路径,返回 bytes=5;它演示 RemoveAll 的收尾调用,不声称验证了某个对象存储或恶意文件扫描器。负例测试拒绝错误的 multipart 输入。
文件下载优先复用 ServeContent 的协议处理
已有一个可 seek 的文件或 reader 时,http.ServeContent 能处理 Range 与部分条件请求,省去自己实现字节范围的复杂边界。示例从 hello 下载 bytes=1-3:
go
http.ServeContent(w, r, "fixed.txt", time.Time{}, strings.NewReader("hello"))实际输出:
text
206 ell bytes 1-3/5路径对应到哪个文件、用户有没有权限、是否允许目录访问,仍然由应用决定。ServeContent 接受的是已选择的数据源,不会替你检查订单归属。ServeFile 和 FileServer 的根目录也必须审视,别把包含配置、备份、密钥的目录直接暴露出去。ServeContent
SSE:一次 HTTP 响应里分批发事件
SSE 适合服务端向浏览器持续推送进度、通知或生成结果。它是服务端到客户端的单向事件流;浏览器向服务端发命令仍可用另一个 HTTP 请求。响应媒体类型是 text/event-stream,内容为 UTF-8 文本,空行结束一个事件。HTML 标准:Server-sent events
text
id: 1
event: progress
data: {"percent":25}data: 可以出现多行;id: 帮助重连时恢复位置;retry: 可以指定重连等待时间;以冒号开头的注释常用作心跳。服务端仍需保留可重放记录并处理 Last-Event-ID,单独发送 ID 不会自动提供可靠重放或去重。
写进缓冲区,不等于客户端已经收到
正在绘制图示…
查看图源代码
flowchart LR
A[业务产生事件] --> B[格式化 SSE 文本]
B --> C[压缩或应用缓冲]
C --> D[Go HTTP 缓冲]
D --> E[反向代理或 CDN 缓冲]
E --> F[浏览器 EventSource]
每一层都可能攒数据。Go 侧写完一个事件后调用 http.NewResponseController(w).Flush(),才把当前可刷出的数据推进下层;它不保证代理也立刻转发,更不证明浏览器已经处理。代理需要单独关闭流式路径的缓冲、设置足够的空闲超时;本文没在真实代理上验证这些配置。
本次增量测试特意让 Handler 发出第一条后停在 channel 上,客户端读到完整事件后才释放它,因此不是“等 Handler 返回后一次读完”冒充流式验证。Handler 中决定顺序的是下面几行:
go
if _, err := fmt.Fprint(w, "id: 1\ndata: ready\n\n"); err != nil { return }
if err := http.NewResponseController(w).Flush(); err != nil { return }
select {
case <-release: // 客户端读到第一条后,测试才允许继续
case <-r.Context().Done():
return
}
<-r.Context().Done()测试输出:
text
first event arrived while handler remained active
handler observed client cancellation随后取消客户端 Context,服务端从 r.Context().Done() 得知并退出。每次写前可以设置新的写 deadline,防止慢客户端把写操作无限拖住;也要检查 Write 和 Flush 的错误。短的全局 Client.Timeout 会覆盖整条流,不能直接照搬普通 API 调用的设置。
中间件必须保留流式能力
ResponseWriter 接口本身没有 Flush。包装器只嵌入 http.ResponseWriter,会隐藏底层额外的方法。当前 Go 可以通过 ResponseController 找 FlushError、Flush,或沿包装器的 Unwrap() http.ResponseWriter 找到底层;不支持时会返回 http.ErrNotSupported。ResponseController
Unwrap 只提供访问底层的路径。如果包装器自己缓存或压缩内容,必须先刷它自己的缓冲,再向下 Flush。否则控制器跳到底层刷了一次,真正的数据仍在上层。鉴权、日志、超时、压缩都要对流式路由单独验收。
响应头一旦提交,后面失败就不能改成 HTTP 500。可以按既定协议发送 event: error 和受控 JSON 内容,然后关闭流;若网络写失败,连错误事件也发不出去,只能记录服务端日志,并让客户端把未出现的“完成事件”识别为中断。不要在 SSE 后面追加普通 http.Error 文本。
资料与验证范围
- Go 1.27.2:net/http、encoding/json、encoding/json/v2、time.Time JSON、mime/multipart。本次已读取对应安装版本
go doc。 - Go 1.27 发布说明:JSON v2 与 v1 的关系。
- RFC 8259 §6:跨实现数字精度边界。
- HTML 标准 SSE 章节:事件格式、EventSource、重连和 Last-Event-ID,查阅日期 2026-10-13。
- 固定 Go 源码:ResponseController、multipart formdata。
go test、go test -race、go vet 均通过;另执行三次微基准。测试包括大小超限、缺失/null、范围、未知字段、尾随 JSON、JSON 默认差异、multipart、Range、实际 TCP 上的 SSE 增量与取消。没有运行浏览器 EventSource、反向代理、对象存储或跨语言客户端。
完整复现代码
文件放在同一目录,使用 Go 1.27.2:
sh
go test -v -count=1 ./...
go test -race -count=1 ./...
go vet ./...
go test -run '^$' -bench . -benchmem -benchtime=200ms -count=3go.mod
go.mod
module example.com/webapi/03_request
go 1.27.2request_test.go
代码块 · 180 行 · 点击收起 / 展开
go
package requestdemo
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
type input struct {
Count *int `json:"count"`
}
func jsonHandler(w http.ResponseWriter, r *http.Request) {
media, _, err := mime.ParseMediaType(r.Header.Get("Content-Type"))
if err != nil || media != "application/json" {
http.Error(w, "JSON required", 415)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 128)
defer r.Body.Close()
d := json.NewDecoder(r.Body)
d.DisallowUnknownFields()
var v input
err = d.Decode(&v)
if err == nil {
var extra any
err = d.Decode(&extra)
if err == io.EOF {
err = nil
} else if err == nil {
err = errors.New("multiple JSON values")
}
}
if err != nil {
var tooLarge *http.MaxBytesError
if errors.As(err, &tooLarge) {
http.Error(w, "too large", 413)
} else {
http.Error(w, "invalid JSON", 400)
}
return
}
if v.Count == nil || *v.Count < 0 || *v.Count > 10 {
http.Error(w, "count must be 0..10", 422)
return
}
w.WriteHeader(204)
}
func upload(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1024)
defer r.Body.Close()
err := r.ParseMultipartForm(64)
if r.MultipartForm != nil {
defer r.MultipartForm.RemoveAll()
}
if err != nil {
http.Error(w, "bad multipart", 400)
return
}
f, _, err := r.FormFile("file")
if err != nil {
http.Error(w, "file required", 400)
return
}
defer f.Close()
n, err := io.Copy(io.Discard, f)
if err != nil {
http.Error(w, "read failed", 400)
return
}
_, _ = fmt.Fprintf(w, "bytes=%d", n)
}
func events(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
ctl := http.NewResponseController(w)
for i := 1; i <= 2; i++ {
select {
case <-r.Context().Done():
return
default:
}
if err := ctl.SetWriteDeadline(time.Now().Add(time.Second)); err != nil {
return
}
if _, err := fmt.Fprintf(w, "id: %d\ndata: %d\n\n", i, i); err != nil {
return
}
if err := ctl.Flush(); err != nil {
return
}
}
}
func TestJSONBoundary(t *testing.T) {
for _, tc := range []struct {
body string
want int
}{
{`{"count":0}`, 204}, {`{}`, 422}, {`{"count":null}`, 422},
{`{"count":11}`, 422}, {`{"count":0,"extra":1}`, 400},
{`{"count":0}{}`, 400}, {`{"count":0}` + strings.Repeat(" ", 128), 413},
} {
r := httptest.NewRequest("POST", "/", strings.NewReader(tc.body))
r.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
jsonHandler(w, r)
if w.Code != tc.want {
t.Errorf("body=%q got=%d want=%d", tc.body, w.Code, tc.want)
}
}
var value map[string]any
d := json.NewDecoder(strings.NewReader(`{"id":9007199254740993}`))
d.UseNumber()
if err := d.Decode(&value); err != nil {
t.Fatal(err)
}
if value["id"].(json.Number).String() != "9007199254740993" {
t.Fatal(value)
}
zero := 0
b, err := json.Marshal(struct {
A int `json:"a,omitempty"`
B *int `json:"b,omitempty"`
}{B: &zero})
if err != nil || string(b) != `{"b":0}` {
t.Fatalf("%s %v", b, err)
}
t.Log("size, missing/null, range, unknown fields, trailing JSON, omitempty, and UseNumber verified")
}
func TestUploadDownloadAndSSE(t *testing.T) {
var body bytes.Buffer
mw := multipart.NewWriter(&body)
f, err := mw.CreateFormFile("file", "untrusted.txt")
if err != nil {
t.Fatal(err)
}
if _, err = io.WriteString(f, "hello"); err != nil {
t.Fatal(err)
}
if err = mw.Close(); err != nil {
t.Fatal(err)
}
r := httptest.NewRequest("POST", "/upload", &body)
r.Header.Set("Content-Type", mw.FormDataContentType())
w := httptest.NewRecorder()
upload(w, r)
if w.Body.String() != "bytes=5" {
t.Fatal(w.Body.String())
}
r = httptest.NewRequest("GET", "/download", nil)
r.Header.Set("Range", "bytes=1-3")
w = httptest.NewRecorder()
http.ServeContent(w, r, "fixed.txt", time.Time{}, strings.NewReader("hello"))
if w.Code != 206 || w.Body.String() != "ell" {
t.Fatalf("%d %s", w.Code, w.Body.String())
}
s := httptest.NewServer(http.HandlerFunc(events))
defer s.Close()
c := &http.Client{Timeout: 2 * time.Second}
defer c.CloseIdleConnections()
resp, err := c.Get(s.URL)
if err != nil {
t.Fatal(err)
}
b, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil || string(b) != "id: 1\ndata: 1\n\nid: 2\ndata: 2\n\n" {
t.Fatalf("%q %v", b, err)
}
t.Log("multipart cleanup path, range response, and finite flushed SSE stream verified")
}teaching_test.go
代码块 · 203 行 · 点击收起 / 展开
go
package requestdemo
import (
"bufio"
"context"
"encoding/json"
jsonv2 "encoding/json/v2"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
func TestJSONTeaching(t *testing.T) {
zero := 0
v := struct {
Count int `json:"count,omitempty"`
Present *int `json:"present,omitempty"`
Tags []string `json:"tags"`
When time.Time `json:"when,omitempty"`
Omit time.Time `json:"omit,omitzero"`
}{Present: &zero}
b, err := json.Marshal(v)
if err != nil {
t.Fatal(err)
}
t.Log("v1:", string(b))
b, err = jsonv2.Marshal(v)
if err != nil {
t.Fatal(err)
}
t.Log("v2:", string(b))
for _, in := range []string{`{}`, `{"count":null}`, `{"count":0}`} {
var x input
if err := json.Unmarshal([]byte(in), &x); err != nil {
t.Fatal(err)
}
t.Logf("%s present=%v", in, x.Count != nil)
}
var generic map[string]any
if err := json.Unmarshal([]byte(`{"id":9007199254740993}`), &generic); err != nil {
t.Fatal(err)
}
t.Logf("any: %T %.0f", generic["id"], generic["id"])
var typed struct {
ID int64 `json:"id"`
}
if err := json.Unmarshal([]byte(`{"id":9007199254740993}`), &typed); err != nil {
t.Fatal(err)
}
t.Log("int64:", typed.ID)
var duplicate struct {
N int `json:"n"`
}
if err := json.Unmarshal([]byte(`{"n":1,"n":2}`), &duplicate); err != nil {
t.Fatal(err)
}
t.Log("v1 duplicate:", duplicate.N)
err = jsonv2.Unmarshal([]byte(`{"n":1,"n":2}`), &duplicate)
t.Log("v2 duplicate rejected:", err != nil)
if err == nil {
t.Fatal("duplicate must be rejected")
}
}
func TestIncrementalSSEAndCancel(t *testing.T) {
release := make(chan struct{})
stopped := make(chan struct{})
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer close(stopped)
w.Header().Set("Content-Type", "text/event-stream")
ctl := http.NewResponseController(w)
if err := ctl.SetWriteDeadline(time.Now().Add(2 * time.Second)); err != nil {
return
}
if _, err := fmt.Fprint(w, "id: 1\ndata: ready\n\n"); err != nil {
return
}
if err := ctl.Flush(); err != nil {
return
}
select {
case <-release:
case <-r.Context().Done():
return
}
<-r.Context().Done()
}))
defer s.Close()
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", s.URL, nil)
if err != nil {
t.Fatal(err)
}
c := &http.Client{}
defer c.CloseIdleConnections()
resp, err := c.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
var lines []string
for scanner.Scan() {
if scanner.Text() == "" {
break
}
lines = append(lines, scanner.Text())
}
if err := scanner.Err(); err != nil {
t.Fatal(err)
}
if strings.Join(lines, "|") != "id: 1|data: ready" {
t.Fatal(lines)
}
select {
case <-stopped:
t.Fatal("handler ended before release")
default:
}
t.Log("first event arrived while handler remained active")
close(release)
cancel()
select {
case <-stopped:
t.Log("handler observed client cancellation")
case <-time.After(2 * time.Second):
t.Fatal("cancel not observed")
}
}
func TestBoundaryStatusOutput(t *testing.T) {
for _, body := range []string{`{"count":0}`, `{}`, `{"count":null}`, `{"count":11}`, `{"count":0,"extra":1}`, `{"count":0}{}`} {
w := httptest.NewRecorder()
r := httptest.NewRequest("POST", "/", strings.NewReader(body))
r.Header.Set("Content-Type", "application/json")
jsonHandler(w, r)
t.Logf("%s -> %d", body, w.Code)
}
r := httptest.NewRequest("POST", "/", strings.NewReader(`{"count":0}`))
r.Header.Set("Content-Type", "text/plain")
w := httptest.NewRecorder()
jsonHandler(w, r)
if w.Code != 415 {
t.Fatal(w.Code)
}
r = httptest.NewRequest("POST", "/", strings.NewReader(strings.Repeat("x", 2048)))
r.Header.Set("Content-Type", "multipart/form-data; boundary=demo")
w = httptest.NewRecorder()
upload(w, r)
if w.Code != 400 {
t.Fatal(w.Code)
}
t.Log("wrong media type=415; malformed/oversized upload rejected")
}
var jsonInput = []byte(`{"id":42,"name":"gopher","tags":["go","http"]}`)
type payload struct {
ID int `json:"id"`
Name string `json:"name"`
Tags []string `json:"tags"`
}
var sink payload
func BenchmarkJSON(b *testing.B) {
b.Run("v1-typed", func(b *testing.B) {
for b.Loop() {
var x payload
if err := json.Unmarshal(jsonInput, &x); err != nil {
b.Fatal(err)
}
sink = x
}
})
b.Run("v2-typed", func(b *testing.B) {
for b.Loop() {
var x payload
if err := jsonv2.Unmarshal(jsonInput, &x); err != nil {
b.Fatal(err)
}
sink = x
}
})
}
func Example_serveContent() {
r := httptest.NewRequest("GET", "/download", nil)
r.Header.Set("Range", "bytes=1-3")
w := httptest.NewRecorder()
http.ServeContent(w, r, "fixed.txt", time.Time{}, strings.NewReader("hello"))
res := w.Result()
defer res.Body.Close()
body, _ := io.ReadAll(res.Body)
fmt.Println(res.StatusCode, string(body), res.Header.Get("Content-Range"))
// Output:
// 206 ell bytes 1-3/5
}