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, rename `todo` to `pTODO` for
consistency and because all-caps TODO is easier to see and more
conventional.

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 <rogpeppe@gmail.com>
Change-Id: I0070f8c02a9b403e2018b84919b886b0bc5f29d8
Dispatch-Trailer: {"type":"trybot","CL":1200578,"patchset":3,"ref":"refs/changes/78/1200578/3","targetBranch":"master"}
  • Loading branch information
rogpeppe authored and cueckoo committed Sep 3, 2024
1 parent 46fb300 commit 640f8d5
Show file tree
Hide file tree
Showing 7 changed files with 144 additions and 109 deletions.
165 changes: 82 additions & 83 deletions encoding/jsonschema/constraints.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,101 +58,100 @@ func init() {

const numPhases = 5

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

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)),
pTODO("$anchor", vfrom(VersionDraft2019_09)),
p2("$comment", constraintComment, vfrom(VersionDraft7)),
p2("$defs", constraintAddDefinitions, allVersions),
pTODO("$dynamicAnchor", vfrom(VersionDraft2020_12)),
pTODO("$dynamicRef", vfrom(VersionDraft2020_12)),
p1("$id", constraintID, vfrom(VersionDraft6)),
pTODO("$recursiveAnchor", vbetween(VersionDraft2019_09, VersionDraft2020_12)),
pTODO("$recursiveRef", vbetween(VersionDraft2019_09, VersionDraft2020_12)),
p2("$ref", constraintRef, allVersions|openAPI),
p0("$schema", constraintSchema, allVersions),
pTODO("$vocabulary", 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)),
pTODO("contentSchema", vfrom(VersionDraft2019_09)),
p2("default", constraintDefault, allVersions|openAPI),
p2("definitions", constraintAddDefinitions, allVersions),
p2("dependencies", constraintDependencies, allVersions),
pTODO("dependentRequired", vfrom(VersionDraft2019_09)),
pTODO("dependentSchemas", vfrom(VersionDraft2019_09)),
p2("deprecated", constraintDeprecated, vfrom(VersionDraft2019_09)|openAPI),
p2("description", constraintDescription, allVersions|openAPI),
pTODO("discriminator", vset(VersionOpenAPI)),
pTODO("else", vfrom(VersionDraft7)),
p2("enum", constraintEnum, allVersions|openAPI),
pTODO("example", vset(VersionOpenAPI)),
p2("examples", constraintExamples, vfrom(VersionDraft6)),
p2("exclusiveMaximum", constraintExclusiveMaximum, allVersions|openAPI),
p2("exclusiveMinimum", constraintExclusiveMinimum, allVersions|openAPI),
pTODO("externalDocs", vset(VersionOpenAPI)),
pTODO("format", allVersions|openAPI),
p1("id", constraintID, vto(VersionDraft4)&^openAPI),
pTODO("if", 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),
pTODO("minProperties", 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),
pTODO("prefixItems", vfrom(VersionDraft2020_12)),
p2("properties", constraintProperties, allVersions|openAPI),
p2("propertyNames", constraintPropertyNames, vfrom(VersionDraft6)),
pTODO("readOnly", vfrom(VersionDraft7)|openAPI),
p3("required", constraintRequired, allVersions|openAPI),
pTODO("then", vfrom(VersionDraft7)),
p2("title", constraintTitle, allVersions|openAPI),
p2("type", constraintType, allVersions|openAPI),
pTODO("unevaluatedItems", vfrom(VersionDraft2019_09)),
pTODO("unevaluatedProperties", vfrom(VersionDraft2019_09)),
p2("uniqueItems", constraintUniqueItems, allVersions|openAPI),
pTODO("writeOnly", vfrom(VersionDraft7)|openAPI),
pTODO("xml", vset(VersionOpenAPI)),
}

func todo(name string, versions versionSet) *constraint {
func pTODO(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}
}

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

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
16 changes: 14 additions & 2 deletions encoding/jsonschema/version.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,22 @@ const (
VersionDraft2019_09 // https://json-schema.org/draft/2019-09/schema
VersionDraft2020_12 // https://json-schema.org/draft/2020-12/schema

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

numVersions // unknown
)

var (
openAPI = vset(VersionOpenAPI)
notOpenAPI = allVersions &^ vset(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<<numVersions-1) &^ (1 << VersionUnknown) &^ (1 << VersionOpenAPI)

// contains reports whether m contains the version v.
func (m versionSet) contains(v Version) bool {
Expand Down Expand Up @@ -70,7 +80,9 @@ 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++ {
if sv == i.String() {
// Note: OpenAPI doesn't have a schema URI in the
// same way as the other schema versions.
if i != VersionOpenAPI && 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.

32 changes: 25 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,24 @@ 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)
}
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 +75,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 640f8d5

Please sign in to comment.