主题
WebSocket、GraphQL 与 Webhook:三种不同的问题,三条不同的链路
聊天界面需要双方随时发消息,页面想一次取齐几个关联对象,支付平台要通知你订单状态变化。这三件事都常出现在 Web 系统里,但它们不是三种可以互换的“更高级 HTTP”。
WebSocket 改变连接上的通信方式,GraphQL 定义查询与执行模型,Webhook 约定系统间如何投递事件。先按问题选机制,再处理各自的失败边界。本篇在 Go 1.27.2、Linux/amd64 上实际运行 WebSocket 回显与心跳、GraphQL 字段执行和缓存、Webhook 签名与去重;完整代码与固定依赖放在文末。
先看数据向哪里走
| 机制 | 谁主动发起 | 主要解决什么 | 不自动保证什么 |
|---|---|---|---|
| WebSocket | 客户端先建立连接,之后双方发消息 | 持续双向交互 | 重连恢复、应用确认、离线消息 |
| GraphQL | 客户端提交有类型的查询/操作 | 按字段选择关联数据 | 查询便宜、对象有权访问、自动解决 N+1 |
| Webhook | 事件提供方调用接收方 HTTP 地址 | 系统间异步事件通知 | 只投一次、按顺序、永不丢失 |
正在绘制图示…
查看图源代码
flowchart LR
B[浏览器] <-->|WebSocket 双向消息| W[实时服务]
P[页面客户端] -->|GraphQL 查询| G[查询执行器]
G --> R[多个 resolver 与数据源]
E[外部事件提供方] -->|Webhook POST| I[签名校验与持久化收件箱]
I --> J[异步业务处理]
它们也能组合:GraphQL subscription 可以借助某种 WebSocket 子协议传输;Webhook 处理完后,可以通过 SSE 或 WebSocket 更新用户界面。组合不意味着其中一层会替另一层做重试与授权。
WebSocket:从 HTTP 握手进入消息协议
经典 HTTP/1.1 WebSocket 先通过 Upgrade 握手,成功得到 101,随后连接上交换的是 WebSocket 帧。应用通常使用库的消息 API,不必自己拼帧;一条消息也可以由多个帧组成。客户端到服务端的 masking 是协议要求,不是加密,公网保密性要依靠 wss 的 TLS。RFC 6455 §4–5、§10
本例固定用 Gorilla WebSocket v1.5.3。它要求应用遵守“一条并发读取路径、一条并发写入路径”的约束;不要让多个 goroutine 随意同时 WriteMessage。控制消息有文档列出的并发例外,仍应按库约定使用。固定版本文档
握手前完成鉴权和 Origin 策略
测试的 Upgrader 只接受一个 Origin:
go
up := websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return r.Header.Get("Origin") == "https://app.example"
},
}来自 https://evil.example 的握手实测得到:
text
rejected origin: 403这是 Origin 策略测试,不是用户身份验证。服务端仍需检查用户凭据和目标频道权限,且不能因为请求来自非浏览器就跳过鉴权。浏览器可能携带 Cookie 发起 WebSocket 握手,Origin 检查有助于避免跨站滥用;普通 HTTP CORS 的响应 Header 不能代替 WebSocket 握手策略。测试中没有实现真实登录。
协议升级后,普通 HTTP 中间件不能再像短请求那样随时返回 JSON 错误。日志、连接追踪和关闭逻辑要转到 WebSocket 生命周期。
心跳需要一直有人读
WebSocket 有 Ping、Pong、Close 控制帧。Gorilla 的默认 Ping 处理器会回复 Pong,但控制消息处理发生在读消息的过程中。如果只写不读,即使没有业务消息需要接收,也可能无法及时处理心跳和关闭。Gorilla 控制消息说明
本次服务端发送 Ping,客户端 ReadMessage 处理 Ping 并回复,服务端读循环触发 PongHandler;同时回显 hello:
go
c.SetPongHandler(func(string) error {
pong <- struct{}{} // 测试观察点
return nil
})
err := c.WriteControl(websocket.PingMessage, []byte("ping"), time.Now().Add(time.Second))实际结果:
text
echo: hello; ping/pong processed生产实现通常在收到 Pong 后延长读 deadline,并独立周期发送 Ping。时间间隔要和代理空闲超时、移动网络以及断线发现需求一起确定;过于频繁会浪费资源。浏览器 JavaScript 的标准 WebSocket API 不直接暴露 Ping/Pong,需要应用层心跳时可以另定义消息。WebSocket 标准 Ping/Pong
长连接要同时限制消息、队列和等待时间
测试给服务端设置 SetReadLimit(64),发送 65 字节消息,客户端观察到:
text
oversized message: close 1009这验证了消息大小限制,没有验证所有碎片化或压缩输入。SetReadDeadline、SetWriteDeadline 还负责限制等待。生产每个连接的待发队列也必须有界;慢客户端写不动时,需要决定断开、丢弃可替代的旧状态,还是让生产者等待。无限队列会把网络背压变成内存故障。
| 故障 | 应用需要的机制 |
|---|---|
| 短暂断网 | 退避加抖动重连,防止所有客户端同时冲回 |
| 重连期间漏消息 | 序列号、最后确认位置、保留窗口与补发 |
| 命令发出后连接断开 | 操作 ID、幂等处理、结果查询 |
| 服务准备退出 | 停止新握手,通知关闭,等待或强制关闭已登记连接 |
TCP 保证一条连接上的字节顺序,却不提供跨重连的应用消息去重。Go Server.Shutdown 也不会自动管理已经被劫持的 WebSocket 连接;应用应登记它们,发 Close 帧并在预算内等待。http.Server.Shutdown
GraphQL:客户端选字段,服务端决定如何取得它们
GraphQL Schema 定义类型与字段,客户端只能提交 Schema 允许的选择。以帖子和作者为例:
graphql
{ posts { id author { name } } }执行器先解析、验证,再调用对应字段的 resolver 取值,最后按查询形状组织数据。Resolver 可以查数据库、缓存或远程服务;GraphQL 本身没有规定数据存在哪。固定规范 October 2021:执行
| 操作类型 | 含义 | 边界 |
|---|---|---|
| query | 查询数据 | 不应靠隐藏副作用实现业务写入 |
| mutation | 执行修改 | 顶层 mutation 字段按序执行,不等于自动数据库事务 |
| subscription | 订阅后续结果 | 传输、重连和保留策略还要另定 |
变量用独立参数传递,避免通过字符串拼接构造操作文本。字段的参数、对象 ID 即使通过类型验证,也仍需授权;登录用户不一定能读取查询中任意作者的私密字段。
为什么一次查询可能变成 N+1 次访问
拿 20 个帖子需要一次查询。若每个 author resolver 都单独查作者,就再访问 20 次数据源,共 21 次。GraphQL 把它们装进一次客户端请求,没有自动合并后端读取。
本次内存实验只有两条帖子,共享作者 ID 7。resolver 的核心是:
go
key := p.Source.(map[string]any)["author_id"].(string)
if cache != nil {
if v, ok := cache.values[key]; ok { return v, nil }
}
loads++ // 代表一次访问作者数据源
v := map[string]any{"name": "Gopher"}
if cache != nil { cache.values[key] = v }
return v, nil文末在访问请求内缓存时加锁,并提供完整 Schema。关闭、打开请求内缓存分别运行同一查询,输出中的关键字段是:
text
cache=false author loads=2
cache=true author loads=1两次的 data 都是:
json
{"posts":[{"author":{"name":"Gopher"},"id":"1"},{"author":{"name":"Gopher"},"id":"2"}]}这只是同一作者的请求内去重,并非完整的批量 DataLoader。若 20 个帖子有 20 个不同作者,缓存本身仍要查 20 次。批量加载器把这一批 key 收集起来,一次查询 WHERE id IN (...),再按原 key 顺序返回结果。请求作用域缓存还可以避免把某用户的授权结果复用给另一用户。GraphQL DataLoader 原项目
同时存在 data 和 errors 是正常的协议情况
本次给作者增加可空 private 字段,其 resolver 返回受控错误。执行查询时,两个帖子都碰到该错误,其余字段仍能返回:
text
field errors=2; partial data present=trueGraphQL 客户端不能只判断 HTTP 请求成功,也不能只判断 data != nil。应检查 errors、路径和字段可空性。若报错字段是 Non-Null,null 会向最近可空的父节点传播,严重时整个 data 为 null;类型设计会直接影响部分成功的形状。规范:Errors and Non-Nullability
另一个负例查询不存在的 missing 字段,验证阶段就失败,业务 resolver 不应执行。Schema 校验能防类型和字段错误,却不能估算所有真实执行代价。
查询成本要在执行前后都设边界
深度限制只能阻止过深嵌套,阻止不了一层里大量别名、很大的分页数量或昂贵字段。可组合限制文档大小、字段数、分页上限、复杂度权重和总 deadline;高风险系统还可使用已登记的查询集合。复杂度估计只是一道保护,实际数据源超时与并发量仍需控制。
GraphQL 也不自动提供全局缓存。HTTP 缓存、客户端规范化缓存、resolver 数据缓存是不同层;mutation 后哪些数据失效,仍由业务定义。本次只执行进程内 Schema,没有搭 HTTP 传输层,也未实现查询成本分析器或 subscription 服务。GraphQL 官方安全建议
Webhook:先可靠接住,再异步处理
Webhook 常是一份带事件 ID 的 HTTP POST。发送方可能因超时或 5xx 重试,同一事件可能到多次,也可能在后发生的事件之后才到。具体策略由提供方定义,不存在适用于所有 Webhook 的统一重试标准。例如 Stripe 明确要求处理重复、重试和非顺序投递。Stripe Webhook 文档
正在绘制图示…
查看图源代码
sequenceDiagram
participant P as 事件提供方
participant H as 接收端
participant D as 持久化收件箱
participant W as 后台处理器
P->>H: 原始 Body、签名、事件 ID
H->>H: 限大小,验证签名和重放窗口
H->>D: 原子保存事件,按提供方与事件 ID 去重
D-->>H: 已持久化或已经存在
H-->>P: 2xx 确认接收
W->>D: 读取待处理事件
W->>W: 幂等执行业务,记录结果
关键在于 2xx 的含义。如果表示“已可靠接收”,就应先完成持久化,而不是扔进一个进程内 channel 后马上确认;否则进程崩溃会丢失已确认的事件。若对方要求同步业务结果,则需遵守它的协议,不能擅自把确认含义换掉。
签名要对原始字节验证
本篇自定义教学协议对 timestamp + "." + rawBody 做 HMAC-SHA256。它不是 GitHub 或 Stripe 的完整签名格式;生产必须按具体提供方文档实现,最好使用其官方验证函数。GitHub 签名验证
go
m := hmac.New(sha256.New, secret)
m.Write([]byte(stamp))
m.Write([]byte("."))
m.Write(body)
expected := m.Sum(nil)
if !hmac.Equal(received, expected) {
// 拒绝请求
}不要先反序列化再重新 Marshal 后验证。字段顺序、空白、转义可能变化,语义相同的 JSON 也不一定是同一份签名字节。本次用合法请求的签名,却在 Body 尾部多加一个空格,服务端返回 401;原文输入可以通过。签名比较使用 hmac.Equal,避免自己用提前退出的逐字节比较。Go hmac.Equal
签名验证也不是“允许任何事件做任何事”。验证成功后仍要检查事件类型、账户范围、结构和资源归属,并限制原始 Body 大小。示例只接受 created 类型和非空 ID。
时间窗口与事件去重各管一件事
签名只能证明持有密钥的一方签过这份内容;被捕获的合法内容仍可重放。本例校验时间戳,允许最多 300 秒过去偏差与 30 秒未来偏差,并把时间戳也签进去。这是教学策略,不是通用标准值。测试中 301 秒以前的签名请求被拒绝。
时间窗口内仍可重复投递,所以还需要事件 ID 去重。文末用锁保护“查 seen、更新 applied”两步,20 个 goroutine 同时发同一合法事件,最后应用次数为 1。这个结果只证明进程内并发临界区正确:重启会丢 map,多实例也不共享它。可靠实现需要持久化唯一键、状态和业务写入的事务边界。
若写入外部系统无法与去重记录处于同一事务,简单地“先标记已处理”可能丢业务,“先处理后标记”可能重复副作用。应让下游支持幂等键,或采用可重试任务与明确补偿。Webhook 的至少一次投递和业务的只生效一次,需要分别设计。
乱序不能只靠事件 ID 解决
事件 ID 判断是否重复,版本判断是否过时。若事件携带的是完整状态快照,可以比较同一资源的递增版本:
go
if incomingVersion <= currentVersion { return false }
currentVersion, state = incomingVersion, incomingState
return true本次先应用 v2 的 closed,再收到 v1 的 open,得到:
text
apply v2=true; apply stale v1=false; state=closed这个策略只适用于有可靠版本号的完整快照。若事件是“加 3”“减 2”这样的增量,直接丢弃旧版本可能丢掉必须执行的操作;需要有序日志、缺口检测或回查提供方的当前权威状态。时间戳也不天然是可靠全序,不能默认不同机器的时间精确一致。
接收端最终还要能观察积压、失败重试和死信,允许受控重放。发送端则应有重试预算、指数退避与抖动、投递日志,并保护可配置回调 URL,防止它被用来探测内部网络。这里的运维设计没有在本次单进程测试中模拟。
资料与实测范围
- RFC 6455:握手、消息、控制帧、安全;WHATWG WebSocket:浏览器 API 边界。
- Gorilla WebSocket v1.5.3 文档源码:并发模型、Origin 与控制消息。
- GraphQL October 2021 规范:本篇所用基础查询、验证、执行和部分错误规则;不声称覆盖后来所有新增规范。
- graphql-go v0.8.1:本次实验库,来源通过固定 Go 模块及校验和核对。
- GraphQL DataLoader 与 官方安全建议:请求内缓存、批量加载和成本限制。
- GitHub Webhook 签名验证、Stripe Webhooks:具体提供方的签名、重试、重复与乱序规则。
- Go hmac、Server.Shutdown。
本次 go mod verify、test、race、vet 全通过。测试范围是本地 TCP 上的 WebSocket、进程内 GraphQL 执行、httptest Webhook 接收器。未运行公网回调、真实支付平台、浏览器、持久化收件箱、跨节点消息恢复或 GraphQL subscription。依赖版本用于复现,不据此推断生产环境应采用同一版本。
完整复现代码
使用 Go 1.27.2。下载时显式使用官方 Go 代理与校验服务;已有缓存后可离线运行:
sh
GOPROXY=https://proxy.golang.org GOSUMDB=sum.golang.org go mod download
go mod verify
GOPROXY=off GOSUMDB=off go test -v -count=1 ./...
GOPROXY=off GOSUMDB=off go test -race -count=1 ./...
GOPROXY=off GOSUMDB=off go vet ./...go.mod
go.mod
module example.com/webapi/06_webhook
go 1.27.2
require (
github.com/gorilla/websocket v1.5.3 // indirect
github.com/graphql-go/graphql v0.8.1 // indirect
)go.sum
text
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/graphql-go/graphql v0.8.1 h1:p7/Ou/WpmulocJeEx7wjQy611rtXGQaAcXGqanuMMgc=
github.com/graphql-go/graphql v0.8.1/go.mod h1:nKiHzRM0qopJEwCITUuIsxk9PlVlwIiiI8pnJEhordQ=webhook_test.go
代码块 · 135 行 · 点击收起 / 展开
go
package webhookdemo
import (
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"sync"
"testing"
"time"
)
type receiver struct {
mu sync.Mutex
seen map[string]bool
applied int
now func() time.Time
secret []byte
}
func sign(secret []byte, stamp string, body []byte) string {
m := hmac.New(sha256.New, secret)
m.Write([]byte(stamp))
m.Write([]byte("."))
m.Write(body)
return hex.EncodeToString(m.Sum(nil))
}
func (s *receiver) ServeHTTP(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
w.Header().Set("Allow", "POST")
http.Error(w, "method", 405)
return
}
r.Body = http.MaxBytesReader(w, r.Body, 1024)
defer r.Body.Close()
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, "body rejected", 413)
return
}
stamp := r.Header.Get("X-Timestamp")
sec, err := strconv.ParseInt(stamp, 10, 64)
now := s.now().Unix()
if err != nil || sec < now-300 || sec > now+30 {
http.Error(w, "stale timestamp", 401)
return
}
got, err := hex.DecodeString(r.Header.Get("X-Signature"))
if err != nil {
http.Error(w, "signature", 401)
return
}
want, _ := hex.DecodeString(sign(s.secret, stamp, body))
if !hmac.Equal(got, want) {
http.Error(w, "signature", 401)
return
}
var event struct {
ID string `json:"id"`
Type string `json:"type"`
}
if err = json.Unmarshal(body, &event); err != nil || event.ID == "" || event.Type != "created" {
http.Error(w, "event", 400)
return
}
s.mu.Lock()
if !s.seen[event.ID] {
s.seen[event.ID] = true
s.applied++
}
s.mu.Unlock()
w.WriteHeader(204)
}
func TestSignatureAndDuplicate(t *testing.T) {
now := time.Unix(1900000000, 0)
s := &receiver{seen: map[string]bool{}, now: func() time.Time { return now }, secret: []byte("public-test-secret")}
body := `{"id":"evt-1","type":"created"}`
stamp := strconv.FormatInt(now.Unix(), 10)
call := func(payload, ts, sig string) int {
r := httptest.NewRequest("POST", "/", strings.NewReader(payload))
r.Header.Set("X-Timestamp", ts)
r.Header.Set("X-Signature", sig)
w := httptest.NewRecorder()
s.ServeHTTP(w, r)
return w.Code
}
signature := sign(s.secret, stamp, []byte(body))
if got := call(body+" ", stamp, signature); got != 401 {
t.Fatal(got)
}
old := strconv.FormatInt(now.Unix()-301, 10)
if got := call(body, old, sign(s.secret, old, []byte(body))); got != 401 {
t.Fatal(got)
}
if got := call(strings.Repeat("x", 1025), stamp, signature); got != 413 {
t.Fatal(got)
}
var wg sync.WaitGroup
for i := 0; i < 20; i++ {
wg.Add(1)
go func() {
defer wg.Done()
if got := call(body, stamp, signature); got != 204 {
t.Errorf("%d", got)
}
}()
}
wg.Wait()
if s.applied != 1 {
t.Fatalf("applied=%d", s.applied)
}
t.Log("raw-body tampering, timestamp window, body limit, and concurrent duplicate suppression verified")
}
func TestOutOfOrderSnapshot(t *testing.T) {
// This works for complete versioned snapshots, not for increment/decrement events.
version, state := 0, ""
apply := func(v int, next string) bool {
if v <= version {
return false
}
version, state = v, next
return true
}
newer, older := apply(2, "closed"), apply(1, "open")
if !newer || older || state != "closed" {
t.Fatal(version, state)
}
t.Logf("apply v2=%v; apply stale v1=%v; state=%s", newer, older, state)
}protocols_test.go
代码块 · 158 行 · 点击收起 / 展开
go
package webhookdemo
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
"github.com/graphql-go/graphql"
)
func TestWebSocket(t *testing.T) {
var workers sync.WaitGroup
pong := make(chan struct{}, 4)
up := websocket.Upgrader{CheckOrigin: func(r *http.Request) bool { return r.Header.Get("Origin") == "https://app.example" }}
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
c, err := up.Upgrade(w, r, nil)
if err != nil {
return
}
workers.Add(1)
defer workers.Done()
defer c.Close()
c.SetReadLimit(64)
_ = c.SetReadDeadline(time.Now().Add(3 * time.Second))
c.SetPongHandler(func(string) error { pong <- struct{}{}; return nil })
if err := c.WriteControl(websocket.PingMessage, []byte("ping"), time.Now().Add(time.Second)); err != nil {
return
}
for {
kind, b, err := c.ReadMessage()
if err != nil {
return
}
_ = c.SetWriteDeadline(time.Now().Add(time.Second))
if err := c.WriteMessage(kind, b); err != nil {
return
}
}
}))
defer s.Close()
url := "ws" + strings.TrimPrefix(s.URL, "http")
dialer := websocket.Dialer{HandshakeTimeout: time.Second}
_, resp, err := dialer.Dial(url, http.Header{"Origin": []string{"https://evil.example"}})
if err == nil || resp.StatusCode != 403 {
t.Fatalf("%v %v", resp, err)
}
resp.Body.Close()
t.Log("rejected origin: 403")
c, _, err := dialer.Dial(url, http.Header{"Origin": []string{"https://app.example"}})
if err != nil {
t.Fatal(err)
}
defer c.Close()
_ = c.SetReadDeadline(time.Now().Add(2 * time.Second))
if err := c.WriteMessage(websocket.TextMessage, []byte("hello")); err != nil {
t.Fatal(err)
}
_, body, err := c.ReadMessage()
if err != nil || string(body) != "hello" {
t.Fatalf("%q %v", body, err)
}
select {
case <-pong:
case <-time.After(time.Second):
t.Fatal("pong not read")
}
t.Log("echo: hello; ping/pong processed")
if err := c.WriteMessage(websocket.TextMessage, []byte(strings.Repeat("x", 65))); err != nil {
t.Fatal(err)
}
_, _, err = c.ReadMessage()
if !websocket.IsCloseError(err, websocket.CloseMessageTooBig) {
t.Fatal(err)
}
t.Log("oversized message: close 1009")
c.Close()
workers.Wait()
}
type cacheKey struct{}
type requestCache struct {
mu sync.Mutex
values map[string]any
}
func TestGraphQLSelectionAndErrors(t *testing.T) {
var loads int
user := graphql.NewObject(graphql.ObjectConfig{Name: "User", Fields: graphql.Fields{
"name": &graphql.Field{Type: graphql.String},
"private": &graphql.Field{Type: graphql.String, Resolve: func(graphql.ResolveParams) (any, error) { return nil, errors.New("forbidden") }},
}})
post := graphql.NewObject(graphql.ObjectConfig{Name: "Post", Fields: graphql.Fields{
"id": &graphql.Field{Type: graphql.ID},
"author": &graphql.Field{Type: user, Resolve: func(p graphql.ResolveParams) (any, error) {
key := p.Source.(map[string]any)["author_id"].(string)
cache, _ := p.Context.Value(cacheKey{}).(*requestCache)
if cache != nil {
cache.mu.Lock()
defer cache.mu.Unlock()
if v, ok := cache.values[key]; ok {
return v, nil
}
}
loads++
v := map[string]any{"name": "Gopher"}
if cache != nil {
cache.values[key] = v
}
return v, nil
}},
}})
schema, err := graphql.NewSchema(graphql.SchemaConfig{Query: graphql.NewObject(graphql.ObjectConfig{Name: "Query", Fields: graphql.Fields{
"posts": &graphql.Field{Type: graphql.NewList(post), Resolve: func(graphql.ResolveParams) (any, error) {
return []map[string]any{{"id": "1", "author_id": "7"}, {"id": "2", "author_id": "7"}}, nil
}},
}})})
if err != nil {
t.Fatal(err)
}
query := `{ posts { id author { name } } }`
for _, cached := range []bool{false, true} {
loads = 0
ctx := context.Background()
if cached {
ctx = context.WithValue(ctx, cacheKey{}, &requestCache{values: map[string]any{}})
}
result := graphql.Do(graphql.Params{Schema: schema, RequestString: query, Context: ctx})
if len(result.Errors) != 0 {
t.Fatal(result.Errors)
}
want := 2
if cached {
want = 1
}
if loads != want {
t.Fatal(loads)
}
b, _ := json.Marshal(result.Data)
t.Logf("cache=%v author loads=%d data=%s", cached, loads, b)
}
result := graphql.Do(graphql.Params{Schema: schema, RequestString: `{ posts { id author { name private } } }`, Context: context.Background()})
if len(result.Errors) != 2 || result.Data == nil {
t.Fatal(result)
}
t.Logf("field errors=%d; partial data present=%v", len(result.Errors), result.Data != nil)
result = graphql.Do(graphql.Params{Schema: schema, RequestString: `{ posts { missing } }`, Context: context.Background()})
if len(result.Errors) == 0 {
t.Fatal("invalid query accepted")
}
t.Log("unknown field rejected before execution")
}