Skip to content

Commit de03fa3

Browse files
authored
feat: Add extensions to GraphQL responses (#5189)
## Relevant issue(s) Resolves #5175 ## Description This PR adds the spec's third top level field, `extensions`, to GraphQL response besides existing `data` and `errors` and a path for the execution layer to attach to it. Nothing fills it yet. It is left out of the response when there is nothing to report, so existing responses do not change. Instead of developing some contrived integration tests specifically for this, with some ways to inject the warnings, I decided to make it only unit test tested. The next follow ups that I'm working on with vector indexes directly use it, and this will be proof on the integration test side that it's working.
1 parent 5109a3a commit de03fa3

15 files changed

Lines changed: 935 additions & 22 deletions

File tree

client/db.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -421,8 +421,63 @@ type GQLResult struct {
421421
//
422422
// It will be nil if any errors were raised during execution.
423423
Data any `json:"data"`
424+
425+
// Extensions holds extra information about the request, such as warnings.
426+
//
427+
// It is nil when there is nothing to report, and is then left out of the response.
428+
Extensions *GQLExtensions `json:"extensions,omitempty"`
429+
}
430+
431+
// GQLExtensions sits next to data and errors in a response. It holds anything we want
432+
// to tell the caller that is neither a result nor an error.
433+
//
434+
// Callers skip anything in here they do not recognise.
435+
type GQLExtensions struct {
436+
// Warnings holds things the caller should know about a request that worked.
437+
Warnings []GQLWarning `json:"warnings,omitempty"`
438+
}
439+
440+
// IsEmpty returns true if there is nothing to send. An empty value is left out of the
441+
// response rather than sent as `{}`.
442+
//
443+
// It turns the value into JSON and looks at the result, so a field added later is
444+
// covered without changing this. A value that cannot be turned into JSON counts as
445+
// empty, so a bad warning is dropped instead of breaking the whole response.
446+
func (e *GQLExtensions) IsEmpty() bool {
447+
if e == nil {
448+
return true
449+
}
450+
451+
data, err := json.Marshal(e)
452+
return err != nil || string(data) == "{}"
424453
}
425454

455+
// GQLWarning describes something that happened during a request that still worked.
456+
type GQLWarning struct {
457+
// Code names the warning. Callers check this. It does not change once released.
458+
Code string `json:"code"`
459+
460+
// Message explains the warning to a person. The wording can change, so do not
461+
// read it in code.
462+
Message string `json:"message"`
463+
464+
// Detail holds values that belong to this warning. Optional.
465+
//
466+
// It is sent to the caller and may be logged, so keep secrets and keys out of it.
467+
// Be careful with counts too: saying how many documents were looked at can tell
468+
// the caller about documents they are not allowed to see.
469+
Detail map[string]any `json:"detail,omitempty"`
470+
}
471+
472+
// Warning codes. Callers match on these, so they do not change once released.
473+
const (
474+
// WarningCodeVectorIndexUnused means a similarity query read the whole collection even though
475+
// the field it scored has a vector index. The results are correct, but the query costs more as
476+
// the collection grows. The `reason` detail says which part of the query shape ruled the index
477+
// out.
478+
WarningCodeVectorIndexUnused = "VECTOR_INDEX_UNUSED"
479+
)
480+
426481
// gqlError represents an error that was encountered during a GQL request.
427482
//
428483
// This is only used for marshalling to keep our responses spec compliant.
@@ -439,6 +494,8 @@ type gqlResult struct {
439494
Errors []gqlError `json:"errors,omitempty"`
440495
// Data contains the result data
441496
Data any `json:"data"`
497+
// Extensions contains the result extensions
498+
Extensions *GQLExtensions `json:"extensions,omitempty"`
442499
}
443500

444501
func (res *GQLResult) UnmarshalJSON(data []byte) error {
@@ -449,6 +506,12 @@ func (res *GQLResult) UnmarshalJSON(data []byte) error {
449506
return err
450507
}
451508
res.Data = out.Data
509+
res.Extensions = out.Extensions
510+
// A peer may send `"extensions":{}`, which decodes to a non nil empty value. Callers
511+
// are told the field is nil when there is nothing to report, so make that true.
512+
if res.Extensions.IsEmpty() {
513+
res.Extensions = nil
514+
}
452515
res.Errors = make([]error, len(out.Errors))
453516
for i, e := range out.Errors {
454517
res.Errors[i] = ReviveError(e.Message)
@@ -458,6 +521,9 @@ func (res *GQLResult) UnmarshalJSON(data []byte) error {
458521

459522
func (res GQLResult) MarshalJSON() ([]byte, error) {
460523
out := gqlResult{Data: res.Data}
524+
if !res.Extensions.IsEmpty() {
525+
out.Extensions = res.Extensions
526+
}
461527
out.Errors = make([]gqlError, len(res.Errors))
462528
for i, e := range res.Errors {
463529
out.Errors[i] = gqlError{Message: e.Error()}

client/db_test.go

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Copyright 2026 Democratized Data Foundation
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the file licenses/BSL.txt.
5+
//
6+
// As of the Change Date specified in that file, in accordance with
7+
// the Business Source License, use of this software will be governed
8+
// by the Apache License, Version 2.0, included in the file
9+
// licenses/APL.txt.
10+
11+
package client
12+
13+
import (
14+
"encoding/json"
15+
"testing"
16+
17+
"github.com/stretchr/testify/require"
18+
)
19+
20+
// GQLResult does not use the default struct marshalling. It copies itself field by
21+
// field through a private mirror, so a field can exist on the type and still be missing
22+
// from the JSON. These tests catch that.
23+
//
24+
// It matters because the Go client never serializes anything. A half applied change
25+
// passes there and fails on every other client.
26+
27+
func TestGQLResultMarshal_WithWarning_RoundTrips(t *testing.T) {
28+
input := GQLResult{
29+
Data: map[string]any{"Users": []any{}},
30+
Extensions: &GQLExtensions{
31+
Warnings: []GQLWarning{
32+
{
33+
Code: "test_warning",
34+
Message: "something worth knowing happened",
35+
Detail: map[string]any{"requested": 10, "returned": 6},
36+
},
37+
},
38+
},
39+
}
40+
41+
data, err := json.Marshal(input)
42+
require.NoError(t, err)
43+
44+
var output GQLResult
45+
err = json.Unmarshal(data, &output)
46+
require.NoError(t, err)
47+
48+
require.NotNil(t, output.Extensions)
49+
require.Len(t, output.Extensions.Warnings, 1)
50+
51+
warning := output.Extensions.Warnings[0]
52+
require.Equal(t, "test_warning", warning.Code)
53+
require.Equal(t, "something worth knowing happened", warning.Message)
54+
55+
// UnmarshalJSON calls dec.UseNumber, so numbers come back as json.Number, not
56+
// float64. Same as everything under `data`.
57+
require.Equal(t, json.Number("10"), warning.Detail["requested"])
58+
require.Equal(t, json.Number("6"), warning.Detail["returned"])
59+
}
60+
61+
func TestGQLResultMarshal_WithMultipleWarnings_PreservesOrder(t *testing.T) {
62+
input := GQLResult{
63+
Extensions: &GQLExtensions{
64+
Warnings: []GQLWarning{
65+
{Code: "first", Message: "one"},
66+
{Code: "second", Message: "two"},
67+
},
68+
},
69+
}
70+
71+
data, err := json.Marshal(input)
72+
require.NoError(t, err)
73+
74+
var output GQLResult
75+
err = json.Unmarshal(data, &output)
76+
require.NoError(t, err)
77+
78+
require.Len(t, output.Extensions.Warnings, 2)
79+
require.Equal(t, "first", output.Extensions.Warnings[0].Code)
80+
require.Equal(t, "second", output.Extensions.Warnings[1].Code)
81+
}
82+
83+
func TestGQLResultMarshal_WithoutExtensions_OmitsField(t *testing.T) {
84+
input := GQLResult{Data: map[string]any{"Users": []any{}}}
85+
86+
data, err := json.Marshal(input)
87+
require.NoError(t, err)
88+
89+
var raw map[string]json.RawMessage
90+
err = json.Unmarshal(data, &raw)
91+
require.NoError(t, err)
92+
93+
require.NotContains(t, raw, "extensions")
94+
}
95+
96+
func TestGQLResultMarshal_WithEmptyExtensions_OmitsField(t *testing.T) {
97+
// An empty value must not be sent as `"extensions":{}`. Otherwise every response
98+
// changes shape as soon as anything allocates an accumulator.
99+
input := GQLResult{
100+
Data: map[string]any{"Users": []any{}},
101+
Extensions: &GQLExtensions{},
102+
}
103+
104+
data, err := json.Marshal(input)
105+
require.NoError(t, err)
106+
107+
var raw map[string]json.RawMessage
108+
err = json.Unmarshal(data, &raw)
109+
require.NoError(t, err)
110+
111+
require.NotContains(t, raw, "extensions")
112+
}
113+
114+
func TestGQLResultUnmarshal_WithUnknownExtensionKey_IsIgnored(t *testing.T) {
115+
// An older client must ignore an entry it does not know about instead of failing
116+
// the whole response.
117+
data := []byte(`{
118+
"data": null,
119+
"extensions": {
120+
"warnings": [{"code": "known", "message": "hi", "unknownField": 1}],
121+
"unknownKey": {"anything": true}
122+
}
123+
}`)
124+
125+
var output GQLResult
126+
err := json.Unmarshal(data, &output)
127+
require.NoError(t, err)
128+
129+
require.NotNil(t, output.Extensions)
130+
require.Len(t, output.Extensions.Warnings, 1)
131+
require.Equal(t, "known", output.Extensions.Warnings[0].Code)
132+
}
133+
134+
func TestGQLResultUnmarshal_WithoutExtensions_LeavesNil(t *testing.T) {
135+
var output GQLResult
136+
err := json.Unmarshal([]byte(`{"data": null}`), &output)
137+
require.NoError(t, err)
138+
139+
require.Nil(t, output.Extensions)
140+
require.True(t, output.Extensions.IsEmpty())
141+
}
142+
143+
// The empty non nil slice is the case a field by field check gets wrong: the slice is
144+
// not the zero value, but it still encodes to nothing.
145+
func TestGQLExtensionsIsEmpty_WithEmptyWarningSlice_IsEmpty(t *testing.T) {
146+
extensions := &GQLExtensions{Warnings: []GQLWarning{}}
147+
148+
require.True(t, extensions.IsEmpty())
149+
}
150+
151+
func TestGQLExtensionsIsEmpty_WithNilReceiver_IsEmpty(t *testing.T) {
152+
var extensions *GQLExtensions
153+
154+
require.True(t, extensions.IsEmpty())
155+
}
156+
157+
func TestGQLExtensionsIsEmpty_WithAWarning_IsNotEmpty(t *testing.T) {
158+
extensions := &GQLExtensions{Warnings: []GQLWarning{{Code: "test_warning"}}}
159+
160+
require.False(t, extensions.IsEmpty())
161+
}
162+
163+
// A peer may send an empty extensions object. Callers are told the field is nil when
164+
// there is nothing to report, so decoding must make that true rather than hand back a
165+
// non nil value with nothing in it.
166+
func TestGQLResultUnmarshal_WithEmptyExtensions_LeavesNil(t *testing.T) {
167+
var output GQLResult
168+
err := json.Unmarshal([]byte(`{"data": null, "extensions": {}}`), &output)
169+
require.NoError(t, err)
170+
171+
require.Nil(t, output.Extensions)
172+
}
173+
174+
func TestGQLResultUnmarshal_WithOnlyUnknownExtensionKeys_LeavesNil(t *testing.T) {
175+
var output GQLResult
176+
err := json.Unmarshal([]byte(`{"data": null, "extensions": {"unknownKey": 1}}`), &output)
177+
require.NoError(t, err)
178+
179+
require.Nil(t, output.Extensions)
180+
}

docs/website/references/http/openapi.json

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2046,6 +2046,29 @@
20462046
"type": "object"
20472047
},
20482048
"type": "array"
2049+
},
2050+
"extensions": {
2051+
"properties": {
2052+
"warnings": {
2053+
"items": {
2054+
"properties": {
2055+
"code": {
2056+
"type": "string"
2057+
},
2058+
"detail": {
2059+
"additionalProperties": true,
2060+
"type": "object"
2061+
},
2062+
"message": {
2063+
"type": "string"
2064+
}
2065+
},
2066+
"type": "object"
2067+
},
2068+
"type": "array"
2069+
}
2070+
},
2071+
"type": "object"
20492072
}
20502073
},
20512074
"type": "object"
@@ -2073,6 +2096,29 @@
20732096
"type": "object"
20742097
},
20752098
"type": "array"
2099+
},
2100+
"extensions": {
2101+
"properties": {
2102+
"warnings": {
2103+
"items": {
2104+
"properties": {
2105+
"code": {
2106+
"type": "string"
2107+
},
2108+
"detail": {
2109+
"additionalProperties": true,
2110+
"type": "object"
2111+
},
2112+
"message": {
2113+
"type": "string"
2114+
}
2115+
},
2116+
"type": "object"
2117+
},
2118+
"type": "array"
2119+
}
2120+
},
2121+
"type": "object"
20762122
}
20772123
},
20782124
"type": "object"
@@ -2118,6 +2164,29 @@
21182164
"type": "object"
21192165
},
21202166
"type": "array"
2167+
},
2168+
"extensions": {
2169+
"properties": {
2170+
"warnings": {
2171+
"items": {
2172+
"properties": {
2173+
"code": {
2174+
"type": "string"
2175+
},
2176+
"detail": {
2177+
"additionalProperties": true,
2178+
"type": "object"
2179+
},
2180+
"message": {
2181+
"type": "string"
2182+
}
2183+
},
2184+
"type": "object"
2185+
},
2186+
"type": "array"
2187+
}
2188+
},
2189+
"type": "object"
21212190
}
21222191
},
21232192
"type": "object"
@@ -2145,6 +2214,29 @@
21452214
"type": "object"
21462215
},
21472216
"type": "array"
2217+
},
2218+
"extensions": {
2219+
"properties": {
2220+
"warnings": {
2221+
"items": {
2222+
"properties": {
2223+
"code": {
2224+
"type": "string"
2225+
},
2226+
"detail": {
2227+
"additionalProperties": true,
2228+
"type": "object"
2229+
},
2230+
"message": {
2231+
"type": "string"
2232+
}
2233+
},
2234+
"type": "object"
2235+
},
2236+
"type": "array"
2237+
}
2238+
},
2239+
"type": "object"
21482240
}
21492241
},
21502242
"type": "object"

0 commit comments

Comments
 (0)