Skip to content

Commit 7cbcb48

Browse files
authored
feat: add schema validation (#42)
* feat: add schema validation * test: fix e2e test * test: fix e2e spec
1 parent c0873c2 commit 7cbcb48

11 files changed

Lines changed: 270 additions & 24 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ flowchart LR
117117
SchemaReconciler["SuperGraph Composer Pod"]
118118
119119
SuperGraphSchema --> SchemaReconciler
120-
SuperGraph --> SuperGraphSchema
120+
SuperGraphSchema --> SuperGraph
121121
Subgraph1 --> SuperGraphSchema
122122
Subgraph2 --> SuperGraphSchema
123123
Subgraph3 --> SuperGraphSchema

api/v1beta1/subgraph_types.go

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,12 @@ func init() {
3232
// SubGraphSpec
3333
// +k8s:openapi-gen=true
3434
type SubGraphSpec struct {
35-
Endpoint string `json:"endpoint,omitempty"`
36-
Suspend bool `json:"suspend,omitempty"`
37-
Timeout *metav1.Duration `json:"timeout,omitempty"`
38-
Interval *metav1.Duration `json:"interval,omitempty"`
39-
Schema Schema `json:"schema,omitempty"`
35+
Endpoint string `json:"endpoint,omitempty"`
36+
Suspend bool `json:"suspend,omitempty"`
37+
SkipSchemaValidation bool `json:"skipSchemaValidation,omitempty"`
38+
Timeout *metav1.Duration `json:"timeout,omitempty"`
39+
Interval *metav1.Duration `json:"interval,omitempty"`
40+
Schema Schema `json:"schema,omitempty"`
4041
}
4142

4243
// +kubebuilder:validation:ExactlyOneOf=sdl;http

chart/apollo-controller/crds/apollo.infra.doodle.com_subgraphs.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ spec:
6666
- message: exactly one of the fields in [sdl http] must be set
6767
rule: '[has(self.sdl),has(self.http)].filter(x,x==true).size() ==
6868
1'
69+
skipSchemaValidation:
70+
type: boolean
6971
suspend:
7072
type: boolean
7173
timeout:

config/base/crd/bases/apollo.infra.doodle.com_subgraphs.yaml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,8 @@ spec:
6666
- message: exactly one of the fields in [sdl http] must be set
6767
rule: '[has(self.sdl),has(self.http)].filter(x,x==true).size() ==
6868
1'
69+
skipSchemaValidation:
70+
type: boolean
6971
suspend:
7072
type: boolean
7173
timeout:

config/tests/cases/default/subgraph.yaml

Lines changed: 4 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,13 @@ spec:
66
endpoint: http://product-service/graphql
77
schema:
88
sdl: |
9-
extend schema
10-
@link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"])
11-
129
type Query {
1310
products: [Product!]!
1411
product(id: ID!): Product
1512
}
1613
17-
type Product @key(fields: "id") {
18-
id: ID!
14+
type Product {
15+
id: String!
1916
name: String!
2017
price: Float!
2118
}
@@ -28,15 +25,12 @@ spec:
2825
endpoint: http://user-server/graphql
2926
schema:
3027
sdl: |
31-
extend schema
32-
@link(url: "https://specs.apollo.dev/federation/v2.0", import: ["@key", "@tag"])
33-
3428
type Query {
3529
users: [User!]!
3630
user(id: ID!): User
3731
}
3832
39-
type User @key(fields: "id") {
40-
id: ID!
33+
type User {
34+
id: String!
4135
username: String!
4236
}

go.mod

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ require (
1010
github.com/onsi/gomega v1.38.3
1111
github.com/spf13/pflag v1.0.10
1212
github.com/stretchr/testify v1.11.1
13+
github.com/vektah/gqlparser/v2 v2.5.31
1314
gopkg.in/yaml.v3 v3.0.1
1415
k8s.io/api v0.35.0
1516
k8s.io/apimachinery v0.35.0
@@ -22,6 +23,7 @@ require (
2223
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect
2324
github.com/MakeNowJust/heredoc v1.0.0 // indirect
2425
github.com/Masterminds/semver/v3 v3.4.0 // indirect
26+
github.com/agnivade/levenshtein v1.2.1 // indirect
2527
github.com/beorn7/perks v1.0.1 // indirect
2628
github.com/blang/semver/v4 v4.0.0 // indirect
2729
github.com/cespare/xxhash/v2 v2.3.0 // indirect

go.sum

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ
66
github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
77
github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0=
88
github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM=
9+
github.com/agnivade/levenshtein v1.2.1 h1:EHBY3UOn1gwdy/VbFwgo4cxecRznFk7fKWN1KOX7eoM=
10+
github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU=
11+
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ=
12+
github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8=
13+
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q=
14+
github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0/go.mod h1:t2tdKJDJF9BV14lnkjHmOQgcvEKgtqs5a1N3LNdJhGE=
915
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
1016
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
1117
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
@@ -23,6 +29,8 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs
2329
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
2430
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
2531
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
32+
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54 h1:SG7nF6SRlWhcT7cNTs5R6Hk4V2lcmLz2NsG2VnInyNo=
33+
github.com/dgryski/trifles v0.0.0-20230903005119-f50d829f2e54/go.mod h1:if7Fbed8SFyPtHLHbg49SI7NAdJiC5WIA09pe59rfAA=
2634
github.com/emicklei/go-restful/v3 v3.13.0 h1:C4Bl2xDndpU6nJ4bc1jXd+uTmYPVUwkD6bFY/oTyCes=
2735
github.com/emicklei/go-restful/v3 v3.13.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc=
2836
github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls=
@@ -196,6 +204,8 @@ github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
196204
github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
197205
github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
198206
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
207+
github.com/vektah/gqlparser/v2 v2.5.31 h1:YhWGA1mfTjID7qJhd1+Vxhpk5HTgydrGU9IgkWBTJ7k=
208+
github.com/vektah/gqlparser/v2 v2.5.31/go.mod h1:c1I28gSOVNzlfc4WuDlqU7voQnsqI6OG2amkBAFmgts=
199209
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
200210
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
201211
github.com/xlab/treeprint v1.2.0 h1:HzHnuAF1plUN2zGlAFHbSQP2qJ0ZAD3XF5XD7OesXRQ=

internal/controllers/subgraph_controller.go

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ import (
2525
"net/http"
2626

2727
"github.com/go-logr/logr"
28+
"github.com/vektah/gqlparser/v2"
29+
"github.com/vektah/gqlparser/v2/ast"
2830
apierrors "k8s.io/apimachinery/pkg/api/errors"
2931
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
3032
"k8s.io/apimachinery/pkg/runtime"
@@ -90,13 +92,15 @@ func (r *SubGraphReconciler) Reconcile(ctx context.Context, req ctrl.Request) (c
9092
return ctrl.Result{}, nil
9193
}
9294

95+
var cancel context.CancelFunc
96+
reconcileCtx := ctx
97+
9398
if subgraph.Spec.Timeout != nil {
94-
var cancel context.CancelFunc
95-
ctx, cancel = context.WithTimeout(ctx, subgraph.Spec.Timeout.Duration)
99+
reconcileCtx, cancel = context.WithTimeout(ctx, subgraph.Spec.Timeout.Duration)
96100
defer cancel()
97101
}
98102

99-
subgraph, result, err := r.reconcile(ctx, subgraph, logger)
103+
subgraph, result, err := r.reconcile(reconcileCtx, subgraph, logger)
100104
subgraph.Status.ObservedGeneration = subgraph.GetGeneration()
101105

102106
if err != nil {
@@ -157,6 +161,17 @@ func (r *SubGraphReconciler) reconcile(ctx context.Context, subgraph infrav1beta
157161
return subgraph, ctrl.Result{}, errors.New("exactly one schema source is required")
158162
}
159163

164+
if !subgraph.Spec.SkipSchemaValidation {
165+
_, err := gqlparser.LoadSchema(&ast.Source{
166+
Name: "schema.graphql",
167+
Input: schema,
168+
})
169+
170+
if err != nil {
171+
return subgraph, ctrl.Result{}, fmt.Errorf("schema is invalid: %w", err)
172+
}
173+
}
174+
160175
checksumSha := sha256.New()
161176
checksumSha.Write([]byte(schema))
162177
checksum := fmt.Sprintf("%x", checksumSha.Sum(nil))

internal/controllers/subgraph_controller_test.go

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ var _ = Describe("SubGraph controller", func() {
3939
By("creating a new SubGraph")
4040
ctx := context.Background()
4141

42-
gi := &v1beta1.SubGraph{
42+
subgraph := &v1beta1.SubGraph{
4343
ObjectMeta: metav1.ObjectMeta{
4444
Name: subgraphName,
4545
Namespace: "default",
@@ -51,7 +51,7 @@ var _ = Describe("SubGraph controller", func() {
5151
},
5252
},
5353
}
54-
Expect(k8sClient.Create(ctx, gi)).Should(Succeed())
54+
Expect(k8sClient.Create(ctx, subgraph)).Should(Succeed())
5555

5656
By("waiting for the reconciliation")
5757
instanceLookupKey := types.NamespacedName{Name: subgraphName, Namespace: "default"}
@@ -178,4 +178,151 @@ var _ = Describe("SubGraph controller", func() {
178178
Expect(k8sClient.Delete(ctx, subgraph)).Should(Succeed())
179179
})
180180
})
181+
182+
When("reconciling a SubGraph with a schema from an http endpoint runs into a specified timeout", func() {
183+
subgraphName := fmt.Sprintf("subgraph-%s", randStringRunes(5))
184+
var subgraph *v1beta1.SubGraph
185+
186+
It("should not update the status", func() {
187+
By("creating a new SubGraph")
188+
ctx := context.Background()
189+
190+
subgraph = &v1beta1.SubGraph{
191+
ObjectMeta: metav1.ObjectMeta{
192+
Name: subgraphName,
193+
Namespace: "default",
194+
},
195+
Spec: v1beta1.SubGraphSpec{
196+
Timeout: &metav1.Duration{},
197+
Schema: v1beta1.Schema{
198+
HTTP: &v1beta1.SchemaHTTP{
199+
Endpoint: "http://127.0.0.1:29001",
200+
},
201+
},
202+
},
203+
}
204+
Expect(k8sClient.Create(ctx, subgraph)).Should(Succeed())
205+
})
206+
207+
It("should update the subgraph status", func() {
208+
ctx := context.Background()
209+
reconciledInstance := &v1beta1.SubGraph{}
210+
instanceLookupKey := types.NamespacedName{Name: subgraphName, Namespace: "default"}
211+
212+
expectedStatus := &v1beta1.SubGraphStatus{
213+
ObservedGeneration: 1,
214+
Conditions: []metav1.Condition{
215+
{
216+
Type: v1beta1.ConditionReady,
217+
Status: metav1.ConditionFalse,
218+
Reason: "ReconciliationFailed",
219+
Message: "failed to fetch schema from http endpoint: Get \"http://127.0.0.1:29001\": context deadline exceeded",
220+
},
221+
},
222+
}
223+
eventuallyMatchExactConditions(ctx, instanceLookupKey, reconciledInstance, expectedStatus)
224+
})
225+
226+
It("cleans up", func() {
227+
ctx := context.Background()
228+
Expect(k8sClient.Delete(ctx, subgraph)).Should(Succeed())
229+
})
230+
})
231+
232+
When("it reconciles a subgraph with an invalid schema", func() {
233+
subgraphName := fmt.Sprintf("subgraph-%s", randStringRunes(5))
234+
var subgraph *v1beta1.SubGraph
235+
schema := "not a valid graphql schema"
236+
237+
It("creates a new subgraph", func() {
238+
ctx := context.Background()
239+
240+
subgraph = &v1beta1.SubGraph{
241+
ObjectMeta: metav1.ObjectMeta{
242+
Name: subgraphName,
243+
Namespace: "default",
244+
},
245+
Spec: v1beta1.SubGraphSpec{
246+
Schema: v1beta1.Schema{
247+
SDL: &schema,
248+
},
249+
},
250+
}
251+
Expect(k8sClient.Create(ctx, subgraph)).Should(Succeed())
252+
})
253+
254+
It("should update the subgraph status", func() {
255+
ctx := context.Background()
256+
reconciledInstance := &v1beta1.SubGraph{}
257+
instanceLookupKey := types.NamespacedName{Name: subgraphName, Namespace: "default"}
258+
259+
expectedStatus := &v1beta1.SubGraphStatus{
260+
ObservedGeneration: 1,
261+
Conditions: []metav1.Condition{
262+
{
263+
Type: v1beta1.ConditionReady,
264+
Status: metav1.ConditionFalse,
265+
Reason: "ReconciliationFailed",
266+
Message: "schema is invalid: schema.graphql:1:1: Unexpected Name \"not\"",
267+
},
268+
},
269+
}
270+
eventuallyMatchExactConditions(ctx, instanceLookupKey, reconciledInstance, expectedStatus)
271+
Expect(reconciledInstance.Status.Schema).To(Equal(""))
272+
})
273+
274+
It("cleans up", func() {
275+
ctx := context.Background()
276+
Expect(k8sClient.Delete(ctx, subgraph)).Should(Succeed())
277+
})
278+
})
279+
280+
When("it reconciles a subgraph with an invalid schema but schema validation is disabled", func() {
281+
subgraphName := fmt.Sprintf("subgraph-%s", randStringRunes(5))
282+
var subgraph *v1beta1.SubGraph
283+
schema := "not a valid graphql schema"
284+
285+
It("creates a new subgraph", func() {
286+
ctx := context.Background()
287+
288+
subgraph = &v1beta1.SubGraph{
289+
ObjectMeta: metav1.ObjectMeta{
290+
Name: subgraphName,
291+
Namespace: "default",
292+
},
293+
Spec: v1beta1.SubGraphSpec{
294+
SkipSchemaValidation: true,
295+
Schema: v1beta1.Schema{
296+
SDL: &schema,
297+
},
298+
},
299+
}
300+
Expect(k8sClient.Create(ctx, subgraph)).Should(Succeed())
301+
})
302+
303+
It("should update the subgraph status", func() {
304+
ctx := context.Background()
305+
reconciledInstance := &v1beta1.SubGraph{}
306+
instanceLookupKey := types.NamespacedName{Name: subgraphName, Namespace: "default"}
307+
308+
expectedStatus := &v1beta1.SubGraphStatus{
309+
ObservedGeneration: 1,
310+
Conditions: []metav1.Condition{
311+
{
312+
Type: v1beta1.ConditionReady,
313+
Status: metav1.ConditionTrue,
314+
Reason: "ReconciliationSuccessful",
315+
Message: "schema available",
316+
},
317+
},
318+
}
319+
eventuallyMatchExactConditions(ctx, instanceLookupKey, reconciledInstance, expectedStatus)
320+
Expect(reconciledInstance.Status.Schema).To(Equal(schema))
321+
})
322+
323+
It("cleans up", func() {
324+
ctx := context.Background()
325+
Expect(k8sClient.Delete(ctx, subgraph)).Should(Succeed())
326+
})
327+
})
181328
})

internal/controllers/supergraphschema_controller.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -175,13 +175,15 @@ func (r *SuperGraphSchemaReconciler) Reconcile(ctx context.Context, req ctrl.Req
175175
return ctrl.Result{}, nil
176176
}
177177

178+
var cancel context.CancelFunc
179+
reconcileCtx := ctx
180+
178181
if schema.Spec.Timeout != nil {
179-
var cancel context.CancelFunc
180-
ctx, cancel = context.WithTimeout(ctx, schema.Spec.Timeout.Duration)
182+
reconcileCtx, cancel = context.WithTimeout(ctx, schema.Spec.Timeout.Duration)
181183
defer cancel()
182184
}
183185

184-
schema, result, err := r.reconcile(ctx, schema, logger)
186+
schema, result, err := r.reconcile(reconcileCtx, schema, logger)
185187
schema.Status.ObservedGeneration = schema.GetGeneration()
186188

187189
if err != nil {

0 commit comments

Comments
 (0)