From 640f8d54b6bc62dbf49869ec3948d664cdbd93a0 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, 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 Change-Id: I0070f8c02a9b403e2018b84919b886b0bc5f29d8 Dispatch-Trailer: {"type":"trybot","CL":1200578,"patchset":3,"ref":"refs/changes/78/1200578/3","targetBranch":"master"} --- encoding/jsonschema/constraints.go | 165 +++++++++--------- encoding/jsonschema/decode_test.go | 27 +-- .../jsonschema/testdata/txtar/basic.txtar | 4 +- .../jsonschema/testdata/txtar/openapi.txtar | 2 +- encoding/jsonschema/version.go | 16 +- encoding/jsonschema/version_string.go | 7 +- encoding/openapi/decode.go | 32 +++- 7 files changed, 144 insertions(+), 109 deletions(-) diff --git a/encoding/jsonschema/constraints.go b/encoding/jsonschema/constraints.go index 1caf7534c..6cf38fa2b 100644 --- a/encoding/jsonschema/constraints.go +++ b/encoding/jsonschema/constraints.go @@ -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} } 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..075b4b4bb 100644 --- a/encoding/jsonschema/version.go +++ b/encoding/jsonschema/version.go @@ -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<= 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 {