Skip to content

Commit 028c6f3

Browse files
committed
encoding/jsonschema: refactor reference handling
This refactors references to use a general system that maps references through a new `MapRef` function that supercedes the existing `MapURL` and `Map` functions. To support this, we use the new `structBuilder` type to build up the final syntax. This fixes a bunch of existing reported and unreported issues, including: - json pointer escaping: JSON pointers were not previously escaped and unescaped correctly. - references into internal structure: `$ref` references can now reference arbitrary internal structure inside the schema being extracted, including nodes that aren't actually schemas. - better doc comments: the outer-level doc comment is now correctly preserved in all circumstances There are inevitably some changes in the form of the generated schemas: - field ordering of definitions is now always lexical - some comments move to new (better) locations - attribute placement also moves to a (better) location - by default, only top level `$defs` members are exported as public definitions. The last issue could be considered a backward incompatible change, but in practice - nested definitions are rare - the nested definitions were not easily accessible anyway in most cases (e.g. when inside a property or other expression) - the new `MapRef` feature can be used to change the location of any schema, including these. As yet, the `MapRef` functionality as provided to the API is not tested other than with `DefaultMapRef`, and the `DefineSchemas` callback is not wired up. This will land in a subsequent CL: in the meantime, what we've got here seems sufficient as an intermediate step. Fixes #3593 Fixes #3548 Updates #2699 Fixes #2287 Fixes #390 Signed-off-by: Roger Peppe <[email protected]> Change-Id: Icfcff6e3d9f1d09f0418ddd493e01beb78045d59 Reviewed-on: https://review.gerrithub.io/c/cue-lang/cue/+/1205706 TryBot-Result: CUEcueckoo <[email protected]> Unity-Result: CUE porcuepine <[email protected]> Reviewed-by: Daniel Martí <[email protected]>
1 parent ccaee22 commit 028c6f3

File tree

101 files changed

+1665
-1996
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

101 files changed

+1665
-1996
lines changed

cmd/cue/cmd/testdata/script/def_comments.txtar

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,9 @@ stdout 'description of foo'
8585
}
8686
}
8787
-- stdout1 --
88+
// This is a title
89+
//
90+
// top description
8891
null | bool | number | string | [...] | {
8992
// a description
9093
a?: {
@@ -95,6 +98,9 @@ null | bool | number | string | [...] | {
9598
...
9699
}
97100
-- stdout1-def --
101+
// This is a title
102+
//
103+
// top description
98104
#top: null | bool | number | string | [...] | {
99105
// a description
100106
a?: {
@@ -126,17 +132,19 @@ null | bool | number | string | [...] | {
126132
// This is a title
127133
//
128134
// top description
135+
// a description
129136
a?: {
130137
// b description
131138
b?: number
132139
...
133140
}
134141
...
135142
-- stdout2-def --
143+
// This is a title
144+
//
145+
// top description
136146
#top: {
137-
// This is a title
138-
//
139-
// top description
147+
// a description
140148
a?: {
141149
// b description
142150
b?: number

cmd/cue/cmd/testdata/script/def_jsonschema.txtar

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,12 +32,13 @@ cmp stderr expect-stderr2
3232
cmp stderr expect-stderr3
3333

3434
-- expect-stdout --
35+
// Person
36+
3537
package schema
3638

3739
import "strings"
3840

3941
#Person: {
40-
// Person
4142
@jsonschema(schema="http://json-schema.org/draft-07/schema#")
4243
@jsonschema(id="https://example.com/person.schema.json")
4344

cmd/cue/cmd/testdata/script/import_auto.txtar

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,16 @@ info: {
2121
version: *"v1beta1" | string
2222
}
2323

24+
#Bar: {
25+
foo!: #Foo
26+
...
27+
}
28+
2429
#Foo: {
2530
a!: int
2631
b!: int & <10 & >=0
2732
...
2833
}
29-
#Bar: {
30-
foo!: #Foo
31-
...
32-
}
3334
-- openapi.yaml --
3435
openapi: 3.0.0
3536
info:

encoding/jsonschema/constraints_combinator.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ func constraintAllOf(key string, n cue.Value, s *state) {
3333
}
3434
a := make([]ast.Expr, 0, len(items))
3535
for _, v := range items {
36-
x, sub := s.schemaState(v, s.allowedTypes, nil)
36+
x, sub := s.schemaState(v, s.allowedTypes)
3737
s.allowedTypes &= sub.allowedTypes
3838
if sub.hasConstraints {
3939
// This might seem a little odd, since the actual
@@ -79,7 +79,7 @@ func constraintAnyOf(key string, n cue.Value, s *state) {
7979
}
8080
a := make([]ast.Expr, 0, len(items))
8181
for _, v := range items {
82-
x, sub := s.schemaState(v, s.allowedTypes, nil)
82+
x, sub := s.schemaState(v, s.allowedTypes)
8383
if sub.allowedTypes == 0 {
8484
// Nothing is allowed; omit.
8585
continue
@@ -123,7 +123,7 @@ func constraintOneOf(key string, n cue.Value, s *state) {
123123
}
124124
a := make([]ast.Expr, 0, len(items))
125125
for _, v := range items {
126-
x, sub := s.schemaState(v, s.allowedTypes, nil)
126+
x, sub := s.schemaState(v, s.allowedTypes)
127127
if sub.allowedTypes == 0 {
128128
// Nothing is allowed; omit
129129
continue
@@ -198,14 +198,14 @@ func constraintIfThenElse(s *state) {
198198
return
199199
}
200200
var ifExpr, thenExpr, elseExpr ast.Expr
201-
ifExpr, ifSub := s.schemaState(s.ifConstraint, s.allowedTypes, nil)
201+
ifExpr, ifSub := s.schemaState(s.ifConstraint, s.allowedTypes)
202202
if hasThen {
203203
// The allowed types of the "then" constraint are constrained both
204204
// by the current constraints and the "if" constraint.
205-
thenExpr, _ = s.schemaState(s.thenConstraint, s.allowedTypes&ifSub.allowedTypes, nil)
205+
thenExpr, _ = s.schemaState(s.thenConstraint, s.allowedTypes&ifSub.allowedTypes)
206206
}
207207
if hasElse {
208-
elseExpr, _ = s.schemaState(s.elseConstraint, s.allowedTypes, nil)
208+
elseExpr, _ = s.schemaState(s.elseConstraint, s.allowedTypes)
209209
}
210210
if thenExpr == nil {
211211
thenExpr = top()

encoding/jsonschema/constraints_generic.go

Lines changed: 71 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -15,51 +15,28 @@
1515
package jsonschema
1616

1717
import (
18+
"errors"
19+
"fmt"
20+
"net/url"
21+
"strings"
22+
1823
"cuelang.org/go/cue"
1924
"cuelang.org/go/cue/ast"
20-
"cuelang.org/go/cue/errors"
2125
"cuelang.org/go/cue/token"
2226
)
2327

2428
// Generic constraints
2529

2630
func constraintAddDefinitions(key string, n cue.Value, s *state) {
2731
if n.Kind() != cue.StructKind {
28-
s.errf(n, `"definitions" expected an object, found %s`, n.Kind())
32+
s.errf(n, `%q expected an object, found %s`, key, n.Kind())
2933
}
3034

3135
s.processMap(n, func(key string, n cue.Value) {
32-
name := key
33-
34-
var f *ast.Field
35-
36-
ident := "#" + name
37-
if ast.IsValidIdent(ident) {
38-
expr, sub := s.schemaState(n, allTypes, []label{{ident, true}})
39-
f = &ast.Field{
40-
Label: ast.NewIdent(ident),
41-
Value: expr,
42-
}
43-
sub.doc(f)
44-
} else {
45-
expr, sub := s.schemaState(n, allTypes, []label{{"#", true}, {name: name}})
46-
inner := ast.NewStruct(&ast.Field{
47-
Label: ast.NewString(name),
48-
Value: expr,
49-
})
50-
// Ensure that we get `#: foo: ...` not `#: {foo: ...}`
51-
inner.Lbrace = token.NoPos
52-
ident = "#"
53-
f = &ast.Field{
54-
Label: ast.NewIdent("#"),
55-
Value: inner,
56-
}
57-
sub.doc(f)
58-
}
59-
60-
ast.SetRelPos(f, token.NewSection)
61-
s.definitions = append(s.definitions, f)
62-
s.setField(label{name: ident, isDef: true}, f)
36+
// Ensure that we are going to make a definition
37+
// for this node.
38+
s.ensureDefinition(n)
39+
s.schema(n)
6340
})
6441
}
6542

@@ -122,26 +99,79 @@ func constraintExamples(key string, n cue.Value, s *state) {
12299
}
123100

124101
func constraintNullable(key string, n cue.Value, s *state) {
125-
// TODO: only allow for OpenAPI.
126102
null := ast.NewNull()
127103
setPos(null, n)
128104
s.nullable = null
129105
}
130106

131107
func constraintRef(key string, n cue.Value, s *state) {
132108
u := s.resolveURI(n)
133-
134-
fragmentParts, err := splitFragment(u)
109+
if u == nil {
110+
return
111+
}
112+
schemaRoot := s.schemaRoot()
113+
if u.Fragment == "" && schemaRoot.isRoot && sameSchemaRoot(u, schemaRoot.id) {
114+
// It's a reference to the root of the schema being
115+
// generated. This never maps to something different.
116+
s.all.add(n, s.refExpr(n, "", cue.Path{}))
117+
return
118+
}
119+
importPath, path, err := cueLocationForRef(s, n, u, schemaRoot)
135120
if err != nil {
136-
s.addErr(errors.Newf(n.Pos(), "%v", err))
121+
s.errf(n, "%v", err)
137122
return
138123
}
139-
expr := s.makeCUERef(n, u, fragmentParts)
140-
if expr == nil {
141-
expr = &ast.BadExpr{From: n.Pos()}
124+
if e := s.refExpr(n, importPath, path); e != nil {
125+
s.all.add(n, e)
142126
}
127+
}
143128

144-
s.all.add(n, expr)
129+
func cueLocationForRef(s *state, n cue.Value, u *url.URL, schemaRoot *state) (importPath string, path cue.Path, err error) {
130+
if ds, ok := s.defs[u.String()]; ok {
131+
// We already know about the schema, so use the information that's stored for it.
132+
return ds.importPath, ds.path, nil
133+
}
134+
loc := SchemaLoc{
135+
ID: u,
136+
}
137+
var base cue.Value
138+
isAnchor := u.Fragment != "" && !strings.HasPrefix(u.Fragment, "/")
139+
if !isAnchor {
140+
// It's a JSON pointer reference.
141+
if sameSchemaRoot(u, s.rootID) {
142+
base = s.root
143+
} else if sameSchemaRoot(u, schemaRoot.id) {
144+
// it's within the current schema.
145+
base = schemaRoot.pos
146+
}
147+
if base.Exists() {
148+
target, err := lookupJSONPointer(schemaRoot.pos, u.Fragment)
149+
if err != nil {
150+
if errors.Is(err, errRefNotFound) {
151+
return "", cue.Path{}, fmt.Errorf("reference to non-existent schema")
152+
}
153+
return "", cue.Path{}, fmt.Errorf("invalid JSON Pointer: %v", err)
154+
}
155+
if ds := s.defForValue.get(target); ds != nil {
156+
// There's a definition in place for the value, which gives
157+
// us our answer.
158+
return ds.importPath, ds.path, nil
159+
}
160+
s.ensureDefinition(target)
161+
loc.IsLocal = true
162+
loc.Path = relPath(target, s.root)
163+
}
164+
}
165+
importPath, path, err = s.cfg.MapRef(loc)
166+
if err != nil {
167+
return "", cue.Path{}, fmt.Errorf("cannot determine CUE location for JSON Schema location %v: %v", loc, err)
168+
}
169+
// TODO we'd quite like to avoid invoking MapRef many times
170+
// for the same reference, but in general we don't necessily know
171+
// the canonical URI of the schema until we've done at least one pass.
172+
// There are potentially ways to do it, but leave it for now in favor
173+
// of simplicity.
174+
return importPath, path, nil
145175
}
146176

147177
func constraintTitle(key string, n cue.Value, s *state) {

encoding/jsonschema/constraints_meta.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,9 @@ func constraintID(key string, n cue.Value, s *state) {
2424
// URL: https://domain.com/schemas/foo.json
2525
// anchors: #identifier
2626
//
27-
// TODO: mark identifiers.
27+
// TODO: mark anchors
2828

29-
// Resolution must be relative to parent $id
29+
// Resolution is relative to parent $id
3030
// https://tools.ietf.org/html/draft-handrews-json-schema-02#section-8.2.2
3131
u := s.resolveURI(n)
3232
if u == nil {

encoding/jsonschema/constraints_object.go

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -118,9 +118,11 @@ func constraintProperties(key string, n cue.Value, s *state) {
118118
s.processMap(n, func(key string, n cue.Value) {
119119
// property?: value
120120
name := ast.NewString(key)
121-
expr, state := s.schemaState(n, allTypes, []label{{name: key}})
121+
expr, state := s.schemaState(n, allTypes)
122122
f := &ast.Field{Label: name, Value: expr}
123-
state.doc(f)
123+
if doc := state.comment(); doc != nil {
124+
ast.SetComments(f, []*ast.CommentGroup{doc})
125+
}
124126
f.Optional = token.Blank.Pos()
125127
if len(obj.Elts) > 0 && len(f.Comments()) > 0 {
126128
// TODO: change formatter such that either a NewSection on the
@@ -136,13 +138,12 @@ func constraintProperties(key string, n cue.Value, s *state) {
136138
}
137139
}
138140
obj.Elts = append(obj.Elts, f)
139-
s.setField(label{name: key}, f)
140141
})
141142
}
142143

143144
func constraintPropertyNames(key string, n cue.Value, s *state) {
144145
// [=~pattern]: _
145-
if names, _ := s.schemaState(n, cue.StringKind, nil); !isTop(names) {
146+
if names, _ := s.schemaState(n, cue.StringKind); !isTop(names) {
146147
x := ast.NewStruct(ast.NewList(names), top())
147148
s.add(n, objectType, x)
148149
}
@@ -154,8 +155,6 @@ func constraintRequired(key string, n cue.Value, s *state) {
154155
return
155156
}
156157

157-
// TODO: detect that properties is defined somewhere.
158-
// s.errf(n, `"required" without a "properties" field`)
159158
obj := s.object(n)
160159

161160
// Create field map

0 commit comments

Comments
 (0)