Repository: ogen-go/ogen
Pull request #583 (2022-09-22 04:44:02)

feat(parser): parse servers

codecov[bot]: # [Codecov](https://codecov.io/gh/ogen-go/ogen/pull/583?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go) Report
Base: **72.09**% // Head: **72.43**% // Increases project coverage by **`+0.33%`** :tada:
> Coverage data is based on head [(`3f25001`)](https://codecov.io/gh/ogen-go/ogen/pull/583?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go) compared to base [(`d43685c`)](https://codecov.io/gh/ogen-go/ogen/commit/d43685ce652de8368b0fac25af2de8efbb4aea90?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go).
> Patch coverage: 94.89% of modified lines in pull request are covered.

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


```diff
@@            Coverage Diff             @@
##             main     #583      +/-   ##
==========================================
+ Coverage   72.09%   72.43%   +0.33%     
==========================================
  Files         159      160       +1     
  Lines       11340    11464     +124     
==========================================
+ Hits         8176     8304     +128     
+ Misses       2739     2732       -7     
- Partials      425      428       +3     
```


| [Impacted Files](https://codecov.io/gh/ogen-go/ogen/pull/583?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go) | Coverage Δ | |
|---|---|---|
| [spec.go](https://codecov.io/gh/ogen-go/ogen/pull/583/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go#diff-c3BlYy5nbw==) | `66.66% <ø> (ø)` | |
| [openapi/parser/parse\_server.go](https://codecov.io/gh/ogen-go/ogen/pull/583/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go#diff-b3BlbmFwaS9wYXJzZXIvcGFyc2Vfc2VydmVyLmdv) | `93.63% <93.63%> (ø)` | |
| [openapi/parser/parser.go](https://codecov.io/gh/ogen-go/ogen/pull/583/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/path\_parser.go](https://codecov.io/gh/ogen-go/ogen/pull/583/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go#diff-b3BlbmFwaS9wYXJzZXIvcGF0aF9wYXJzZXIuZ28=) | `96.66% <100.00%> (+0.37%)` | :arrow_up: |
| [gen/gen\_contents.go](https://codecov.io/gh/ogen-go/ogen/pull/583/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: |
| [internal/location/pretty\_error.go](https://codecov.io/gh/ogen-go/ogen/pull/583/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go#diff-aW50ZXJuYWwvbG9jYXRpb24vcHJldHR5X2Vycm9yLmdv) | `80.00% <0.00%> (+23.63%)` | :arrow_up: |

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/583?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/gen/gen_operation.go b/gen/gen_operation.go
index 3bea34f0..deeba6eb 100644
--- a/gen/gen_operation.go
+++ b/gen/gen_operation.go
@@ -57,7 +57,7 @@ func (g *Generator) generateOperation(ctx *genctx, spec *openapi.Operation) (_ *
 	return op, nil
 }
 
-func convertPathParts(parts []openapi.PathPart, params []*ir.Parameter) []*ir.PathPart {
+func convertPathParts(parts openapi.Path, params []*ir.Parameter) []*ir.PathPart {
 	find := func(pname string) (*ir.Parameter, bool) {
 		for _, p := range params {
 			if p.Spec.Name == pname && p.Spec.In == openapi.LocationPath {
@@ -69,7 +69,7 @@ func convertPathParts(parts []openapi.PathPart, params []*ir.Parameter) []*ir.Pa
 
 	result := make([]*ir.PathPart, 0, len(parts))
 	for _, part := range parts {
-		if part.Raw != "" {
+		if !part.IsParam() {
 			result = append(result, &ir.PathPart{Raw: part.Raw})
 			continue
 		}
diff --git a/openapi/api.go b/openapi/api.go
index 07c6b865..dc2da459 100644
--- a/openapi/api.go
+++ b/openapi/api.go
@@ -5,6 +5,7 @@ import "github.com/ogen-go/ogen/jsonschema"
 
 // API represents parsed OpenAPI spec.
 type API struct {
+	Servers    []Server
 	Operations []*Operation
 	Components *Components
 }
diff --git a/openapi/operation.go b/openapi/operation.go
index e12a562e..9270f500 100644
--- a/openapi/operation.go
+++ b/openapi/operation.go
@@ -29,41 +29,6 @@ type Operation struct {
 	location.Locator `json:"-" yaml:"-"`
 }
 
-// Path is an operation path.
-type Path []PathPart
-
-// ID returns path, but without parameter names.
-//
-// For example, if path is "/users/{id}", ID returns "/users/{}".
-func (p Path) ID() (path string) {
-	for _, part := range p {
-		if part.Raw != "" {
-			path += part.Raw
-			continue
-		}
-		path += "{}"
-	}
-	return
-}
-
-// String implements fmt.Stringer.
-func (p Path) String() (path string) {
-	for _, part := range p {
-		if part.Raw != "" {
-			path += part.Raw
-			continue
-		}
-		path += "{" + part.Param.Name + "}"
-	}
-	return
-}
-
-// PathPart is a part of an OpenAPI Operation Path.
-type PathPart struct {
-	Raw   string
-	Param *Parameter
-}
-
 // RequestBody of an OpenAPI Operation.
 type RequestBody struct {
 	Ref         string
diff --git a/openapi/parser/parse_server.go b/openapi/parser/parse_server.go
new file mode 100644
index 00000000..78911792
--- /dev/null
+++ b/openapi/parser/parse_server.go
@@ -0,0 +1,126 @@
+package parser
+
+import (
+	"fmt"
+	"go/token"
+
+	"github.com/go-faster/errors"
+	"golang.org/x/exp/slices"
+
+	"github.com/ogen-go/ogen"
+	"github.com/ogen-go/ogen/internal/jsonpointer"
+	"github.com/ogen-go/ogen/openapi"
+)
+
+func (p *parser) parseServers(servers []ogen.Server, ctx *jsonpointer.ResolveCtx) ([]openapi.Server, error) {
+	if len(servers) == 0 {
+		return nil, nil
+	}
+	var (
+		r     = make([]openapi.Server, len(servers))
+		dedup = map[string]struct{}{}
+	)
+	for i, s := range servers {
+		srv, err := p.parseServer(s, dedup, ctx)
+		if err != nil {
+			return nil, err
+		}
+		r[i] = srv
+	}
+	return r, nil
+}
+
+func (p *parser) parseServer(
+	s ogen.Server,
+	dedup map[string]struct{},
+	ctx *jsonpointer.ResolveCtx,
+) (_ openapi.Server, rerr error) {
+	locator := s.Common.Locator
+	defer func() {
+		rerr = p.wrapLocation(ctx.LastLoc(), locator, rerr)
+	}()
+
+	// Validate variables.
+	for _, v := range s.Variables {
+		locator := v.Common.Locator
+		if len(v.Enum) > 0 {
+			locator := locator.Field("enum")
+			if !slices.Contains(v.Enum, v.Default) {
+				err := errors.Errorf("the default value %q MUST exist in the enum's values", v.Default)
+				return openapi.Server{}, p.wrapLocation(ctx.LastLoc(), locator, err)
+			}
+			for i, e := range v.Enum {
+				locator := locator.Index(i)
+				found := slices.Index(v.Enum, e)
+				if found < 0 {
+					panic(fmt.Sprintf("unreachable: slice %#v doesn't contain %q", v.Enum, e))
+				}
+				if found != i {
+					err := errors.Errorf("enum MUST NOT contain duplicate values: %q", e)
+					return openapi.Server{}, p.wrapLocation(ctx.LastLoc(), locator, err)
+				}
+			}
+		}
+	}
+
+	// Validate URL.
+	u, err := func() (openapi.ServerURL, error) {
+		if s.URL == "" {
+			return nil, errors.New("server URL must not be empty")
+		}
+		return parseServerURL(s.URL, func(name string) (sv openapi.ServerVariable, _ bool) {
+			v, ok := s.Variables[name]
+			if !ok {
+				return sv, false
+			}
+
+			return openapi.ServerVariable{
+				Name:    name,
+				Default: v.Default,
+				Enum:    slices.Clone(v.Enum),
+			}, true
+		})
+	}()
+	if err != nil {
+		locator := locator.Field("url")
+		return openapi.Server{}, p.wrapLocation(ctx.LastLoc(), locator, err)
+	}
+
+	server := openapi.Server{
+		Template:    u,
+		Description: s.Description,
+	}
+
+	// Parse extensions.
+	//
+	// TODO(tdakkota): describe extensions somewhere, it would be nice to have machine-readable
+	// 	description of extensions, their types, and their validation rules.
+	const nameKey = "x-ogen-server-name"
+	if nameNode, ok := s.Common.Extensions[nameKey]; ok {
+		if err := func() error {
+			if err := nameNode.Decode(&server.Name); err != nil {
+				return err
+			}
+
+			name := server.Name
+			switch {
+			case name == "":
+				return errors.New("server name must not be empty")
+			case !token.IsIdentifier(name + "Server"):
+				// Ensure that ${name}Server is a valid Go identifier.
+				return errors.Errorf("server name %q cannot be used as a Go identifier", name)
+			}
+			if _, ok := dedup[name]; ok {
+				return errors.Errorf("server name %q is not unique", name)
+			}
+			dedup[name] = struct{}{}
+
+			return nil
+		}(); err != nil {
+			locator := locator.Field(nameKey)
+			return openapi.Server{}, p.wrapLocation(ctx.LastLoc(), locator, err)
+		}
+	}
+
+	return server, nil
+}
diff --git a/openapi/parser/parser.go b/openapi/parser/parser.go
index 396946b4..5a40b7c1 100644
--- a/openapi/parser/parser.go
+++ b/openapi/parser/parser.go
@@ -104,10 +104,16 @@ func Parse(spec *ogen.Spec, s Settings) (*openapi.API, error) {
 	}
 
 	if err := p.parsePathItems(); err != nil {
-		return nil, errors.Wrap(err, "parse operations")
+		return nil, errors.Wrap(err, "parse path items")
+	}
+
+	servers, err := p.parseServers(p.spec.Servers, jsonpointer.NewResolveCtx(p.depthLimit))
+	if err != nil {
+		return nil, errors.Wrap(err, "parse servers")
 	}
 
 	return &openapi.API{
+		Servers:    servers,
 		Operations: p.operations,
 		Components: components,
 	}, nil
diff --git a/openapi/parser/path_parser.go b/openapi/parser/path_parser.go
index b7cc23f9..60700f78 100644
--- a/openapi/parser/path_parser.go
+++ b/openapi/parser/path_parser.go
@@ -10,17 +10,20 @@ import (
 	"github.com/ogen-go/ogen/openapi"
 )
 
-type pathParser struct {
-	path   string // immutable
-	lookup func(name string) (*openapi.Parameter, bool)
-
-	parts []openapi.PathPart // parsed parts
-	part  []rune             // current part
-	param bool               // current part is param name?
+type pathParser[P any] struct {
+	// Input path.
+	path string // immutable
+	// Callback to lookup parameter by name.
+	lookup func(name string) (P, bool) // immutable
+
+	// Parser state.
+	parts []openapi.PathPart[P] // parsed parts
+	part  []rune                // current part
+	param bool                  // current part is param name?
 }
 
 func pathID(path string) (string, error) {
-	p, err := (&pathParser{
+	p, err := (&pathParser[*openapi.Parameter]{
 		path: path,
 		lookup: func(name string) (*openapi.Parameter, bool) {
 			return nil, true
@@ -29,31 +32,14 @@ func pathID(path string) (string, error) {
 	if err != nil {
 		return "", err
 	}
-	return p.ID(), nil
-}
-
-func parsePath(path string, params []*openapi.Parameter) (openapi.Path, error) {
-	return (&pathParser{
-		path: path,
-		lookup: func(name string) (*openapi.Parameter, bool) {
-			for _, p := range params {
-				if p.Name == name && p.In == openapi.LocationPath {
-					return p, true
-				}
-			}
-			return nil, false
-		},
-	}).Parse()
+	return openapi.Path(p).ID(), nil
 }
 
-func (p *pathParser) Parse() (openapi.Path, error) {
-	err := p.parse()
-	return p.parts, err
-}
+var errInvalidPathUTF8 = errors.New("path must be valid UTF-8 string")
 
-func (p *pathParser) parse() error {
-	if !utf8.ValidString(p.path) {
-		return errors.New("path must be valid UTF-8 string")
+func parsePath(path string, params []*openapi.Parameter) (openapi.Path, error) {
+	if !utf8.ValidString(path) {
+		return nil, errInvalidPathUTF8
 	}
 
 	// Validate and unescape path.
@@ -64,20 +50,50 @@ func (p *pathParser) parse() error {
 	// 	paths must not include query parameters.
 	//  In summary, we do not pass URL scheme, user info, host or query string.
 	//
-	u, err := url.Parse(p.path)
+	u, err := url.Parse(path)
 	if err != nil {
-		return err
+		return nil, err
 	}
 	switch {
 	case u.IsAbs() || u.Host != "" || u.User != nil:
-		return errors.New("path MUST be relative")
+		return nil, errors.New("path MUST be relative")
 	case !strings.HasPrefix(u.Path, "/"):
-		return errors.New("path MUST begin with a forward slash")
+		return nil, errors.New("path MUST begin with a forward slash")
 	case u.RawQuery != "":
-		return errors.New("path MUST NOT contain a query string")
+		return nil, errors.New("path MUST NOT contain a query string")
 	}
+	path = u.Path
+
+	return (&pathParser[*openapi.Parameter]{
+		path: path,
+		lookup: func(name string) (*openapi.Parameter, bool) {
+			for _, p := range params {
+				if p.Name == name && p.In == openapi.LocationPath {
+					return p, true
+				}
+			}
+			return nil, false
+		},
+	}).Parse()
+}
+
+func parseServerURL(u string, lookup func(name string) (openapi.ServerVariable, bool)) (openapi.ServerURL, error) {
+	return (&pathParser[openapi.ServerVariable]{
+		path:   u,
+		lookup: lookup,
+	}).Parse()
+}
+
+func (p *pathParser[P]) Parse() ([]openapi.PathPart[P], error) {
+	err := p.parse()
+	return p.parts, err
+}
 
-	for _, r := range u.Path {
+func (p *pathParser[P]) parse() error {
+	if !utf8.ValidString(p.path) {
+		return errInvalidPathUTF8
+	}
+	for _, r := range p.path {
 		switch r {
 		case '/':
 			if p.param {
@@ -115,14 +131,14 @@ func (p *pathParser) parse() error {
 	return p.push()
 }
 
-func (p *pathParser) push() error {
+func (p *pathParser[P]) push() error {
 	if len(p.part) == 0 {
 		return nil
 	}
 	defer func() { p.part = nil }()
 
 	if !p.param {
-		p.parts = append(p.parts, openapi.PathPart{Raw: string(p.part)})
+		p.parts = append(p.parts, openapi.PathPart[P]{Raw: string(p.part)})
 		return nil
 	}
 
@@ -131,6 +147,6 @@ func (p *pathParser) push() error {
 		return errors.Errorf("path parameter not specified: %q", string(p.part))
 	}
 
-	p.parts = append(p.parts, openapi.PathPart{Param: param})
+	p.parts = append(p.parts, openapi.PathPart[P]{Param: param})
 	return nil
 }
diff --git a/openapi/path.go b/openapi/path.go
new file mode 100644
index 00000000..7f6ca58a
--- /dev/null
+++ b/openapi/path.go
@@ -0,0 +1,41 @@
+package openapi
+
+// Path is an operation path.
+type Path []PathPart[*Parameter]
+
+// ID returns path, but without parameter names.
+//
+// For example, if path is "/users/{id}", ID returns "/users/{}".
+func (p Path) ID() (path string) {
+	for _, part := range p {
+		if !part.IsParam() {
+			path += part.Raw
+			continue
+		}
+		path += "{}"
+	}
+	return
+}
+
+// String implements fmt.Stringer.
+func (p Path) String() (path string) {
+	for _, part := range p {
+		if !part.IsParam() {
+			path += part.Raw
+			continue
+		}
+		path += "{" + part.Param.Name + "}"
+	}
+	return
+}
+
+// PathPart is a part of an OpenAPI Operation Path.
+type PathPart[P any] struct {
+	Raw   string
+	Param P
+}
+
+// IsParam returns true if part is a parameter.
+func (p PathPart[P]) IsParam() bool {
+	return p.Raw == ""
+}
diff --git a/openapi/server.go b/openapi/server.go
new file mode 100644
index 00000000..a6c091c8
--- /dev/null
+++ b/openapi/server.go
@@ -0,0 +1,18 @@
+package openapi
+
+// Server represents parsed OpenAPI Server Object.
+type Server struct {
+	Name        string // optional,extension
+	Description string // optional
+	Template    ServerURL
+}
+
+// ServerVariable represents parsed OpenAPI Server Variable Object.
+type ServerVariable struct {
+	Name    string
+	Default string
+	Enum    []string
+}
+
+// ServerURL is URL template with variables.
+type ServerURL []PathPart[ServerVariable]
diff --git a/spec.go b/spec.go
index adc02267..9e3da048 100644
--- a/spec.go
+++ b/spec.go
@@ -197,8 +197,7 @@ type Server struct {
 	// A map between a variable name and its value. The value is used for substitution in the server's URL template.
 	Variables map[string]ServerVariable `json:"variables,omitempty" yaml:"variables,omitempty"`
 
-	// Specification extensions.
-	Extensions Extensions `json:"-" yaml:",inline"`
+	Common OpenAPICommon `json:"-" yaml:",inline"`
 }
 
 // ServerVariable describes an object representing a Server Variable for server URL template substitution.
@@ -216,8 +215,7 @@ type ServerVariable struct {
 	// An optional description for the server variable. CommonMark syntax MAY be used for rich text representation.
 	Description string `json:"description,omitempty" yaml:"description,omitempty"`
 
-	// Specification extensions.
-	Extensions Extensions `json:"-" yaml:",inline"`
+	Common OpenAPICommon `json:"-" yaml:",inline"`
 }
 
 // Components Holds a set of reusable objects for different aspects of the OAS.


Test patch:
diff --git a/dsl_test.go b/dsl_test.go
index 9dc8ceca..4677ab26 100644
--- a/dsl_test.go
+++ b/dsl_test.go
@@ -108,8 +108,8 @@ func TestBuilder(t *testing.T) {
 			Version: "0.1.0",
 		},
 		Servers: []ogen.Server{
-			{"staging", "staging.api.com", nil, _extensions},
-			{"production", "api.com", nil, _extensions},
+			{"staging", "staging.api.com", nil, _common},
+			{"production", "api.com", nil, _common},
 		},
 		Paths: map[string]*ogen.PathItem{
 			pathWithID: {
@@ -334,7 +334,7 @@ func TestBuilder(t *testing.T) {
 		SetHead(ogen.NewOperation().SetOperationID("head").SetResponses(ogen.Responses{"resp": ogen.NewResponse()})).
 		SetPatch(ogen.NewOperation().SetOperationID("patch").AddParameters(ogen.NewParameter().InHeader().SetDeprecated(true))).
 		SetTrace(ogen.NewOperation().SetOperationID("trace")).
-		SetServers([]ogen.Server{{"url1", "desc1", nil, nil}}).
+		SetServers([]ogen.Server{{"url1", "desc1", nil, _common}}).
 		AddServers(ogen.NewServer().SetDescription("desc2").SetURL("url2")).
 		SetParameters([]*ogen.Parameter{_queryParam.Parameter})
 	assert.Equal(t, &ogen.PathItem{
@@ -345,8 +345,8 @@ func TestBuilder(t *testing.T) {
 		Patch:   &ogen.Operation{OperationID: "patch", Parameters: []*ogen.Parameter{{In: "header", Deprecated: true}}},
 		Trace:   &ogen.Operation{OperationID: "trace"},
 		Servers: []ogen.Server{
-			{"url1", "desc1", nil, nil},
-			{"url2", "desc2", nil, nil},
+			{"url1", "desc1", nil, _common},
+			{"url2", "desc2", nil, _common},
 		},
 		Parameters: []*ogen.Parameter{_queryParam.Parameter},
 		Common:     _common,
diff --git a/openapi/parser/_testdata/negative/servers/duplicate_enum_value.json b/openapi/parser/_testdata/negative/servers/duplicate_enum_value.json
new file mode 100644
index 00000000..1491b391
--- /dev/null
+++ b/openapi/parser/_testdata/negative/servers/duplicate_enum_value.json
@@ -0,0 +1,33 @@
+{
+  "openapi": "3.0.3",
+  "info": {
+    "title": "title",
+    "version": "v0.1.0"
+  },
+  "servers": [
+    {
+      "url": "https://{dc}.example.com",
+      "variables": {
+        "dc": {
+          "default": "dc1",
+          "enum": [
+            "dc1",
+            "dc2",
+            "dc2"
+          ]
+        }
+      }
+    }
+  ],
+  "paths": {
+    "/foo": {
+      "post": {
+        "responses": {
+          "200": {
+            "description": "User info"
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/openapi/parser/_testdata/negative/servers/empty_server_url.json b/openapi/parser/_testdata/negative/servers/empty_server_url.json
new file mode 100644
index 00000000..b64ae527
--- /dev/null
+++ b/openapi/parser/_testdata/negative/servers/empty_server_url.json
@@ -0,0 +1,23 @@
+{
+  "openapi": "3.0.3",
+  "info": {
+    "title": "title",
+    "version": "v0.1.0"
+  },
+  "servers": [
+    {
+      "url": ""
+    }
+  ],
+  "paths": {
+    "/foo": {
+      "post": {
+        "responses": {
+          "200": {
+            "description": "User info"
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/openapi/parser/_testdata/negative/servers/enum_does_not_contain_default.json b/openapi/parser/_testdata/negative/servers/enum_does_not_contain_default.json
new file mode 100644
index 00000000..a5f4ed20
--- /dev/null
+++ b/openapi/parser/_testdata/negative/servers/enum_does_not_contain_default.json
@@ -0,0 +1,32 @@
+{
+  "openapi": "3.0.3",
+  "info": {
+    "title": "title",
+    "version": "v0.1.0"
+  },
+  "servers": [
+    {
+      "url": "https://{dc}.example.com",
+      "variables": {
+        "dc": {
+          "default": "dc1",
+          "enum": [
+            "dc2",
+            "dc3"
+          ]
+        }
+      }
+    }
+  ],
+  "paths": {
+    "/foo": {
+      "post": {
+        "responses": {
+          "200": {
+            "description": "User info"
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/openapi/parser/_testdata/negative/servers/unknown_parameter.json b/openapi/parser/_testdata/negative/servers/unknown_parameter.json
new file mode 100644
index 00000000..fc0b0500
--- /dev/null
+++ b/openapi/parser/_testdata/negative/servers/unknown_parameter.json
@@ -0,0 +1,28 @@
+{
+  "openapi": "3.0.3",
+  "info": {
+    "title": "title",
+    "version": "v0.1.0"
+  },
+  "servers": [
+    {
+      "url": "https://{dc}.example.com/{version}",
+      "variables": {
+        "dc": {
+          "default": "dc1"
+        }
+      }
+    }
+  ],
+  "paths": {
+    "/foo": {
+      "post": {
+        "responses": {
+          "200": {
+            "description": "User info"
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/openapi/parser/_testdata/negative/servers/x_duplicate_server_name.json b/openapi/parser/_testdata/negative/servers/x_duplicate_server_name.json
new file mode 100644
index 00000000..40c0d030
--- /dev/null
+++ b/openapi/parser/_testdata/negative/servers/x_duplicate_server_name.json
@@ -0,0 +1,28 @@
+{
+  "openapi": "3.0.3",
+  "info": {
+    "title": "title",
+    "version": "v0.1.0"
+  },
+  "servers": [
+    {
+      "url": "https://example.com/",
+      "x-ogen-server-name": "foo"
+    },
+    {
+      "url": "https://cdn.example.com",
+      "x-ogen-server-name": "foo"
+    }
+  ],
+  "paths": {
+    "/foo": {
+      "post": {
+        "responses": {
+          "200": {
+            "description": "User info"
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/openapi/parser/_testdata/negative/servers/x_empty_server_name.json b/openapi/parser/_testdata/negative/servers/x_empty_server_name.json
new file mode 100644
index 00000000..5df0bb8c
--- /dev/null
+++ b/openapi/parser/_testdata/negative/servers/x_empty_server_name.json
@@ -0,0 +1,24 @@
+{
+  "openapi": "3.0.3",
+  "info": {
+    "title": "title",
+    "version": "v0.1.0"
+  },
+  "servers": [
+    {
+      "url": "https://example.com/",
+      "x-ogen-server-name": ""
+    }
+  ],
+  "paths": {
+    "/foo": {
+      "post": {
+        "responses": {
+          "200": {
+            "description": "User info"
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/openapi/parser/_testdata/negative/servers/x_invalid_server_name.json b/openapi/parser/_testdata/negative/servers/x_invalid_server_name.json
new file mode 100644
index 00000000..9e838bcc
--- /dev/null
+++ b/openapi/parser/_testdata/negative/servers/x_invalid_server_name.json
@@ -0,0 +1,24 @@
+{
+  "openapi": "3.0.3",
+  "info": {
+    "title": "title",
+    "version": "v0.1.0"
+  },
+  "servers": [
+    {
+      "url": "https://example.com/",
+      "x-ogen-server-name": "."
+    }
+  ],
+  "paths": {
+    "/foo": {
+      "post": {
+        "responses": {
+          "200": {
+            "description": "User info"
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/openapi/parser/_testdata/negative/servers/x_wrong_type_server_name.json b/openapi/parser/_testdata/negative/servers/x_wrong_type_server_name.json
new file mode 100644
index 00000000..ebd0eb54
--- /dev/null
+++ b/openapi/parser/_testdata/negative/servers/x_wrong_type_server_name.json
@@ -0,0 +1,24 @@
+{
+  "openapi": "3.0.3",
+  "info": {
+    "title": "title",
+    "version": "v0.1.0"
+  },
+  "servers": [
+    {
+      "url": "https://example.com/",
+      "x-ogen-server-name": {}
+    }
+  ],
+  "paths": {
+    "/foo": {
+      "post": {
+        "responses": {
+          "200": {
+            "description": "User info"
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/openapi/parser/path_parser_test.go b/openapi/parser/path_parser_test.go
index af3638e0..27b7a495 100644
--- a/openapi/parser/path_parser_test.go
+++ b/openapi/parser/path_parser_test.go
@@ -65,7 +65,7 @@ func TestPathParser(t *testing.T) {
 		},
 		{
 			Path:      "\xF1",
-			ExpectErr: `path must be valid UTF-8 string`,
+			ExpectErr: errInvalidPathUTF8.Error(),
 		},
 		{
 			Path:      "\x00",
diff --git a/spec_test.go b/spec_test.go
index bd15bb71..f20e8623 100644
--- a/spec_test.go
+++ b/spec_test.go
@@ -9,14 +9,6 @@ import (
 	"github.com/ogen-go/ogen"
 )
 
-func encodeDecode[T any](a *require.Assertions, input T) (result T) {
-	data, err := yaml.Marshal(input)
-	a.NoError(err)
-
-	a.NoError(yaml.Unmarshal(data, &result))
-	return result
-}
-
 func TestExtensionParsing(t *testing.T) {
 	a := require.New(t)
 
@@ -26,9 +18,8 @@ func TestExtensionParsing(t *testing.T) {
 			s     ogen.Server
 		)
 		a.NoError(yaml.Unmarshal([]byte(input), &s))
-		a.Equal("foo", s.Extensions["x-ogen-name"].Value)
-		s2 := encodeDecode(a, s)
-		a.Equal("foo", s2.Extensions["x-ogen-name"].Value)
+		a.Equal("foo", s.Common.Extensions["x-ogen-name"].Value)
+		// FIXME(tdakkota): encodeDecode doesn't work for this type
 	}
 
 	{