From 2c577fdaa7d217d9d3707030b04203ed491b7b29 Mon Sep 17 00:00:00 2001 From: Roger Peppe Date: Tue, 3 Sep 2024 09:50:16 +0100 Subject: [PATCH] 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 Change-Id: I0070f8c02a9b403e2018b84919b886b0bc5f29d8 Dispatch-Trailer: {"type":"trybot","CL":1200578,"patchset":4,"ref":"refs/changes/78/1200578/4","targetBranch":"master"} --- encoding/jsonschema/constraints.go | 167 +++++++++--------- encoding/jsonschema/decode_test.go | 27 +-- .../jsonschema/testdata/txtar/basic.txtar | 4 +- .../jsonschema/testdata/txtar/openapi.txtar | 2 +- encoding/jsonschema/version.go | 13 +- encoding/jsonschema/version_string.go | 7 +- encoding/openapi/decode.go | 32 +++- 7 files changed, 140 insertions(+), 112 deletions(-) diff --git a/encoding/jsonschema/constraints.go b/encoding/jsonschema/constraints.go index 1caf7534c..871beeeb4 100644 --- a/encoding/jsonschema/constraints.go +++ b/encoding/jsonschema/constraints.go @@ -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} } diff --git a/encoding/jsonschema/decode_test.go b/encoding/jsonschema/decode_test.go index e0c641526..282041fdc 100644 --- a/encoding/jsonschema/decode_test.go +++ b/encoding/jsonschema/decode_test.go @@ -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: 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", @@ -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") diff --git a/encoding/jsonschema/testdata/txtar/basic.txtar b/encoding/jsonschema/testdata/txtar/basic.txtar index 0521c5fc6..3569993df 100644 --- a/encoding/jsonschema/testdata/txtar/basic.txtar +++ b/encoding/jsonschema/testdata/txtar/basic.txtar @@ -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", @@ -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?: { diff --git a/encoding/jsonschema/testdata/txtar/openapi.txtar b/encoding/jsonschema/testdata/txtar/openapi.txtar index 3f1287c4a..842397d6c 100644 --- a/encoding/jsonschema/testdata/txtar/openapi.txtar +++ b/encoding/jsonschema/testdata/txtar/openapi.txtar @@ -1,4 +1,4 @@ -#openapi +#version: openapi -- schema.yaml -- components: diff --git a/encoding/jsonschema/version.go b/encoding/jsonschema/version.go index 88656adbc..bb547a122 100644 --- a/encoding/jsonschema/version.go +++ b/encoding/jsonschema/version.go @@ -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 + // Note: OpenAPI stands alone: it's not in the regular JSON Schema lineage. + VersionOpenAPI // OpenAPI 3.0 + numVersions // unknown ) +const openAPI = versionSet(1 << VersionOpenAPI) + type versionSet int -const allVersions = versionSet(1<= Version(len(_Version_index)-1) { diff --git a/encoding/openapi/decode.go b/encoding/openapi/decode.go index d60e82d68..1c6f60bfa 100644 --- a/encoding/openapi/decode.go +++ b/encoding/openapi/decode.go @@ -15,6 +15,7 @@ package openapi import ( + "fmt" "strings" "cuelang.org/go/cue" @@ -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 != "" { @@ -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 {