1// Copyright 2019 CUE Authors
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7// http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15package jsonschema_test
16
17import (
18 "bytes"
19 "fmt"
20 "io/fs"
21 "maps"
22 "net/url"
23 "path"
24 "slices"
25 "strings"
26 "testing"
27
28 "github.com/go-quicktest/qt"
29 "golang.org/x/tools/txtar"
30
31 "cuelang.org/go/cue"
32 "cuelang.org/go/cue/ast"
33 "cuelang.org/go/cue/cuecontext"
34 "cuelang.org/go/cue/errors"
35 "cuelang.org/go/cue/format"
36 "cuelang.org/go/cue/token"
37 "cuelang.org/go/encoding/json"
38 "cuelang.org/go/encoding/jsonschema"
39 "cuelang.org/go/encoding/yaml"
40 "cuelang.org/go/internal/cuetdtest"
41 "cuelang.org/go/internal/cuetxtar"
42 _ "cuelang.org/go/pkg"
43)
44
45// TestDecode reads the testdata/*.txtar files, converts the contained
46// JSON schema to CUE and compares it against the output.
47//
48// Set CUE_UPDATE=1 to update test files with the corresponding output.
49//
50// Each test extracts the JSON Schema from a schema file (either
51// schema.json or schema.yaml) and writes the result to
52// out/decode/extract.
53//
54// If there are any files in the "test" directory in the txtar, each one
55// is extracted and validated against the extracted schema. If the file
56// name starts with "err-" it is expected to fail, otherwise it is
57// expected to succeed.
58//
59// If the first line of a test file starts with a "#" character,
60// it should start with `#schema` followed by a CUE path
61// of the schema to test within the extracted schema.
62//
63// The #noverify tag in the txtar header causes verification and
64// instance tests to be skipped.
65//
66// The #version: <version> tag selects the default schema version URI to use.
67// As a special case, when this is "openapi", OpenAPI extraction
68// mode is enabled.
69func TestDecode(t *testing.T) {
70 t.Parallel()
71 test := cuetxtar.TxTarTest{
72 Root: "./testdata/txtar",
73 Name: "decode",
74 Matrix: cuetdtest.FullMatrix,
75 }
76 test.Run(t, func(t *cuetxtar.Test) {
77 t.Parallel()
78 ctx := t.CueContext()
79
80 fsys, err := txtar.FS(t.Archive)
81 if err != nil {
82 t.Fatal(err)
83 }
84 v, err := readSchema(ctx, fsys)
85 if err != nil {
86 t.Fatal(err)
87 }
88
89 cfg := &jsonschema.Config{}
90
91 if versStr, ok := t.Value("version"); ok {
92 // TODO most schemas have neither an explicit $schema or a #version
93 // tag, so when we update the default version, they could break.
94 // We should probably change most of the tests to use an explicit $schema
95 // field apart from when we're explicitly testing the default version logic.
96 switch versStr {
97 case "openapi":
98 cfg.DefaultVersion = jsonschema.VersionOpenAPI
99 cfg.Map = func(p token.Pos, a []string) ([]ast.Label, error) {
100 // Just for testing: does not validate the path.
101 return []ast.Label{ast.NewIdent("#" + a[len(a)-1])}, nil
102 }
103 cfg.Root = "#/components/schemas/"
104 cfg.StrictKeywords = true // encoding/openapi always uses strict keywords
105 case "k8sAPI":
106 cfg.DefaultVersion = jsonschema.VersionKubernetesAPI
107 cfg.Root = "#/components/schemas/"
108 cfg.StrictKeywords = true
109 case "k8sCRD":
110 if v.IncompleteKind() == cue.ListKind {
111 t.Skip("regular Extract does not cope with extracting CRDs from arrays")
112 }
113 cfg.DefaultVersion = jsonschema.VersionKubernetesCRD
114 // Default to the first version; can be overridden with #root.
115 cfg.Root = "#/spec/versions/0/schema/openAPIV3Schema"
116 cfg.StrictKeywords = true // CRDs always use strict keywords
117 cfg.SingleRoot = true
118 default:
119 vers, err := jsonschema.ParseVersion(versStr)
120 qt.Assert(t, qt.IsNil(err))
121 cfg.DefaultVersion = vers
122 }
123 }
124 if root, ok := t.Value("root"); ok {
125 cfg.Root = root
126 }
127 cfg.Strict = t.HasTag("strict")
128 cfg.StrictFeatures = t.HasTag("strictFeatures")
129 cfg.StrictKeywords = cfg.StrictKeywords || t.HasTag("strictKeywords")
130 cfg.AllowNonExistentRoot = t.HasTag("allowNonExistentRoot")
131 cfg.OpenOnlyWhenExplicit = t.HasTag("openOnlyWhenExplicit")
132 if t.HasTag("singleRoot") {
133 cfg.SingleRoot = true
134 }
135 cfg.PkgName, _ = t.Value("pkgName")
136
137 w := t.Writer("extract")
138 expr, err := jsonschema.Extract(v, cfg)
139 if err != nil {
140 got := "ERROR:\n" + errors.Details(err, nil)
141 w.Write([]byte(got))
142 return
143 }
144 if expr == nil {
145 t.Fatal("no expression was extracted")
146 }
147
148 b, err := format.Node(expr, format.Simplify())
149 if err != nil {
150 t.Fatal(errors.Details(err, nil))
151 }
152 b = append(bytes.TrimSpace(b), '\n')
153 w.Write(b)
154 if t.HasTag("noverify") {
155 return
156 }
157 // Verify that the generated CUE compiles.
158 schemav := ctx.CompileBytes(b, cue.Filename("generated.cue"))
159 if err := schemav.Err(); err != nil {
160 t.Fatal(errors.Details(err, nil), qt.Commentf("generated code: %q", b))
161 }
162 testEntries, err := fs.ReadDir(fsys, "test")
163 if err != nil {
164 return
165 }
166 for _, e := range testEntries {
167 file := path.Join("test", e.Name())
168 var v cue.Value
169 base := ""
170 testData, err := fs.ReadFile(fsys, file)
171 if err != nil {
172 t.Fatal(err)
173 }
174 var schemaPath cue.Path
175 if bytes.HasPrefix(testData, []byte("#")) {
176 directiveBytes, rest, _ := bytes.Cut(testData, []byte("\n"))
177 // Replace the directive with a newline so the line numbers
178 // are correct in any error messages.
179 testData = append([]byte("\n"), rest...)
180 directive := string(directiveBytes)
181 verb, arg, ok := strings.Cut(directive, " ")
182 if verb != "#schema" {
183 t.Fatalf("unknown directive %q in test file %v", directiveBytes, file)
184 }
185 if !ok {
186 t.Fatalf("no schema path argument to #schema directive in %s", file)
187 }
188 schemaPath = cue.ParsePath(arg)
189 qt.Assert(t, qt.IsNil(schemaPath.Err()))
190 }
191
192 switch {
193 case strings.HasSuffix(file, ".json"):
194 expr, err := json.Extract(file, testData)
195 if err != nil {
196 t.Fatal(err)
197 }
198 v = ctx.BuildExpr(expr)
199 base = strings.TrimSuffix(e.Name(), ".json")
200
201 case strings.HasSuffix(file, ".yaml"):
202 file, err := yaml.Extract(file, testData)
203 if err != nil {
204 t.Fatal(err)
205 }
206 v = ctx.BuildFile(file)
207 base = strings.TrimSuffix(e.Name(), ".yaml")
208 default:
209 t.Fatalf("unknown file encoding for test file %v", file)
210 }
211 if err := v.Err(); err != nil {
212 t.Fatalf("error building expression for test %v: %v", file, err)
213 }
214 subSchema := schemav.LookupPath(schemaPath)
215 if !subSchema.Exists() {
216 t.Fatalf("path %q does not exist within schema", schemaPath)
217 }
218 rv := subSchema.Unify(v)
219 if strings.HasPrefix(e.Name(), "err-") {
220 err := rv.Err()
221 if err == nil {
222 t.Fatalf("test %v unexpectedly passes", file)
223 }
224 if t.M.IsDefault() {
225 // The error results of the different evaluators can vary,
226 // so only test the exact results for the default evaluator.
227 t.Writer(path.Join("testerr", base)).Write([]byte(errors.Details(err, nil)))
228 }
229 } else {
230 if err := rv.Err(); err != nil {
231 t.Fatalf("test %v unexpectedly fails: %v", file, errors.Details(err, nil))
232 }
233 }
234 }
235 })
236}
237
238func TestDecodeCRD(t *testing.T) {
239 t.Parallel()
240 test := cuetxtar.TxTarTest{
241 Root: "./testdata/txtar",
242 Name: "decodeCRD",
243 Matrix: cuetdtest.FullMatrix,
244 }
245 test.Run(t, func(t *cuetxtar.Test) {
246 if versStr, ok := t.Value("version"); !ok || versStr != "k8sCRD" {
247 t.Skip("test not relevant to CRDs")
248 }
249 t.Parallel()
250
251 ctx := t.CueContext()
252
253 fsys, err := txtar.FS(t.Archive)
254 if err != nil {
255 t.Fatal(err)
256 }
257 v, err := readSchema(ctx, fsys)
258 if err != nil {
259 t.Fatal(err)
260 }
261 crds, err := jsonschema.ExtractCRDs(v, &jsonschema.CRDConfig{})
262 if err != nil {
263 w := t.Writer("extractCRD/error")
264 fmt.Fprintf(w, "%v\n", err)
265 return
266 }
267 for i, crd := range crds {
268 for _, version := range slices.Sorted(maps.Keys(crd.Versions)) {
269 w := t.Writer(fmt.Sprintf("extractCRD/%d/%s", i, version))
270 schemaPath := crd.VersionToPath[version]
271 // Sanity check that the path does actually resolve and looks plausible.
272 qt.Check(t, qt.Matches(schemaPath.String(), `spec\.versions\[\d+\]\.schema\.openAPIV3Schema`))
273 schemav := crd.Source.LookupPath(schemaPath)
274 qt.Check(t, qt.IsTrue(schemav.Exists()))
275 f := crd.Versions[version]
276 b, err := format.Node(f, format.Simplify())
277 if err != nil {
278 t.Fatal(errors.Details(err, nil))
279 }
280 b = append(bytes.TrimSpace(b), '\n')
281 w.Write(b)
282 // TODO test that schema actually works.
283 }
284 }
285 })
286}
287
288func readSchema(ctx *cue.Context, fsys fs.FS) (cue.Value, error) {
289 jsonData, jsonErr := fs.ReadFile(fsys, "schema.json")
290 yamlData, yamlErr := fs.ReadFile(fsys, "schema.yaml")
291 var v cue.Value
292 switch {
293 case jsonErr == nil && yamlErr == nil:
294 return cue.Value{}, fmt.Errorf("cannot define both schema.json and schema.yaml")
295 case jsonErr == nil:
296 expr, err := json.Extract("schema.json", jsonData)
297 if err != nil {
298 return cue.Value{}, err
299 }
300 v = ctx.BuildExpr(expr)
301 case yamlErr == nil:
302 file, err := yaml.Extract("schema.yaml", yamlData)
303 if err != nil {
304 return cue.Value{}, err
305 }
306 v = ctx.BuildFile(file)
307 default:
308 return cue.Value{}, fmt.Errorf("no schema.yaml or schema.json file found for test")
309 }
310 if err := v.Err(); err != nil {
311 return cue.Value{}, err
312 }
313 return v, nil
314}
315
316func TestMapURL(t *testing.T) {
317 t.Parallel()
318 v := cuecontext.New().CompileString(`
319type: "object"
320properties: x: $ref: "https://something.test/foo#/definitions/blah"
321`)
322 var calls []string
323 expr, err := jsonschema.Extract(v, &jsonschema.Config{
324 MapURL: func(u *url.URL) (string, cue.Path, error) {
325 calls = append(calls, u.String())
326 return "other.test/something:blah", cue.ParsePath("#Foo.bar"), nil
327 },
328 })
329 qt.Assert(t, qt.IsNil(err))
330 b, err := format.Node(expr, format.Simplify())
331 if err != nil {
332 t.Fatal(errors.Details(err, nil))
333 }
334 qt.Assert(t, qt.DeepEquals(calls, []string{"https://something.test/foo"}))
335 qt.Assert(t, qt.Equals(string(b), `
336import "other.test/something:blah"
337
338x?: blah.#Foo.bar.#blah
339...
340`[1:]))
341}
342
343func TestMapURLErrors(t *testing.T) {
344 v := cuecontext.New().CompileString(`
345type: "object"
346properties: {
347 x: $ref: "https://something.test/foo#/definitions/x"
348 y: $ref: "https://something.test/foo#/definitions/y"
349}
350`, cue.Filename("foo.cue"))
351 _, err := jsonschema.Extract(v, &jsonschema.Config{
352 MapURL: func(u *url.URL) (string, cue.Path, error) {
353 return "", cue.Path{}, fmt.Errorf("some error")
354 },
355 })
356 qt.Assert(t, qt.Equals(errors.Details(err, nil), `
357cannot determine CUE location for JSON Schema location id=https://something.test/foo#/definitions/x: some error:
358 foo.cue:4:5
359cannot determine CUE location for JSON Schema location id=https://something.test/foo#/definitions/y: some error:
360 foo.cue:5:5
361`[1:]))
362}
363
364func TestMapRef(t *testing.T) {
365 t.Parallel()
366 v := cuecontext.New().CompileString(`
367type: "object"
368$id: "https://this.test"
369$defs: foo: type: "string"
370properties: x: $ref: "https://something.test/foo#/$defs/blah"
371`)
372 var calls []string
373 expr, err := jsonschema.Extract(v, &jsonschema.Config{
374 MapRef: func(loc jsonschema.SchemaLoc) (string, cue.Path, error) {
375 calls = append(calls, loc.String())
376 switch loc.ID.String() {
377 case "https://this.test#/$defs/foo":
378 return "", cue.ParsePath("#x.#def.#foo"), nil
379 case "https://something.test/foo#/$defs/blah":
380 return "other.test/something:blah", cue.ParsePath("#Foo.bar"), nil
381 case "https://this.test":
382 return "", cue.Path{}, nil
383 }
384 t.Errorf("unexpected ID")
385 return "", cue.Path{}, fmt.Errorf("unexpected ID %q passed to MapRef", loc.ID)
386 },
387 })
388 qt.Assert(t, qt.IsNil(err))
389 b, err := format.Node(expr, format.Simplify())
390 if err != nil {
391 t.Fatal(errors.Details(err, nil))
392 }
393 qt.Assert(t, qt.DeepEquals(calls, []string{
394 "id=https://this.test#/$defs/foo localPath=$defs.foo",
395 "id=https://something.test/foo#/$defs/blah",
396 "id=https://this.test localPath=",
397 "id=https://something.test/foo#/$defs/blah",
398 }))
399 qt.Assert(t, qt.Equals(string(b), `
400import "other.test/something:blah"
401
402@jsonschema(id="https://this.test")
403x?: blah.#Foo.bar
404
405#x: #def: #foo: string
406...
407`[1:]))
408}
409
410func TestMapRefExternalRefForInternalSchema(t *testing.T) {
411 t.Parallel()
412 v := cuecontext.New().CompileString(`
413type: "object"
414$id: "https://this.test"
415$defs: foo: {
416 description: "foo can be a number or a string"
417 type: ["number", "string"]
418}
419$defs: bar: type: "boolean"
420$ref: "#/$defs/foo"
421`)
422 var calls []string
423 defines := make(map[string]string)
424 expr, err := jsonschema.Extract(v, &jsonschema.Config{
425 MapRef: func(loc jsonschema.SchemaLoc) (string, cue.Path, error) {
426 calls = append(calls, loc.String())
427 switch loc.ID.String() {
428 case "https://this.test#/$defs/foo":
429 return "otherpkg.example/foo", cue.ParsePath("#x"), nil
430 case "https://this.test#/$defs/bar":
431 return "otherpkg.example/bar", cue.ParsePath("#x"), nil
432 case "https://this.test":
433 return "", cue.Path{}, nil
434 }
435 t.Errorf("unexpected ID")
436 return "", cue.Path{}, fmt.Errorf("unexpected ID %q passed to MapRef", loc.ID)
437 },
438 DefineSchema: func(importPath string, path cue.Path, e ast.Expr, c *ast.CommentGroup) {
439 if c != nil {
440 ast.AddComment(e, c)
441 }
442 data, err := format.Node(e)
443 if err != nil {
444 t.Errorf("cannot format: %v", err)
445 return
446 }
447 defines[fmt.Sprintf("%s.%v", importPath, path)] = string(data)
448 },
449 })
450 qt.Assert(t, qt.IsNil(err))
451 b, err := format.Node(expr, format.Simplify())
452 if err != nil {
453 t.Fatal(errors.Details(err, nil))
454 }
455 qt.Check(t, qt.DeepEquals(calls, []string{
456 "id=https://this.test#/$defs/foo localPath=$defs.foo",
457 "id=https://this.test#/$defs/bar localPath=$defs.bar",
458 "id=https://this.test localPath=",
459 }))
460 qt.Check(t, qt.Equals(string(b), `
461import "otherpkg.example/foo"
462
463@jsonschema(id="https://this.test")
464foo.#x & {
465 ...
466}
467`[1:]))
468 qt.Check(t, qt.DeepEquals(defines, map[string]string{
469 "otherpkg.example/bar.#x": "bool",
470 "otherpkg.example/foo.#x": "// foo can be a number or a string\nnumber | string",
471 }))
472}