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/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