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