encoding/jsonschema: add features to support OpenAPI

- Root: root directory from where to load Schema
- Map: remapping of references

Also:
- Fixed bug in compiler that would reject top-level attributes
- Added internal.NewAttr helper.

Change-Id: I87c79fe49448bddfb873fc9ac805879cfdb10a4b
Reviewed-on: https://cue-review.googlesource.com/c/cue/+/5250
Reviewed-by: Marcel van Lohuizen <mpvl@golang.org>
diff --git a/cue/ast.go b/cue/ast.go
index c7432c3..fa1331c 100644
--- a/cue/ast.go
+++ b/cue/ast.go
@@ -724,6 +724,9 @@
 	case *ast.CommentGroup:
 		// Nothing to do for a free-floating comment group.
 
+	case *ast.Attribute:
+		// Nothing to do for now.
+
 	// nothing to do
 	// case *syntax.EmbedDecl:
 	default:
diff --git a/encoding/jsonschema/constraints.go b/encoding/jsonschema/constraints.go
index 985c904..4b0cd32 100644
--- a/encoding/jsonschema/constraints.go
+++ b/encoding/jsonschema/constraints.go
@@ -16,13 +16,11 @@
 
 import (
 	"math/big"
-	"net/url"
-	"path"
-	"strings"
 
 	"cuelang.org/go/cue"
 	"cuelang.org/go/cue/ast"
 	"cuelang.org/go/cue/token"
+	"cuelang.org/go/internal"
 )
 
 type constraint struct {
@@ -193,48 +191,16 @@
 	p0("$def", addDefinitions),
 	p0("definitions", addDefinitions),
 	p0("$ref", func(n cue.Value, s *state) {
-		if str, ok := s.strValue(n); ok {
-			u, err := url.Parse(str)
-			if err != nil {
-				s.add(s.errf(n, "invalid JSON reference: %s", err))
-				return
-			}
-
-			if u.Host != "" || u.Path != "" {
-				s.add(s.errf(n, "external references (%s) not supported", str))
-				// TODO: handle
-				//    host:
-				//      If the host corresponds to a package known to cue,
-				//      load it from there. It would prefer schema converted to
-				//      CUE, although we could consider loading raw JSON schema
-				//      if present.
-				//      If not present, advise the user to run cue get.
-				//    path:
-				//      Look up on file system or relatively to authority location.
-				return
-			}
-
-			if !path.IsAbs(u.Fragment) {
-				s.add(s.errf(n, "anchors (%s) not supported", u.Fragment))
-				// TODO: support anchors
-				return
-			}
-
-			// NOTE: Go bug?: url.URL has no raw representation of the fragment.
-			// This means that %2F gets translated to `/` before it can be
-			// split. This, in turn, means that field names cannot have a `/`
-			// as name.
-			a := strings.Split(u.Fragment[1:], "/")
-			if a[0] != "definitions" && a[0] != "$def" {
-				s.add(s.errf(n, "reference %q must resolve to definition", u.Fragment))
-				return
-			}
-			s.add(ast.NewSel(ast.NewIdent(rootDefs), a[1:]...))
-
-			// TODO: technically, a references could reference a non-definition.
-			// In that case this will not resolve. We should detect cases that
-			// are not definitions and then resolve those as literal values.
+		str, _ := s.strValue(n)
+		a := s.parseRef(n.Pos(), str)
+		if a != nil {
+			a = s.mapRef(n.Pos(), str, a)
 		}
+		if a == nil {
+			s.add(&ast.BadExpr{From: n.Pos()})
+			return
+		}
+		s.add(ast.NewSel(ast.NewIdent(a[0]), a[1:]...))
 	}),
 
 	// Combinators
@@ -346,6 +312,14 @@
 				// field or doc comment will cause a new section.
 				ast.SetRelPos(f.Comments()[0], token.NewSection)
 			}
+			if state.deprecated {
+				switch expr.(type) {
+				case *ast.StructLit:
+					s.obj.Elts = append(s.obj.Elts, addTag(key, "deprecated", ""))
+				default:
+					f.Attrs = append(f.Attrs, internal.NewAttr("deprecated", ""))
+				}
+			}
 			s.obj.Elts = append(s.obj.Elts, f)
 		})
 	}),
diff --git a/encoding/jsonschema/decode.go b/encoding/jsonschema/decode.go
index 2bac410..09838e6 100644
--- a/encoding/jsonschema/decode.go
+++ b/encoding/jsonschema/decode.go
@@ -52,19 +52,37 @@
 	return ident
 }
 
-func (d *decoder) decode(inst *cue.Instance) *ast.File {
-	root := state{decoder: d}
-	expr, state := root.schemaState(inst.Value())
-
-	var a []ast.Decl
+func (d *decoder) decode(v cue.Value) *ast.File {
+	f := &ast.File{}
 
 	if pkgName := d.cfg.PkgName; pkgName != "" {
 		pkg := &ast.Package{Name: ast.NewIdent(pkgName)}
-		state.doc(pkg)
+		f.Decls = append(f.Decls, pkg)
+	}
 
-		a = append(a, pkg)
-	} else if doc := state.comment(); doc != nil {
-		a = append(a, doc)
+	var a []ast.Decl
+
+	if d.cfg.Root == "" {
+		a = append(a, d.schema([]string{"Schema"}, v)...)
+	} else {
+		ref := d.parseRef(token.NoPos, d.cfg.Root)
+		if ref == nil {
+			return f
+		}
+		i, err := v.Lookup(ref...).Fields()
+		if err != nil {
+			d.errs = errors.Append(d.errs, errors.Promote(err, ""))
+			return nil
+		}
+		for i.Next() {
+			ref := append(ref, i.Label())
+			ref = d.mapRef(i.Value().Pos(), "", ref)
+			if len(ref) == 0 {
+				return nil
+			}
+			decls := d.schema(ref, i.Value())
+			a = append(a, decls...)
+		}
 	}
 
 	var imports []string
@@ -78,9 +96,23 @@
 		for _, p := range imports {
 			x.Specs = append(x.Specs, ast.NewImport(nil, p))
 		}
-		a = append(a, x)
+		f.Decls = append(f.Decls, x)
 	}
 
+	f.Decls = append(f.Decls, a...)
+	f.Decls = append(f.Decls, d.definitions...)
+
+	return f
+}
+
+func (d *decoder) schema(ref []string, v cue.Value) (a []ast.Decl) {
+	root := state{decoder: d}
+
+	inner := len(ref) - 1
+	name := ref[inner]
+
+	expr, state := root.schemaState(v)
+
 	tags := []string{}
 	if state.jsonschema != "" {
 		tags = append(tags, fmt.Sprintf("schema=%q", state.jsonschema))
@@ -89,32 +121,45 @@
 		tags = append(tags, fmt.Sprintf("id=%q", state.id))
 	}
 	if len(tags) > 0 {
-		a = append(a, addTag("Schema", "jsonschema", strings.Join(tags, ",")))
+		a = append(a, addTag(name, "jsonschema", strings.Join(tags, ",")))
 	}
 
 	if state.deprecated {
-		a = append(a, addTag("Schema", "deprecated", ""))
+		a = append(a, addTag(name, "deprecated", ""))
 	}
 
 	f := &ast.Field{
-		Label: ast.NewIdent("Schema"),
+		Label: ast.NewIdent(name),
+		Token: token.ISA,
 		Value: expr,
 	}
 
-	f.Token = token.ISA
 	a = append(a, f)
-	a = append(a, d.definitions...)
+	state.doc(a[0])
 
-	return &ast.File{Decls: a}
+	for i := inner - 1; i >= 0; i-- {
+		a = []ast.Decl{&ast.Field{
+			Label: ast.NewIdent(ref[i]),
+			Token: token.ISA,
+			Value: &ast.StructLit{Elts: a},
+		}}
+		expr = ast.NewStruct(ref[i], token.ISA, expr)
+	}
+
+	return a
 }
 
 func (d *decoder) errf(n cue.Value, format string, args ...interface{}) ast.Expr {
-	d.warnf(n, format, args...)
+	d.warnf(n.Pos(), format, args...)
 	return &ast.BadExpr{From: n.Pos()}
 }
 
-func (d *decoder) warnf(n cue.Value, format string, args ...interface{}) {
-	d.errs = errors.Append(d.errs, errors.Newf(n.Pos(), format, args...))
+func (d *decoder) warnf(p token.Pos, format string, args ...interface{}) {
+	d.addErr(errors.Newf(p, format, args...))
+}
+
+func (d *decoder) addErr(err errors.Error) {
+	d.errs = errors.Append(d.errs, err)
 }
 
 func (d *decoder) number(n cue.Value) ast.Expr {
@@ -306,7 +351,7 @@
 			c := constraintMap[key]
 			if c == nil {
 				if pass == 0 {
-					s.warnf(n, "unsupported constraint %q", key)
+					s.warnf(n.Pos(), "unsupported constraint %q", key)
 				}
 				return
 			}
diff --git a/encoding/jsonschema/decode_test.go b/encoding/jsonschema/decode_test.go
index dddb127..469bbb5 100644
--- a/encoding/jsonschema/decode_test.go
+++ b/encoding/jsonschema/decode_test.go
@@ -27,6 +27,7 @@
 	"cuelang.org/go/cue"
 	"cuelang.org/go/cue/errors"
 	"cuelang.org/go/cue/format"
+	"cuelang.org/go/cue/token"
 	"cuelang.org/go/encoding/json"
 	"cuelang.org/go/encoding/yaml"
 	"github.com/google/go-cmp/cmp"
@@ -53,6 +54,16 @@
 				t.Fatal(err)
 			}
 
+			cfg := &Config{ID: fullpath}
+
+			if bytes.Contains(a.Comment, []byte("openapi")) {
+				cfg.Root = "#/components/schemas/"
+				cfg.Map = func(p token.Pos, a []string) ([]string, error) {
+					// Just for testing: does not validate the path.
+					return []string{a[len(a)-1]}, nil
+				}
+			}
+
 			r := &cue.Runtime{}
 			var in *cue.Instance
 			var out, errout []byte
@@ -75,7 +86,7 @@
 				t.Fatal(err)
 			}
 
-			expr, err := Extract(in, &Config{ID: fullpath})
+			expr, err := Extract(in, cfg)
 			if err != nil && errout == nil {
 				t.Fatal(errors.Details(err, nil))
 			}
diff --git a/encoding/jsonschema/jsonschema.go b/encoding/jsonschema/jsonschema.go
index f685c46..09e4173 100644
--- a/encoding/jsonschema/jsonschema.go
+++ b/encoding/jsonschema/jsonschema.go
@@ -33,22 +33,24 @@
 import (
 	"cuelang.org/go/cue"
 	"cuelang.org/go/cue/ast"
+	"cuelang.org/go/cue/token"
 )
 
 // Extract converts JSON Schema data into an equivalent CUE representation.
 //
 // The generated CUE schema is guaranteed to deem valid any value that is
 // a valid instance of the source JSON schema.
-func Extract(data *cue.Instance, cfg *Config) (*ast.File, error) {
+func Extract(data *cue.Instance, cfg *Config) (f *ast.File, err error) {
 	d := &decoder{
 		cfg:     cfg,
 		imports: map[string]*ast.Ident{},
 	}
-	e := d.decode(data)
+
+	f = d.decode(data.Value())
 	if d.errs != nil {
 		return nil, d.errs
 	}
-	return e, nil
+	return f, nil
 }
 
 // A Config configures a JSON Schema encoding or decoding.
@@ -57,6 +59,23 @@
 
 	ID string // URL of the original source, corresponding to the $id field.
 
+	// JSON reference of location containing schema. The empty string indicates
+	// that there is a single schema at the root.
+	//
+	// Examples:
+	//  "#/"                     top-level fields are schemas.
+	//  "#/components/schemas"   the canonical OpenAPI location.
+	Root string
+
+	// Map maps the locations of schemas and definitions to a new location.
+	// References are updated accordingly.
+	//
+	// The default mapping is
+	//    {}                     {"Schema"}
+	//    {"definitions", foo}   {"Defs", strings.Title(foo)}
+	//    {"$defs", foo}         {"Defs", strings.Title(foo)}
+	Map func(pos token.Pos, path []string) ([]string, error)
+
 	// TODO: configurability to make it compatible with OpenAPI, such as
 	// - locations of definitions: #/components/schemas, for instance.
 	// - selection and definition of formats
diff --git a/encoding/jsonschema/ref.go b/encoding/jsonschema/ref.go
new file mode 100644
index 0000000..6961a2e
--- /dev/null
+++ b/encoding/jsonschema/ref.go
@@ -0,0 +1,99 @@
+// Copyright 2020 CUE Authors
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package jsonschema
+
+import (
+	"net/url"
+	"path"
+	"strings"
+
+	"cuelang.org/go/cue/errors"
+	"cuelang.org/go/cue/token"
+)
+
+func (d *decoder) parseRef(p token.Pos, str string) []string {
+	u, err := url.Parse(str)
+	if err != nil {
+		d.addErr(errors.Newf(p, "invalid JSON reference: %s", err))
+		return nil
+	}
+
+	if u.Host != "" || u.Path != "" {
+		d.addErr(errors.Newf(p, "external references (%s) not supported", str))
+		// TODO: handle
+		//    host:
+		//      If the host corresponds to a package known to cue,
+		//      load it from there. It would prefer schema converted to
+		//      CUE, although we could consider loading raw JSON schema
+		//      if present.
+		//      If not present, advise the user to run cue get.
+		//    path:
+		//      Look up on file system or relatively to authority location.
+		return nil
+	}
+
+	if !path.IsAbs(u.Fragment) {
+		d.addErr(errors.Newf(p, "anchors (%s) not supported", u.Fragment))
+		// TODO: support anchors
+		return nil
+	}
+
+	// NOTE: Go bug?: url.URL has no raw representation of the fragment. This
+	// means that %2F gets translated to `/` before it can be split. This, in
+	// turn, means that field names cannot have a `/` as name.
+
+	s := strings.TrimRight(u.Fragment[1:], "/")
+	return strings.Split(s, "/")
+}
+
+func (d *decoder) mapRef(p token.Pos, str string, ref []string) []string {
+	fn := d.cfg.Map
+	if fn == nil {
+		fn = jsonSchemaRef
+	}
+	a, err := fn(p, ref)
+	if err != nil {
+		if str == "" {
+			str = "#/" + strings.Join(ref, "/")
+		}
+		d.addErr(errors.Newf(p, "invalid reference %q: %v", str, err))
+		return nil
+	}
+	if len(a) == 0 {
+		// TODO: should we allow inserting at root level?
+		if str == "" {
+			str = "#/" + strings.Join(ref, "/")
+		}
+		d.addErr(errors.Newf(p,
+			"invalid empty reference returned by map for %q", str))
+		return nil
+	}
+	return a
+}
+
+func jsonSchemaRef(p token.Pos, a []string) ([]string, error) {
+	// TODO: technically, references could reference a
+	// non-definition. We disallow this case for the standard
+	// JSON Schema interpretation. We could detect cases that
+	// are not definitions and then resolve those as literal
+	// values.
+	if len(a) != 2 || (a[0] != "definitions" && a[0] != "$defs") {
+		return nil, errors.Newf(p,
+			// Don't mention the ability to use $defs, as this definition seems
+			// to already have been withdrawn from the JSON Schema spec.
+			"$ref must be of the form #/definitions/...")
+	}
+	return append([]string{rootDefs}, a[1:]...), nil
+}
diff --git a/encoding/jsonschema/testdata/basic.txtar b/encoding/jsonschema/testdata/basic.txtar
index fe55c38..cdb0080 100644
--- a/encoding/jsonschema/testdata/basic.txtar
+++ b/encoding/jsonschema/testdata/basic.txtar
@@ -35,7 +35,6 @@
 // Main schema
 // 
 // Specify who you are and all.
-
 Schema :: _ @jsonschema(schema="http://json-schema.org/draft-07/schema#")
 Schema :: {
 	// A person is a human being.
@@ -45,7 +44,7 @@
 		// where does this person live?
 		address?: string
 		children?: [...string]
-		"home phone"?: string
+		"home phone"?: string @deprecated()
 		...
 	}
 	...
diff --git a/encoding/jsonschema/testdata/object.txtar b/encoding/jsonschema/testdata/object.txtar
index 7fb15c3..50d49e1 100644
--- a/encoding/jsonschema/testdata/object.txtar
+++ b/encoding/jsonschema/testdata/object.txtar
@@ -59,10 +59,9 @@
 }
 
 -- out.cue --
-// Main schema
-
 import "struct"
 
+// Main schema
 Schema :: {
 	fields?: struct.MinFields(3) & struct.MaxFields(10) & {
 		[=~"^\\P{Lu}"]: _
diff --git a/encoding/jsonschema/testdata/openapi.txtar b/encoding/jsonschema/testdata/openapi.txtar
new file mode 100644
index 0000000..139cd5b
--- /dev/null
+++ b/encoding/jsonschema/testdata/openapi.txtar
@@ -0,0 +1,30 @@
+openapi
+
+-- type.yaml --
+components:
+  schemas:
+    User:
+      description: "A User uses something."
+      type: object
+      properties:
+        id:
+          type: integer
+        name:
+          type: string
+        address:
+          $ref: "#/components/schemas/PhoneNumber"
+    PhoneNumber:
+      description: "The number to dial."
+      type: string
+
+-- out.cue --
+// A User uses something.
+User :: {
+	name?:    string
+	id?:      int
+	address?: PhoneNumber
+	...
+}
+
+// The number to dial.
+PhoneNumber :: string
diff --git a/encoding/jsonschema/testdata/type.txtar b/encoding/jsonschema/testdata/type.txtar
index a1144d1..cd97a78 100644
--- a/encoding/jsonschema/testdata/type.txtar
+++ b/encoding/jsonschema/testdata/type.txtar
@@ -28,7 +28,6 @@
 
 -- out.cue --
 // Main schema
-
 Schema :: {
 	// an integer or string.
 	intString?: string | int | bool | [...] | null
diff --git a/internal/internal.go b/internal/internal.go
index ae44dd5..dd6142a 100644
--- a/internal/internal.go
+++ b/internal/internal.go
@@ -21,6 +21,7 @@
 
 import (
 	"bufio"
+	"fmt"
 	"os"
 	"path/filepath"
 	"strings"
@@ -133,6 +134,17 @@
 	return cg
 }
 
+func NewAttr(name, str string) *ast.Attribute {
+	buf := &strings.Builder{}
+	buf.WriteByte('@')
+	buf.WriteString(name)
+	buf.WriteByte('(')
+	fmt.Fprintf(buf, str)
+	buf.WriteByte(')')
+
+	return &ast.Attribute{Text: buf.String()}
+}
+
 // IsEllipsis reports whether the declaration can be represented as an ellipsis.
 func IsEllipsis(x ast.Decl) bool {
 	// ...