Skip to content

Commit

Permalink
encoding/jsonschema: add OpenAPI 3.0 version support
Browse files Browse the repository at this point in the history
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]>
  • Loading branch information
rogpeppe committed Sep 4, 2024
1 parent 46fb300 commit d7852d7
Show file tree
Hide file tree
Showing 7 changed files with 143 additions and 113 deletions.
167 changes: 81 additions & 86 deletions encoding/jsonschema/constraints.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,101 +58,96 @@ func init() {

const numPhases = 5

var constraints = []*constraint{
todo("$anchor", vfrom(VersionDraft2019_09)),
p2d("$comment", constraintComment, vfrom(VersionDraft7)),
p2("$defs", constraintAddDefinitions),
todo("$dynamicAnchor", vfrom(VersionDraft2020_12)),
todo("$dynamicRef", vfrom(VersionDraft2020_12)),
p1d("$id", constraintID, vfrom(VersionDraft6)),
todo("$recursiveAnchor", vbetween(VersionDraft2019_09, VersionDraft2020_12)),
todo("$recursiveRef", vbetween(VersionDraft2019_09, VersionDraft2020_12)),
p2("$ref", constraintRef),
p0("$schema", constraintSchema),
todo("$vocabulary", vfrom(VersionDraft2019_09)),
p2d("additionalItems", constraintAdditionalItems, vto(VersionDraft2019_09)),
p4("additionalProperties", constraintAdditionalProperties),
p3("allOf", constraintAllOf),
p3("anyOf", constraintAnyOf),
p2d("const", constraintConst, vfrom(VersionDraft6)),
p2d("contains", constraintContains, vfrom(VersionDraft6)),
p2d("contentEncoding", constraintContentEncoding, vfrom(VersionDraft7)),
p2d("contentMediaType", constraintContentMediaType, vfrom(VersionDraft7)),
todo("contentSchema", vfrom(VersionDraft2019_09)),
p2("default", constraintDefault),
p2("definitions", constraintAddDefinitions),
p2("dependencies", constraintDependencies),
todo("dependentRequired", vfrom(VersionDraft2019_09)),
todo("dependentSchemas", vfrom(VersionDraft2019_09)),
p2("deprecated", constraintDeprecated),
p2("description", constraintDescription),
todo("else", vfrom(VersionDraft7)),
p2("enum", constraintEnum),
p2d("examples", constraintExamples, vfrom(VersionDraft6)),
p2("exclusiveMaximum", constraintExclusiveMaximum),
p2("exclusiveMinimum", constraintExclusiveMinimum),
todo("format", allVersions),
p1d("id", constraintID, vto(VersionDraft4)),
todo("if", vfrom(VersionDraft7)),
p2("items", constraintItems),
p1d("maxContains", constraintMaxContains, vfrom(VersionDraft2019_09)),
p2("maxItems", constraintMaxItems),
p2("maxLength", constraintMaxLength),
p2("maxProperties", constraintMaxProperties),
p3("maximum", constraintMaximum),
p1d("minContains", constraintMinContains, vfrom(VersionDraft2019_09)),
p2("minItems", constraintMinItems),
p2("minLength", constraintMinLength),
todo("minProperties", allVersions),
p3("minimum", constraintMinimum),
p2("multipleOf", constraintMultipleOf),
p3("not", constraintNot),
p2("nullable", constraintNullable),
p3("oneOf", constraintOneOf),
p2("pattern", constraintPattern),
p3("patternProperties", constraintPatternProperties),
todo("prefixItems", vfrom(VersionDraft2020_12)),
p2("properties", constraintProperties),
p2d("propertyNames", constraintPropertyNames, vfrom(VersionDraft6)),
todo("readOnly", vfrom(VersionDraft7)),
p3("required", constraintRequired),
todo("then", vfrom(VersionDraft7)),
p2("title", constraintTitle),
p2("type", constraintType),
todo("unevaluatedItems", vfrom(VersionDraft2019_09)),
todo("unevaluatedProperties", vfrom(VersionDraft2019_09)),
p2("uniqueItems", constraintUniqueItems),
todo("writeOnly", vfrom(VersionDraft7)),
}

func todo(name string, versions versionSet) *constraint {
return &constraint{key: name, phase: 1, versions: versions, fn: constraintTODO}
}

func p0(name string, f constraintFunc) *constraint {
return &constraint{key: name, phase: 0, versions: allVersions, fn: f}
}
// Note: OpenAPI is excluded from version sets by default, as it does not fit in
// the linear progression of the rest of the JSON Schema versions.

func p1(name string, f constraintFunc) *constraint {
return &constraint{key: name, phase: 1, versions: allVersions, fn: f}
var constraints = []*constraint{
p1("$anchor", constraintTODO, vfrom(VersionDraft2019_09)),
p2("$comment", constraintComment, vfrom(VersionDraft7)),
p2("$defs", constraintAddDefinitions, allVersions),
p1("$dynamicAnchor", constraintTODO, vfrom(VersionDraft2020_12)),
p1("$dynamicRef", constraintTODO, vfrom(VersionDraft2020_12)),
p1("$id", constraintID, vfrom(VersionDraft6)),
p1("$recursiveAnchor", constraintTODO, vbetween(VersionDraft2019_09, VersionDraft2020_12)),
p1("$recursiveRef", constraintTODO, vbetween(VersionDraft2019_09, VersionDraft2020_12)),
p2("$ref", constraintRef, allVersions|openAPI),
p0("$schema", constraintSchema, allVersions),
p1("$vocabulary", constraintTODO, vfrom(VersionDraft2019_09)),
p2("additionalItems", constraintAdditionalItems, vto(VersionDraft2019_09)),
p4("additionalProperties", constraintAdditionalProperties, allVersions|openAPI),
p3("allOf", constraintAllOf, allVersions|openAPI),
p3("anyOf", constraintAnyOf, allVersions|openAPI),
p2("const", constraintConst, vfrom(VersionDraft6)),
p2("contains", constraintContains, vfrom(VersionDraft6)),
p2("contentEncoding", constraintContentEncoding, vfrom(VersionDraft7)),
p2("contentMediaType", constraintContentMediaType, vfrom(VersionDraft7)),
p1("contentSchema", constraintTODO, vfrom(VersionDraft2019_09)),
p2("default", constraintDefault, allVersions|openAPI),
p2("definitions", constraintAddDefinitions, allVersions),
p2("dependencies", constraintDependencies, allVersions),
p1("dependentRequired", constraintTODO, vfrom(VersionDraft2019_09)),
p1("dependentSchemas", constraintTODO, vfrom(VersionDraft2019_09)),
p2("deprecated", constraintDeprecated, vfrom(VersionDraft2019_09)|openAPI),
p2("description", constraintDescription, allVersions|openAPI),
p1("discriminator", constraintTODO, vset(VersionOpenAPI)),
p1("else", constraintTODO, vfrom(VersionDraft7)),
p2("enum", constraintEnum, allVersions|openAPI),
p1("example", constraintTODO, vset(VersionOpenAPI)),
p2("examples", constraintExamples, vfrom(VersionDraft6)),
p2("exclusiveMaximum", constraintExclusiveMaximum, allVersions|openAPI),
p2("exclusiveMinimum", constraintExclusiveMinimum, allVersions|openAPI),
p1("externalDocs", constraintTODO, vset(VersionOpenAPI)),
p1("format", constraintTODO, allVersions|openAPI),
p1("id", constraintID, vto(VersionDraft4)),
p1("if", constraintTODO, vfrom(VersionDraft7)),
p2("items", constraintItems, allVersions|openAPI),
p1("maxContains", constraintMaxContains, vfrom(VersionDraft2019_09)),
p2("maxItems", constraintMaxItems, allVersions|openAPI),
p2("maxLength", constraintMaxLength, allVersions|openAPI),
p2("maxProperties", constraintMaxProperties, allVersions|openAPI),
p3("maximum", constraintMaximum, allVersions|openAPI),
p1("minContains", constraintMinContains, vfrom(VersionDraft2019_09)),
p2("minItems", constraintMinItems, allVersions|openAPI),
p2("minLength", constraintMinLength, allVersions|openAPI),
p1("minProperties", constraintTODO, allVersions|openAPI),
p3("minimum", constraintMinimum, allVersions|openAPI),
p2("multipleOf", constraintMultipleOf, allVersions|openAPI),
p3("not", constraintNot, allVersions|openAPI),
p2("nullable", constraintNullable, vset(VersionOpenAPI)),
p3("oneOf", constraintOneOf, allVersions|openAPI),
p2("pattern", constraintPattern, allVersions|openAPI),
p3("patternProperties", constraintPatternProperties, allVersions),
p1("prefixItems", constraintTODO, vfrom(VersionDraft2020_12)),
p2("properties", constraintProperties, allVersions|openAPI),
p2("propertyNames", constraintPropertyNames, vfrom(VersionDraft6)),
p1("readOnly", constraintTODO, vfrom(VersionDraft7)|openAPI),
p3("required", constraintRequired, allVersions|openAPI),
p1("then", constraintTODO, vfrom(VersionDraft7)),
p2("title", constraintTitle, allVersions|openAPI),
p2("type", constraintType, allVersions|openAPI),
p1("unevaluatedItems", constraintTODO, vfrom(VersionDraft2019_09)),
p1("unevaluatedProperties", constraintTODO, vfrom(VersionDraft2019_09)),
p2("uniqueItems", constraintUniqueItems, allVersions|openAPI),
p1("writeOnly", constraintTODO, vfrom(VersionDraft7)|openAPI),
p1("xml", constraintTODO, vset(VersionOpenAPI)),
}

func p2(name string, f constraintFunc) *constraint {
return &constraint{key: name, phase: 2, versions: allVersions, fn: f}
func p0(name string, f constraintFunc, versions versionSet) *constraint {
return &constraint{key: name, phase: 0, versions: versions, fn: f}
}

func p3(name string, f constraintFunc) *constraint {
return &constraint{key: name, phase: 3, versions: allVersions, fn: f}
func p1(name string, f constraintFunc, versions versionSet) *constraint {
return &constraint{key: name, phase: 1, versions: versions, fn: f}
}

func p4(name string, f constraintFunc) *constraint {
return &constraint{key: name, phase: 4, versions: allVersions, fn: f}
func p2(name string, f constraintFunc, versions versionSet) *constraint {
return &constraint{key: name, phase: 2, versions: versions, fn: f}
}

func p1d(name string, f constraintFunc, versions versionSet) *constraint {
return &constraint{key: name, phase: 1, versions: versions, fn: f}
func p3(name string, f constraintFunc, versions versionSet) *constraint {
return &constraint{key: name, phase: 3, versions: versions, fn: f}
}

func p2d(name string, f constraintFunc, versions versionSet) *constraint {
return &constraint{key: name, phase: 2, versions: versions, fn: f}
func p4(name string, f constraintFunc, versions versionSet) *constraint {
return &constraint{key: name, phase: 4, versions: versions, fn: f}
}
27 changes: 16 additions & 11 deletions encoding/jsonschema/decode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ import (
// The #noverify tag in the txtar header causes verification and
// instance tests to be skipped.
//
// The #openapi tag in the txtar header enables OpenAPI extraction mode.
// The #version: <version> tag selects the default schema version URI to use.
// As a special case, when this is "openapi", OpenAPI extraction
// mode is enabled.
func TestDecode(t *testing.T) {
test := cuetxtar.TxTarTest{
Root: "./testdata/txtar",
Expand All @@ -72,17 +74,20 @@ func TestDecode(t *testing.T) {
t.Skip("skipping because test is broken under the v2 evaluator")
}

if t.HasTag("openapi") {
cfg.Root = "#/components/schemas/"
cfg.Map = func(p token.Pos, a []string) ([]ast.Label, error) {
// Just for testing: does not validate the path.
return []ast.Label{ast.NewIdent("#" + a[len(a)-1])}, nil
}
}
if versStr, ok := t.Value("version"); ok {
vers, err := jsonschema.ParseVersion(versStr)
qt.Assert(t, qt.IsNil(err))
cfg.DefaultVersion = vers
if versStr == "openapi" {
// OpenAPI doesn't have a JSON Schema URI so it gets a special case.
cfg.DefaultVersion = jsonschema.VersionOpenAPI
cfg.Root = "#/components/schemas/"
cfg.Map = func(p token.Pos, a []string) ([]ast.Label, error) {
// Just for testing: does not validate the path.
return []ast.Label{ast.NewIdent("#" + a[len(a)-1])}, nil
}
} else {
vers, err := jsonschema.ParseVersion(versStr)
qt.Assert(t, qt.IsNil(err))
cfg.DefaultVersion = vers
}
}
cfg.Strict = t.HasTag("strict")

Expand Down
4 changes: 2 additions & 2 deletions encoding/jsonschema/testdata/txtar/basic.txtar
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
-- schema.json --
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$schema": "https://json-schema.org/draft/2019-09/schema",

"type": "object",
"title": "Main schema",
Expand Down Expand Up @@ -43,7 +43,7 @@ import "strings"
// Main schema
//
// Specify who you are and all.
@jsonschema(schema="http://json-schema.org/draft-07/schema#")
@jsonschema(schema="https://json-schema.org/draft/2019-09/schema")

// A person is a human being.
person?: {
Expand Down
2 changes: 1 addition & 1 deletion encoding/jsonschema/testdata/txtar/openapi.txtar
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
#openapi
#version: openapi

-- schema.yaml --
components:
Expand Down
13 changes: 10 additions & 3 deletions encoding/jsonschema/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,19 @@ const (
VersionDraft2019_09 // https://json-schema.org/draft/2019-09/schema
VersionDraft2020_12 // https://json-schema.org/draft/2020-12/schema

numVersions // unknown
numJSONSchemaVersions // unknown

// Note: OpenAPI stands alone: it's not in the regular JSON Schema lineage.
VersionOpenAPI // OpenAPI 3.0
)

const openAPI = versionSet(1 << VersionOpenAPI)

type versionSet int

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

// contains reports whether m contains the version v.
func (m versionSet) contains(v Version) bool {
Expand Down Expand Up @@ -69,7 +76,7 @@ func vto(v Version) versionSet {
func ParseVersion(sv string) (Version, error) {
// If this linear search is ever a performance issue, we could
// build a map, but it doesn't seem worthwhile for now.
for i := Version(1); i < numVersions; i++ {
for i := Version(1); i < numJSONSchemaVersions; i++ {
if sv == i.String() {
return i, nil
}
Expand Down
7 changes: 4 additions & 3 deletions encoding/jsonschema/version_string.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 29 additions & 7 deletions encoding/openapi/decode.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
package openapi

import (
"fmt"
"strings"

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

js, err := jsonschema.Extract(data, &jsonschema.Config{
Root: oapiSchemas,
Map: openAPIMapping,
})
v := data.Value()
versionValue := v.LookupPath(cue.MakePath(cue.Str("openapi")))
if versionValue.Err() != nil {
return nil, fmt.Errorf("openapi field is required but not found")
}
version, err := versionValue.String()
if err != nil {
return nil, err
return nil, fmt.Errorf("invalid openapi field (must be string): %v", err)
}
// A simple prefix match is probably OK for now, following
// the same logic used by internal/encoding.isOpenAPI.
// The specification says that the patch version should be disregarded:
// https://swagger.io/specification/v3/
var schemaVersion jsonschema.Version
switch {
case strings.HasPrefix(version, "3.0."):
schemaVersion = jsonschema.VersionOpenAPI
case strings.HasPrefix(version, "3.1."):
schemaVersion = jsonschema.VersionDraft2020_12
default:
return nil, fmt.Errorf("unknown OpenAPI version %q", version)
}

v := data.Value()

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

js, err := jsonschema.Extract(data, &jsonschema.Config{
Root: oapiSchemas,
Map: openAPIMapping,
DefaultVersion: schemaVersion,
})
if err != nil {
return nil, err
}
preamble := js.Preamble()
body := js.Decls[len(preamble):]
for _, d := range preamble {
Expand Down

0 comments on commit d7852d7

Please sign in to comment.