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  }