Skip to content

Commit d7852d7

Browse files
committed
encoding/jsonschema: add OpenAPI 3.0 version support
Although OpenAPI 3.0 is its own fork of JSON Schema, with distinct semantics (new and removed keywords, different semantics for other keywords), `encoding/jsonschema` does not currently have any way of choosing OpenAPI-specific behaviour. Fix that by adding an OpenAPI version. As it's not in the linear progression of other JSON Schema versions (OpenAPI moved to using exactly JSON Schema 2020-12 in 3.1), we treat it distinctly, requiring all keywords to opt into it explicitly. This in turn means that almost all keywords require their version set to be specified explicitly, so it seems like there's no longer much benefit to having the vanilla `p0`, `p1` etc constraint functions, so we change to passing the version set for all constraints. While we're about it, remove `todo` and use the regular `p1` function so that all the constraint names line up nicely. Finally we change `encoding/openapi` to choose the correct version based on the value of the `openapi` field. For #3375 Signed-off-by: Roger Peppe <[email protected]> Change-Id: I0070f8c02a9b403e2018b84919b886b0bc5f29d8 Reviewed-on: https://review.gerrithub.io/c/cue-lang/cue/+/1200578 Reviewed-by: Daniel Martí <[email protected]> Unity-Result: CUE porcuepine <[email protected]> TryBot-Result: CUEcueckoo <[email protected]>
1 parent 46fb300 commit d7852d7

File tree

7 files changed

+143
-113
lines changed

7 files changed

+143
-113
lines changed

encoding/jsonschema/constraints.go

+81-86
Original file line numberDiff line numberDiff line change
@@ -58,101 +58,96 @@ func init() {
5858

5959
const numPhases = 5
6060

61-
var constraints = []*constraint{
62-
todo("$anchor", vfrom(VersionDraft2019_09)),
63-
p2d("$comment", constraintComment, vfrom(VersionDraft7)),
64-
p2("$defs", constraintAddDefinitions),
65-
todo("$dynamicAnchor", vfrom(VersionDraft2020_12)),
66-
todo("$dynamicRef", vfrom(VersionDraft2020_12)),
67-
p1d("$id", constraintID, vfrom(VersionDraft6)),
68-
todo("$recursiveAnchor", vbetween(VersionDraft2019_09, VersionDraft2020_12)),
69-
todo("$recursiveRef", vbetween(VersionDraft2019_09, VersionDraft2020_12)),
70-
p2("$ref", constraintRef),
71-
p0("$schema", constraintSchema),
72-
todo("$vocabulary", vfrom(VersionDraft2019_09)),
73-
p2d("additionalItems", constraintAdditionalItems, vto(VersionDraft2019_09)),
74-
p4("additionalProperties", constraintAdditionalProperties),
75-
p3("allOf", constraintAllOf),
76-
p3("anyOf", constraintAnyOf),
77-
p2d("const", constraintConst, vfrom(VersionDraft6)),
78-
p2d("contains", constraintContains, vfrom(VersionDraft6)),
79-
p2d("contentEncoding", constraintContentEncoding, vfrom(VersionDraft7)),
80-
p2d("contentMediaType", constraintContentMediaType, vfrom(VersionDraft7)),
81-
todo("contentSchema", vfrom(VersionDraft2019_09)),
82-
p2("default", constraintDefault),
83-
p2("definitions", constraintAddDefinitions),
84-
p2("dependencies", constraintDependencies),
85-
todo("dependentRequired", vfrom(VersionDraft2019_09)),
86-
todo("dependentSchemas", vfrom(VersionDraft2019_09)),
87-
p2("deprecated", constraintDeprecated),
88-
p2("description", constraintDescription),
89-
todo("else", vfrom(VersionDraft7)),
90-
p2("enum", constraintEnum),
91-
p2d("examples", constraintExamples, vfrom(VersionDraft6)),
92-
p2("exclusiveMaximum", constraintExclusiveMaximum),
93-
p2("exclusiveMinimum", constraintExclusiveMinimum),
94-
todo("format", allVersions),
95-
p1d("id", constraintID, vto(VersionDraft4)),
96-
todo("if", vfrom(VersionDraft7)),
97-
p2("items", constraintItems),
98-
p1d("maxContains", constraintMaxContains, vfrom(VersionDraft2019_09)),
99-
p2("maxItems", constraintMaxItems),
100-
p2("maxLength", constraintMaxLength),
101-
p2("maxProperties", constraintMaxProperties),
102-
p3("maximum", constraintMaximum),
103-
p1d("minContains", constraintMinContains, vfrom(VersionDraft2019_09)),
104-
p2("minItems", constraintMinItems),
105-
p2("minLength", constraintMinLength),
106-
todo("minProperties", allVersions),
107-
p3("minimum", constraintMinimum),
108-
p2("multipleOf", constraintMultipleOf),
109-
p3("not", constraintNot),
110-
p2("nullable", constraintNullable),
111-
p3("oneOf", constraintOneOf),
112-
p2("pattern", constraintPattern),
113-
p3("patternProperties", constraintPatternProperties),
114-
todo("prefixItems", vfrom(VersionDraft2020_12)),
115-
p2("properties", constraintProperties),
116-
p2d("propertyNames", constraintPropertyNames, vfrom(VersionDraft6)),
117-
todo("readOnly", vfrom(VersionDraft7)),
118-
p3("required", constraintRequired),
119-
todo("then", vfrom(VersionDraft7)),
120-
p2("title", constraintTitle),
121-
p2("type", constraintType),
122-
todo("unevaluatedItems", vfrom(VersionDraft2019_09)),
123-
todo("unevaluatedProperties", vfrom(VersionDraft2019_09)),
124-
p2("uniqueItems", constraintUniqueItems),
125-
todo("writeOnly", vfrom(VersionDraft7)),
126-
}
127-
128-
func todo(name string, versions versionSet) *constraint {
129-
return &constraint{key: name, phase: 1, versions: versions, fn: constraintTODO}
130-
}
131-
132-
func p0(name string, f constraintFunc) *constraint {
133-
return &constraint{key: name, phase: 0, versions: allVersions, fn: f}
134-
}
61+
// Note: OpenAPI is excluded from version sets by default, as it does not fit in
62+
// the linear progression of the rest of the JSON Schema versions.
13563

136-
func p1(name string, f constraintFunc) *constraint {
137-
return &constraint{key: name, phase: 1, versions: allVersions, fn: f}
64+
var constraints = []*constraint{
65+
p1("$anchor", constraintTODO, vfrom(VersionDraft2019_09)),
66+
p2("$comment", constraintComment, vfrom(VersionDraft7)),
67+
p2("$defs", constraintAddDefinitions, allVersions),
68+
p1("$dynamicAnchor", constraintTODO, vfrom(VersionDraft2020_12)),
69+
p1("$dynamicRef", constraintTODO, vfrom(VersionDraft2020_12)),
70+
p1("$id", constraintID, vfrom(VersionDraft6)),
71+
p1("$recursiveAnchor", constraintTODO, vbetween(VersionDraft2019_09, VersionDraft2020_12)),
72+
p1("$recursiveRef", constraintTODO, vbetween(VersionDraft2019_09, VersionDraft2020_12)),
73+
p2("$ref", constraintRef, allVersions|openAPI),
74+
p0("$schema", constraintSchema, allVersions),
75+
p1("$vocabulary", constraintTODO, vfrom(VersionDraft2019_09)),
76+
p2("additionalItems", constraintAdditionalItems, vto(VersionDraft2019_09)),
77+
p4("additionalProperties", constraintAdditionalProperties, allVersions|openAPI),
78+
p3("allOf", constraintAllOf, allVersions|openAPI),
79+
p3("anyOf", constraintAnyOf, allVersions|openAPI),
80+
p2("const", constraintConst, vfrom(VersionDraft6)),
81+
p2("contains", constraintContains, vfrom(VersionDraft6)),
82+
p2("contentEncoding", constraintContentEncoding, vfrom(VersionDraft7)),
83+
p2("contentMediaType", constraintContentMediaType, vfrom(VersionDraft7)),
84+
p1("contentSchema", constraintTODO, vfrom(VersionDraft2019_09)),
85+
p2("default", constraintDefault, allVersions|openAPI),
86+
p2("definitions", constraintAddDefinitions, allVersions),
87+
p2("dependencies", constraintDependencies, allVersions),
88+
p1("dependentRequired", constraintTODO, vfrom(VersionDraft2019_09)),
89+
p1("dependentSchemas", constraintTODO, vfrom(VersionDraft2019_09)),
90+
p2("deprecated", constraintDeprecated, vfrom(VersionDraft2019_09)|openAPI),
91+
p2("description", constraintDescription, allVersions|openAPI),
92+
p1("discriminator", constraintTODO, vset(VersionOpenAPI)),
93+
p1("else", constraintTODO, vfrom(VersionDraft7)),
94+
p2("enum", constraintEnum, allVersions|openAPI),
95+
p1("example", constraintTODO, vset(VersionOpenAPI)),
96+
p2("examples", constraintExamples, vfrom(VersionDraft6)),
97+
p2("exclusiveMaximum", constraintExclusiveMaximum, allVersions|openAPI),
98+
p2("exclusiveMinimum", constraintExclusiveMinimum, allVersions|openAPI),
99+
p1("externalDocs", constraintTODO, vset(VersionOpenAPI)),
100+
p1("format", constraintTODO, allVersions|openAPI),
101+
p1("id", constraintID, vto(VersionDraft4)),
102+
p1("if", constraintTODO, vfrom(VersionDraft7)),
103+
p2("items", constraintItems, allVersions|openAPI),
104+
p1("maxContains", constraintMaxContains, vfrom(VersionDraft2019_09)),
105+
p2("maxItems", constraintMaxItems, allVersions|openAPI),
106+
p2("maxLength", constraintMaxLength, allVersions|openAPI),
107+
p2("maxProperties", constraintMaxProperties, allVersions|openAPI),
108+
p3("maximum", constraintMaximum, allVersions|openAPI),
109+
p1("minContains", constraintMinContains, vfrom(VersionDraft2019_09)),
110+
p2("minItems", constraintMinItems, allVersions|openAPI),
111+
p2("minLength", constraintMinLength, allVersions|openAPI),
112+
p1("minProperties", constraintTODO, allVersions|openAPI),
113+
p3("minimum", constraintMinimum, allVersions|openAPI),
114+
p2("multipleOf", constraintMultipleOf, allVersions|openAPI),
115+
p3("not", constraintNot, allVersions|openAPI),
116+
p2("nullable", constraintNullable, vset(VersionOpenAPI)),
117+
p3("oneOf", constraintOneOf, allVersions|openAPI),
118+
p2("pattern", constraintPattern, allVersions|openAPI),
119+
p3("patternProperties", constraintPatternProperties, allVersions),
120+
p1("prefixItems", constraintTODO, vfrom(VersionDraft2020_12)),
121+
p2("properties", constraintProperties, allVersions|openAPI),
122+
p2("propertyNames", constraintPropertyNames, vfrom(VersionDraft6)),
123+
p1("readOnly", constraintTODO, vfrom(VersionDraft7)|openAPI),
124+
p3("required", constraintRequired, allVersions|openAPI),
125+
p1("then", constraintTODO, vfrom(VersionDraft7)),
126+
p2("title", constraintTitle, allVersions|openAPI),
127+
p2("type", constraintType, allVersions|openAPI),
128+
p1("unevaluatedItems", constraintTODO, vfrom(VersionDraft2019_09)),
129+
p1("unevaluatedProperties", constraintTODO, vfrom(VersionDraft2019_09)),
130+
p2("uniqueItems", constraintUniqueItems, allVersions|openAPI),
131+
p1("writeOnly", constraintTODO, vfrom(VersionDraft7)|openAPI),
132+
p1("xml", constraintTODO, vset(VersionOpenAPI)),
138133
}
139134

140-
func p2(name string, f constraintFunc) *constraint {
141-
return &constraint{key: name, phase: 2, versions: allVersions, fn: f}
135+
func p0(name string, f constraintFunc, versions versionSet) *constraint {
136+
return &constraint{key: name, phase: 0, versions: versions, fn: f}
142137
}
143138

144-
func p3(name string, f constraintFunc) *constraint {
145-
return &constraint{key: name, phase: 3, versions: allVersions, fn: f}
139+
func p1(name string, f constraintFunc, versions versionSet) *constraint {
140+
return &constraint{key: name, phase: 1, versions: versions, fn: f}
146141
}
147142

148-
func p4(name string, f constraintFunc) *constraint {
149-
return &constraint{key: name, phase: 4, versions: allVersions, fn: f}
143+
func p2(name string, f constraintFunc, versions versionSet) *constraint {
144+
return &constraint{key: name, phase: 2, versions: versions, fn: f}
150145
}
151146

152-
func p1d(name string, f constraintFunc, versions versionSet) *constraint {
153-
return &constraint{key: name, phase: 1, versions: versions, fn: f}
147+
func p3(name string, f constraintFunc, versions versionSet) *constraint {
148+
return &constraint{key: name, phase: 3, versions: versions, fn: f}
154149
}
155150

156-
func p2d(name string, f constraintFunc, versions versionSet) *constraint {
157-
return &constraint{key: name, phase: 2, versions: versions, fn: f}
151+
func p4(name string, f constraintFunc, versions versionSet) *constraint {
152+
return &constraint{key: name, phase: 4, versions: versions, fn: f}
158153
}

encoding/jsonschema/decode_test.go

+16-11
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,9 @@ import (
5858
// The #noverify tag in the txtar header causes verification and
5959
// instance tests to be skipped.
6060
//
61-
// The #openapi tag in the txtar header enables OpenAPI extraction mode.
61+
// The #version: <version> tag selects the default schema version URI to use.
62+
// As a special case, when this is "openapi", OpenAPI extraction
63+
// mode is enabled.
6264
func TestDecode(t *testing.T) {
6365
test := cuetxtar.TxTarTest{
6466
Root: "./testdata/txtar",
@@ -72,17 +74,20 @@ func TestDecode(t *testing.T) {
7274
t.Skip("skipping because test is broken under the v2 evaluator")
7375
}
7476

75-
if t.HasTag("openapi") {
76-
cfg.Root = "#/components/schemas/"
77-
cfg.Map = func(p token.Pos, a []string) ([]ast.Label, error) {
78-
// Just for testing: does not validate the path.
79-
return []ast.Label{ast.NewIdent("#" + a[len(a)-1])}, nil
80-
}
81-
}
8277
if versStr, ok := t.Value("version"); ok {
83-
vers, err := jsonschema.ParseVersion(versStr)
84-
qt.Assert(t, qt.IsNil(err))
85-
cfg.DefaultVersion = vers
78+
if versStr == "openapi" {
79+
// OpenAPI doesn't have a JSON Schema URI so it gets a special case.
80+
cfg.DefaultVersion = jsonschema.VersionOpenAPI
81+
cfg.Root = "#/components/schemas/"
82+
cfg.Map = func(p token.Pos, a []string) ([]ast.Label, error) {
83+
// Just for testing: does not validate the path.
84+
return []ast.Label{ast.NewIdent("#" + a[len(a)-1])}, nil
85+
}
86+
} else {
87+
vers, err := jsonschema.ParseVersion(versStr)
88+
qt.Assert(t, qt.IsNil(err))
89+
cfg.DefaultVersion = vers
90+
}
8691
}
8792
cfg.Strict = t.HasTag("strict")
8893

encoding/jsonschema/testdata/txtar/basic.txtar

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
-- schema.json --
22
{
3-
"$schema": "http://json-schema.org/draft-07/schema#",
3+
"$schema": "https://json-schema.org/draft/2019-09/schema",
44

55
"type": "object",
66
"title": "Main schema",
@@ -43,7 +43,7 @@ import "strings"
4343
// Main schema
4444
//
4545
// Specify who you are and all.
46-
@jsonschema(schema="http://json-schema.org/draft-07/schema#")
46+
@jsonschema(schema="https://json-schema.org/draft/2019-09/schema")
4747

4848
// A person is a human being.
4949
person?: {

encoding/jsonschema/testdata/txtar/openapi.txtar

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
#openapi
1+
#version: openapi
22

33
-- schema.yaml --
44
components:

encoding/jsonschema/version.go

+10-3
Original file line numberDiff line numberDiff line change
@@ -31,12 +31,19 @@ const (
3131
VersionDraft2019_09 // https://json-schema.org/draft/2019-09/schema
3232
VersionDraft2020_12 // https://json-schema.org/draft/2020-12/schema
3333

34-
numVersions // unknown
34+
numJSONSchemaVersions // unknown
35+
36+
// Note: OpenAPI stands alone: it's not in the regular JSON Schema lineage.
37+
VersionOpenAPI // OpenAPI 3.0
3538
)
3639

40+
const openAPI = versionSet(1 << VersionOpenAPI)
41+
3742
type versionSet int
3843

39-
const allVersions = versionSet(1<<numVersions-1) &^ (1 << VersionUnknown)
44+
// allVersions includes all regular versions of JSON Schema.
45+
// It does not include OpenAPI v3.0
46+
const allVersions = versionSet(1<<numJSONSchemaVersions-1) &^ (1 << VersionUnknown)
4047

4148
// contains reports whether m contains the version v.
4249
func (m versionSet) contains(v Version) bool {
@@ -69,7 +76,7 @@ func vto(v Version) versionSet {
6976
func ParseVersion(sv string) (Version, error) {
7077
// If this linear search is ever a performance issue, we could
7178
// build a map, but it doesn't seem worthwhile for now.
72-
for i := Version(1); i < numVersions; i++ {
79+
for i := Version(1); i < numJSONSchemaVersions; i++ {
7380
if sv == i.String() {
7481
return i, nil
7582
}

encoding/jsonschema/version_string.go

+4-3
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

encoding/openapi/decode.go

+29-7
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
package openapi
1616

1717
import (
18+
"fmt"
1819
"strings"
1920

2021
"cuelang.org/go/cue"
@@ -41,15 +42,28 @@ func Extract(data cue.InstanceOrValue, c *Config) (*ast.File, error) {
4142
}
4243
}
4344

44-
js, err := jsonschema.Extract(data, &jsonschema.Config{
45-
Root: oapiSchemas,
46-
Map: openAPIMapping,
47-
})
45+
v := data.Value()
46+
versionValue := v.LookupPath(cue.MakePath(cue.Str("openapi")))
47+
if versionValue.Err() != nil {
48+
return nil, fmt.Errorf("openapi field is required but not found")
49+
}
50+
version, err := versionValue.String()
4851
if err != nil {
49-
return nil, err
52+
return nil, fmt.Errorf("invalid openapi field (must be string): %v", err)
53+
}
54+
// A simple prefix match is probably OK for now, following
55+
// the same logic used by internal/encoding.isOpenAPI.
56+
// The specification says that the patch version should be disregarded:
57+
// https://swagger.io/specification/v3/
58+
var schemaVersion jsonschema.Version
59+
switch {
60+
case strings.HasPrefix(version, "3.0."):
61+
schemaVersion = jsonschema.VersionOpenAPI
62+
case strings.HasPrefix(version, "3.1."):
63+
schemaVersion = jsonschema.VersionDraft2020_12
64+
default:
65+
return nil, fmt.Errorf("unknown OpenAPI version %q", version)
5066
}
51-
52-
v := data.Value()
5367

5468
doc, _ := v.LookupPath(cue.MakePath(cue.Str("info"), cue.Str("title"))).String() // Required
5569
if s, _ := v.LookupPath(cue.MakePath(cue.Str("info"), cue.Str("description"))).String(); s != "" {
@@ -65,6 +79,14 @@ func Extract(data cue.InstanceOrValue, c *Config) (*ast.File, error) {
6579
add(cg)
6680
}
6781

82+
js, err := jsonschema.Extract(data, &jsonschema.Config{
83+
Root: oapiSchemas,
84+
Map: openAPIMapping,
85+
DefaultVersion: schemaVersion,
86+
})
87+
if err != nil {
88+
return nil, err
89+
}
6890
preamble := js.Preamble()
6991
body := js.Decls[len(preamble):]
7092
for _, d := range preamble {

0 commit comments

Comments
 (0)