this repo has no description
at master 472 lines 14 kB view raw
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}