Repository: ogen-go/ogen
Pull request #627 (2022-10-15 15:59:23)

feat(parser): parse openapi version

codecov[bot]: # [Codecov](https://codecov.io/gh/ogen-go/ogen/pull/627?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go) Report
Base: **73.71**% // Head: **73.89**% // Increases project coverage by **`+0.17%`** :tada:
> Coverage data is based on head [(`66cb949`)](https://codecov.io/gh/ogen-go/ogen/pull/627?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go) compared to base [(`73d0bba`)](https://codecov.io/gh/ogen-go/ogen/commit/73d0bba48aeb4587b699e8d7b071d8e9afa7b37a?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go).
> Patch coverage: 100.00% of modified lines in pull request are covered.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main     #627      +/-   ##
==========================================
+ Coverage   73.71%   73.89%   +0.17%     
==========================================
  Files         166      168       +2     
  Lines       11459    11538      +79     
==========================================
+ Hits         8447     8526      +79     
+ Misses       2578     2577       -1     
- Partials      434      435       +1     
```


| [Impacted Files](https://codecov.io/gh/ogen-go/ogen/pull/627?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go) | Coverage Δ | |
|---|---|---|
| [openapi/parser/parse\_components.go](https://codecov.io/gh/ogen-go/ogen/pull/627/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go#diff-b3BlbmFwaS9wYXJzZXIvcGFyc2VfY29tcG9uZW50cy5nbw==) | `86.84% <100.00%> (+0.60%)` | :arrow_up: |
| [openapi/parser/parse\_security.go](https://codecov.io/gh/ogen-go/ogen/pull/627/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go#diff-b3BlbmFwaS9wYXJzZXIvcGFyc2Vfc2VjdXJpdHkuZ28=) | `96.62% <100.00%> (+2.91%)` | :arrow_up: |
| [openapi/parser/parse\_webhook.go](https://codecov.io/gh/ogen-go/ogen/pull/627/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go#diff-b3BlbmFwaS9wYXJzZXIvcGFyc2Vfd2ViaG9vay5nbw==) | `100.00% <100.00%> (ø)` | |
| [openapi/parser/parser.go](https://codecov.io/gh/ogen-go/ogen/pull/627/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go#diff-b3BlbmFwaS9wYXJzZXIvcGFyc2VyLmdv) | `100.00% <100.00%> (ø)` | |
| [openapi/parser/version.go](https://codecov.io/gh/ogen-go/ogen/pull/627/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go#diff-b3BlbmFwaS9wYXJzZXIvdmVyc2lvbi5nbw==) | `100.00% <100.00%> (ø)` | |
| [openapi/version.go](https://codecov.io/gh/ogen-go/ogen/pull/627/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go#diff-b3BlbmFwaS92ZXJzaW9uLmdv) | `100.00% <100.00%> (ø)` | |
| [openapi/parser/parse\_example.go](https://codecov.io/gh/ogen-go/ogen/pull/627/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go#diff-b3BlbmFwaS9wYXJzZXIvcGFyc2VfZXhhbXBsZS5nbw==) | `85.71% <0.00%> (-14.29%)` | :arrow_down: |
| [gen/gen\_contents.go](https://codecov.io/gh/ogen-go/ogen/pull/627/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go#diff-Z2VuL2dlbl9jb250ZW50cy5nbw==) | `72.20% <0.00%> (-0.68%)` | :arrow_down: |

Help us with your feedback. Take ten seconds to tell us [how you rate us](https://about.codecov.io/nps?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go). Have a feature suggestion? [Share it here.](https://app.codecov.io/gh/feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go)

</details>

[:umbrella: View full report at Codecov](https://codecov.io/gh/ogen-go/ogen/pull/627?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go).   
:loudspeaker: Do you have feedback about the report comment? [Let us know in this issue](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go).


Patch:
diff --git a/openapi/parser/parse_components.go b/openapi/parser/parse_components.go
index c998fcb7..d4912d3d 100644
--- a/openapi/parser/parse_components.go
+++ b/openapi/parser/parse_components.go
@@ -92,6 +92,12 @@ func (p *parser) parseComponents(c *ogen.Components) (_ *openapi.Components, rer
 	if err := validateComponentsKeys(p, c); err != nil {
 		return nil, err
 	}
+	if len(c.PathItems) > 0 {
+		if err := p.requireMinorVersion("pathItem components", 1); err != nil {
+			err := p.wrapLocation("", locator.Field("pathItems"), err)
+			return nil, err
+		}
+	}
 
 	result := &openapi.Components{
 		Schemas:       make(map[string]*jsonschema.Schema, len(c.Schemas)),
diff --git a/openapi/parser/parse_security.go b/openapi/parser/parse_security.go
index c723aff1..cf24ce19 100644
--- a/openapi/parser/parse_security.go
+++ b/openapi/parser/parse_security.go
@@ -70,6 +70,9 @@ func (p *parser) parseSecurityScheme(
 			}
 			return nil
 		case "mutualTLS":
+			if err := p.requireMinorVersion("mutualTLS security", 1); err != nil {
+				return p.wrapField("type", ctx.LastLoc(), locator, err)
+			}
 			return nil
 		case "oauth2":
 			err := p.validateOAuthFlows(scheme.Flows, ctx.LastLoc())
@@ -192,7 +195,7 @@ func (p *parser) parseSecurityRequirements(
 		for name, scopes := range req {
 			v, ok := p.securitySchemes[name]
 			if !ok {
-				err := errors.Errorf("unknown security schema %q", name)
+				err := errors.Errorf("unknown security scheme %q", name)
 				return nil, p.wrapLocation(ctx.LastLoc(), locator.Key(name), err)
 			}
 
diff --git a/openapi/parser/parse_webhook.go b/openapi/parser/parse_webhook.go
index 4c8a9d1b..a5ec9aac 100644
--- a/openapi/parser/parse_webhook.go
+++ b/openapi/parser/parse_webhook.go
@@ -32,6 +32,9 @@ func (p *parser) parseWebhooks(webhooks map[string]*ogen.PathItem) (r []openapi.
 	defer func() {
 		rerr = p.wrapLocation(ctx.LastLoc(), locator, rerr)
 	}()
+	if err := p.requireMinorVersion("webhooks", 1); err != nil {
+		return nil, err
+	}
 
 	r = make([]openapi.Webhook, 0, len(webhooks))
 	for _, name := range xmaps.SortedKeys(webhooks) {
diff --git a/openapi/parser/parser.go b/openapi/parser/parser.go
index b25960ce..3670c18d 100644
--- a/openapi/parser/parser.go
+++ b/openapi/parser/parser.go
@@ -20,6 +20,9 @@ type parser struct {
 	spec *ogen.Spec
 	// root location of the spec, immutable.
 	rootLoc location.Locator
+	// parsed version of the spec, immutable.
+	version openapi.Version
+
 	// parsed operations.
 	operations []*openapi.Operation
 	// refs contains lazy-initialized referenced components.
@@ -52,7 +55,7 @@ type parser struct {
 }
 
 // Parse parses raw Spec into
-func Parse(spec *ogen.Spec, s Settings) (*openapi.API, error) {
+func Parse(spec *ogen.Spec, s Settings) (_ *openapi.API, rerr error) {
 	if spec == nil {
 		return nil, errors.New("spec is nil")
 	}
@@ -101,12 +104,19 @@ func Parse(spec *ogen.Spec, s Settings) (*openapi.API, error) {
 		var loc location.Location
 		loc.FromNode(spec.Raw)
 		p.rootLoc.SetLocation(loc)
+		defer func() {
+			rerr = p.wrapLocation("", p.rootLoc, rerr)
+		}()
 	}
 
 	for name, s := range spec.Components.SecuritySchemes {
 		p.securitySchemes[name] = s
 	}
 
+	if err := p.parseVersion(); err != nil {
+		return nil, errors.Wrap(err, "parse version")
+	}
+
 	components, err := p.parseComponents(spec.Components)
 	if err != nil {
 		return nil, errors.Wrap(err, "parse components")
diff --git a/openapi/parser/version.go b/openapi/parser/version.go
new file mode 100644
index 00000000..6ab3d5a8
--- /dev/null
+++ b/openapi/parser/version.go
@@ -0,0 +1,51 @@
+package parser
+
+import (
+	"fmt"
+
+	"github.com/go-faster/errors"
+
+	"github.com/ogen-go/ogen/openapi"
+)
+
+func (p *parser) parseVersion() (rerr error) {
+	defer func() {
+		rerr = p.wrapLocation("", p.rootLoc.Field("openapi"), rerr)
+	}()
+	if err := p.version.UnmarshalText([]byte(p.spec.OpenAPI)); err != nil {
+		return errors.Wrap(err, "invalid version")
+	}
+	if p.version.Major != 3 || p.version.Minor > 1 {
+		return errors.Errorf("unsupported version: %s", p.spec.OpenAPI)
+	}
+	return nil
+}
+
+// FeatureVersionError is an error that is returned when a feature is used
+// that requires a newer version of OpenAPI.
+type FeatureVersionError struct {
+	Feature string
+	Minimum openapi.Version
+	Actual  openapi.Version
+}
+
+// Error implements error.
+func (f *FeatureVersionError) Error() string {
+	return fmt.Sprintf("feature %q requires OpenAPI version %s, but actual version is %s",
+		f.Feature, f.Minimum, f.Actual,
+	)
+}
+
+func (p *parser) requireMinorVersion(feature string, minor int) error {
+	if p.version.Minor >= minor {
+		return nil
+	}
+	return &FeatureVersionError{
+		Feature: feature,
+		Minimum: openapi.Version{
+			Major: p.version.Major,
+			Minor: minor,
+		},
+		Actual: p.version,
+	}
+}
diff --git a/openapi/version.go b/openapi/version.go
new file mode 100644
index 00000000..e8629f60
--- /dev/null
+++ b/openapi/version.go
@@ -0,0 +1,65 @@
+package openapi
+
+import (
+	"fmt"
+	"strconv"
+	"strings"
+
+	"github.com/go-faster/errors"
+)
+
+// Version represents OpenAPI version.
+type Version struct {
+	// Major is the major version number.
+	Major int
+	// Minor is the minor version number.
+	Minor int
+	// Patch is the patch version number.
+	Patch int
+}
+
+// MarshalText implements encoding.TextMarshaler.
+func (v *Version) MarshalText() ([]byte, error) {
+	return []byte(v.String()), nil
+}
+
+// UnmarshalText implements encoding.TextUnmarshaler.
+func (v *Version) UnmarshalText(text []byte) error {
+	var (
+		r      = string(text)
+		err    error
+		target Version
+	)
+	switch n := strings.Split(r, "."); len(n) {
+	case 3:
+		target.Patch, err = strconv.Atoi(n[2])
+		if err != nil {
+			return errors.Wrap(err, "invalid patch version")
+		}
+		fallthrough
+	case 2:
+		target.Minor, err = strconv.Atoi(n[1])
+		if err != nil {
+			return errors.Wrap(err, "invalid minor version")
+		}
+		fallthrough
+	case 1:
+		major := n[0]
+		if len(n) == 1 {
+			major = r
+		}
+		target.Major, err = strconv.Atoi(major)
+		if err != nil {
+			return errors.Wrap(err, "invalid major version")
+		}
+	default:
+		return errors.New("version must have format <major>.<minor>[.<patch>]")
+	}
+	*v = target
+	return nil
+}
+
+// String returns the string representation of the version.
+func (v Version) String() string {
+	return fmt.Sprintf("%d.%d.%d", v.Major, v.Minor, v.Patch)
+}
diff --git a/spec.go b/spec.go
index 9e3da048..5f2b1929 100644
--- a/spec.go
+++ b/spec.go
@@ -248,32 +248,28 @@ type Components struct {
 	Common OpenAPICommon `json:"-" yaml:",inline"`
 }
 
+func initMapIfNil[K comparable, V any](m map[K]V) map[K]V {
+	if m == nil {
+		m = make(map[K]V)
+	}
+	return m
+}
+
 // Init initializes all fields.
 func (c *Components) Init() {
 	if c == nil {
 		return
 	}
-	if c.Schemas == nil {
-		c.Schemas = map[string]*Schema{}
-	}
-	if c.Responses == nil {
-		c.Responses = map[string]*Response{}
-	}
-	if c.Parameters == nil {
-		c.Parameters = map[string]*Parameter{}
-	}
-	if c.Headers == nil {
-		c.Headers = map[string]*Header{}
-	}
-	if c.Examples == nil {
-		c.Examples = map[string]*Example{}
-	}
-	if c.RequestBodies == nil {
-		c.RequestBodies = map[string]*RequestBody{}
-	}
-	if c.SecuritySchemes == nil {
-		c.SecuritySchemes = map[string]*SecurityScheme{}
-	}
+	c.Schemas = initMapIfNil(c.Schemas)
+	c.Responses = initMapIfNil(c.Responses)
+	c.Parameters = initMapIfNil(c.Parameters)
+	c.Examples = initMapIfNil(c.Examples)
+	c.RequestBodies = initMapIfNil(c.RequestBodies)
+	c.Headers = initMapIfNil(c.Headers)
+	c.SecuritySchemes = initMapIfNil(c.SecuritySchemes)
+	c.Links = initMapIfNil(c.Links)
+	c.Callbacks = initMapIfNil(c.Callbacks)
+	c.PathItems = initMapIfNil(c.PathItems)
 }
 
 // Paths holds the relative paths to the individual endpoints and their operations.


Test patch:
diff --git a/openapi/parser/_testdata/negative/securityScheme/unknown_scheme.json b/openapi/parser/_testdata/negative/securityScheme/unknown_scheme.json
new file mode 100644
index 00000000..9c3143bd
--- /dev/null
+++ b/openapi/parser/_testdata/negative/securityScheme/unknown_scheme.json
@@ -0,0 +1,23 @@
+{
+  "openapi": "3.0.3",
+  "info": {
+    "title": "title",
+    "version": "v0.1.0"
+  },
+  "paths": {
+    "/foo": {
+      "get": {
+        "security": [
+          {
+            "api_key": []
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "default"
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/openapi/parser/_testdata/negative/version/3.1_mutualTLS.json b/openapi/parser/_testdata/negative/version/3.1_mutualTLS.json
new file mode 100644
index 00000000..5bb06fdf
--- /dev/null
+++ b/openapi/parser/_testdata/negative/version/3.1_mutualTLS.json
@@ -0,0 +1,30 @@
+{
+  "openapi": "3.0.3",
+  "info": {
+    "title": "title",
+    "version": "v0.1.0"
+  },
+  "paths": {
+    "/foo": {
+      "get": {
+        "security": [
+          {
+            "api_key": []
+          }
+        ],
+        "responses": {
+          "200": {
+            "description": "default"
+          }
+        }
+      }
+    }
+  },
+  "components": {
+    "securitySchemes": {
+      "api_key": {
+        "type": "mutualTLS"
+      }
+    }
+  }
+}
diff --git a/openapi/parser/_testdata/negative/version/3.1_pathItem_components.json b/openapi/parser/_testdata/negative/version/3.1_pathItem_components.json
new file mode 100644
index 00000000..1610b5eb
--- /dev/null
+++ b/openapi/parser/_testdata/negative/version/3.1_pathItem_components.json
@@ -0,0 +1,25 @@
+{
+  "openapi": "3.0.3",
+  "info": {
+    "title": "title",
+    "version": "v0.1.0"
+  },
+  "paths": {
+    "/foo": {
+      "$ref": "#/components/pathItems/PathItem"
+    }
+  },
+  "components": {
+    "pathItems": {
+      "PathItem": {
+        "get": {
+          "responses": {
+            "200": {
+              "description": "default"
+            }
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/openapi/parser/_testdata/negative/version/3.1_webhooks.json b/openapi/parser/_testdata/negative/version/3.1_webhooks.json
new file mode 100644
index 00000000..0fb4a145
--- /dev/null
+++ b/openapi/parser/_testdata/negative/version/3.1_webhooks.json
@@ -0,0 +1,29 @@
+{
+  "openapi": "3.0.3",
+  "info": {
+    "title": "title",
+    "version": "v0.1.0"
+  },
+  "webhooks": {
+    "update": {
+      "get": {
+        "responses": {
+          "200": {
+            "description": "default"
+          }
+        }
+      }
+    }
+  },
+  "paths": {
+    "/foo": {
+      "get": {
+        "responses": {
+          "200": {
+            "description": "default"
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/openapi/parser/_testdata/negative/version/invalid_version.json b/openapi/parser/_testdata/negative/version/invalid_version.json
new file mode 100644
index 00000000..cfff4830
--- /dev/null
+++ b/openapi/parser/_testdata/negative/version/invalid_version.json
@@ -0,0 +1,18 @@
+{
+  "openapi": "4.0.3",
+  "info": {
+    "title": "title",
+    "version": "v0.1.0"
+  },
+  "paths": {
+    "/foo": {
+      "get": {
+        "responses": {
+          "200": {
+            "description": "default"
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/openapi/parser/errors_test.go b/openapi/parser/errors_test.go
index 37394b0e..f87589c1 100644
--- a/openapi/parser/errors_test.go
+++ b/openapi/parser/errors_test.go
@@ -14,6 +14,7 @@ func TestRemoteLocation(t *testing.T) {
 	a := require.New(t)
 
 	root := &ogen.Spec{
+		OpenAPI: "3.0.3",
 		Paths: map[string]*ogen.PathItem{
 			"/get": {
 				Get: &ogen.Operation{
@@ -53,5 +54,6 @@ func TestRemoteLocation(t *testing.T) {
 		iterErr = locErr.Err
 	}
 	t.Log(locErr)
+	a.NotNil(locErr)
 	a.Equal("foo.json", locErr.File)
 }
diff --git a/openapi/parser/resolve_external_test.go b/openapi/parser/resolve_external_test.go
index 9ce039ca..27ecd543 100644
--- a/openapi/parser/resolve_external_test.go
+++ b/openapi/parser/resolve_external_test.go
@@ -27,6 +27,7 @@ func TestExternalReference(t *testing.T) {
 	exampleValue := jsonschema.Example(`"value"`)
 
 	root := &ogen.Spec{
+		OpenAPI: "3.1.0",
 		Paths: map[string]*ogen.PathItem{
 			"/get": {
 				Get: &ogen.Operation{
@@ -312,6 +313,7 @@ func TestExternalReference(t *testing.T) {
 // Ensure that parser checks for duplicate operation IDs even if there is pathItem reference.
 func TestDuplicateOperationID(t *testing.T) {
 	root := &ogen.Spec{
+		OpenAPI: "3.0.3",
 		Paths: map[string]*ogen.PathItem{
 			"/get": {
 				Get: &ogen.Operation{
diff --git a/openapi/parser/resolve_test.go b/openapi/parser/resolve_test.go
index fb095227..6b98cf4f 100644
--- a/openapi/parser/resolve_test.go
+++ b/openapi/parser/resolve_test.go
@@ -27,6 +27,7 @@ func compareJSON(t require.TestingT, expected, got any) {
 
 func TestComplicatedReference(t *testing.T) {
 	root := &ogen.Spec{
+		OpenAPI: "3.0.3",
 		Paths: map[string]*ogen.PathItem{
 			"/get": {
 				Get: &ogen.Operation{
diff --git a/openapi/version_test.go b/openapi/version_test.go
new file mode 100644
index 00000000..30ac7311
--- /dev/null
+++ b/openapi/version_test.go
@@ -0,0 +1,56 @@
+package openapi
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+)
+
+func TestVersion_UnmarshalText(t *testing.T) {
+	tests := []struct {
+		input   string
+		want    Version
+		wantErr bool
+	}{
+		{"3", Version{Major: 3}, false},
+		{"3.0", Version{Major: 3, Minor: 0}, false},
+		{"3.1", Version{Major: 3, Minor: 1}, false},
+		{"3.1.0", Version{Major: 3, Minor: 1, Patch: 0}, false},
+		{"3.1.1", Version{Major: 3, Minor: 1, Patch: 1}, false},
+
+		{"", Version{}, true},
+		{" ", Version{}, true},
+		{".", Version{}, true},
+		{"..", Version{}, true},
+		{"3.", Version{}, true},
+		{"3.1.", Version{}, true},
+		{".1.", Version{}, true},
+		{"3..0", Version{}, true},
+		{"3.3 .0", Version{}, true},
+		{"3.3.0.4", Version{}, true},
+	}
+	for i, tt := range tests {
+		tt := tt
+		t.Run(fmt.Sprintf("Test%d", i+1), func(t *testing.T) {
+			a := require.New(t)
+
+			var v Version
+			err := v.UnmarshalText([]byte(tt.input))
+			if tt.wantErr {
+				a.Error(err)
+				a.Zero(v)
+				return
+			}
+			a.NoError(err)
+			a.Equal(tt.want, v)
+
+			// Ensure that the version can be marshaled back to the same string.
+			r, err := v.MarshalText()
+			a.NoError(err)
+			var v2 Version
+			a.NoError(v2.UnmarshalText(r))
+			a.Equal(v, v2)
+		})
+	}
+}
diff --git a/spec_test.go b/spec_test.go
index f20e8623..0e6fb02f 100644
--- a/spec_test.go
+++ b/spec_test.go
@@ -1,6 +1,7 @@
 package ogen_test
 
 import (
+	"reflect"
 	"testing"
 
 	yaml "github.com/go-faster/yamlx"
@@ -32,3 +33,21 @@ func TestExtensionParsing(t *testing.T) {
 		// FIXME(tdakkota): encodeDecode doesn't work for this type
 	}
 }
+
+func TestComponents_Init(t *testing.T) {
+	a := require.New(t)
+
+	c := ogen.Components{}
+	c.Init()
+
+	val := reflect.ValueOf(c)
+	a.Equalf(10+1, val.NumField(), "update this test if you add new fields to Components")
+	for i := 0; i < val.NumField(); i++ {
+		f := val.Field(i)
+		// Init doesn't set Common and it's ok.
+		if val.Type().Field(i).Name == "Common" {
+			continue
+		}
+		a.NotNil(f.Interface())
+	}
+}