Repository: ogen-go/ogen
Pull request #647 (2022-10-27 14:29:42)

feat(ogen): support URL as spec path argument

codecov[bot]: # [Codecov](https://codecov.io/gh/ogen-go/ogen/pull/647?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go) Report
Base: **73.83**% // Head: **74.17**% // Increases project coverage by **`+0.34%`** :tada:
> Coverage data is based on head [(`425b366`)](https://codecov.io/gh/ogen-go/ogen/pull/647?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go) compared to base [(`5307bd5`)](https://codecov.io/gh/ogen-go/ogen/commit/5307bd5eaa8dc1ae3259aa71b9cc0a4387e437b8?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go).
> Patch coverage: 66.29% of modified lines in pull request are covered.

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


```diff
@@            Coverage Diff             @@
##             main     #647      +/-   ##
==========================================
+ Coverage   73.83%   74.17%   +0.34%     
==========================================
  Files         169      168       -1     
  Lines       11754    11758       +4     
==========================================
+ Hits         8678     8722      +44     
+ Misses       2628     2581      -47     
- Partials      448      455       +7     
```


| [Impacted Files](https://codecov.io/gh/ogen-go/ogen/pull/647?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go) | Coverage Δ | |
|---|---|---|
| [gen/options.go](https://codecov.io/gh/ogen-go/ogen/pull/647/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go#diff-Z2VuL29wdGlvbnMuZ28=) | `16.66% <ø> (ø)` | |
| [openapi/parser/settings.go](https://codecov.io/gh/ogen-go/ogen/pull/647/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go#diff-b3BlbmFwaS9wYXJzZXIvc2V0dGluZ3MuZ28=) | `100.00% <ø> (ø)` | |
| [gen/generator.go](https://codecov.io/gh/ogen-go/ogen/pull/647/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go#diff-Z2VuL2dlbmVyYXRvci5nbw==) | `57.31% <50.00%> (+0.26%)` | :arrow_up: |
| [jsonschema/external.go](https://codecov.io/gh/ogen-go/ogen/pull/647/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go#diff-anNvbnNjaGVtYS9leHRlcm5hbC5nbw==) | `50.79% <52.45%> (+50.79%)` | :arrow_up: |
| [internal/jsonpointer/resolve\_ctx.go](https://codecov.io/gh/ogen-go/ogen/pull/647/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go#diff-aW50ZXJuYWwvanNvbnBvaW50ZXIvcmVzb2x2ZV9jdHguZ28=) | `91.42% <100.00%> (+0.12%)` | :arrow_up: |
| [openapi/parser/parse\_components.go](https://codecov.io/gh/ogen-go/ogen/pull/647/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%> (ø)` | |
| [openapi/parser/parse\_webhook.go](https://codecov.io/gh/ogen-go/ogen/pull/647/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/647/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/resolve.go](https://codecov.io/gh/ogen-go/ogen/pull/647/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go#diff-b3BlbmFwaS9wYXJzZXIvcmVzb2x2ZS5nbw==) | `89.26% <100.00%> (+0.12%)` | :arrow_up: |
| [gen/gen\_contents.go](https://codecov.io/gh/ogen-go/ogen/pull/647/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: |
| ... and [2 more](https://codecov.io/gh/ogen-go/ogen/pull/647/diff?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=ogen-go) | |

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/647?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/cmd/ogen/main.go b/cmd/ogen/main.go
index dd4d90a2..79493d58 100644
--- a/cmd/ogen/main.go
+++ b/cmd/ogen/main.go
@@ -5,7 +5,10 @@ import (
 	"flag"
 	"fmt"
 	"io"
+	"net/http"
+	"net/url"
 	"os"
+	"path"
 	"path/filepath"
 	"regexp"
 	"runtime"
@@ -46,12 +49,17 @@ func cleanDir(targetDir string, files []os.DirEntry) (rerr error) {
 	return rerr
 }
 
-func generate(data []byte, packageName, targetDir string, clean bool, opts gen.Options) error {
+func generate(f file, packageName, targetDir string, clean bool, opts gen.Options) error {
+	data := f.data
 	log := opts.Logger
 
 	spec, err := ogen.Parse(data)
 	if err != nil {
-		return errors.Wrap(err, "parse spec")
+		// For pretty error message, we need to pass location.File.
+		return &location.Error{
+			File: f.location(),
+			Err:  errors.Wrap(err, "parse spec"),
+		}
 	}
 
 	start := time.Now()
@@ -127,6 +135,84 @@ Also, you can use --ct-alias to map content types to supported ones.
 	return false
 }
 
+type file struct {
+	data     []byte
+	fileName string
+	source   string
+	rootURL  *url.URL
+}
+
+func (f file) location() location.File {
+	return location.NewFile(f.fileName, f.source, f.data)
+}
+
+func parseSpecPath(
+	p string,
+	client *http.Client,
+	readFile func(string) ([]byte, error),
+) (f file, opts gen.RemoteOptions, _ error) {
+	// FIXME(tdakkota): pass context.
+	if u, _ := url.Parse(p); u != nil {
+		switch u.Scheme {
+		case "http", "https":
+			_, fileName := path.Split(u.Path)
+
+			resp, err := client.Get(p)
+			if err != nil {
+				return f, opts, err
+			}
+			defer func() {
+				_ = resp.Body.Close()
+			}()
+
+			data, err := io.ReadAll(resp.Body)
+			if err != nil {
+				return f, opts, err
+			}
+
+			f = file{
+				data:     data,
+				fileName: fileName,
+				source:   p,
+				rootURL:  u,
+			}
+			opts = gen.RemoteOptions{
+				ReadFile: func(p string) ([]byte, error) {
+					return nil, errors.New("local files are not supported in remote mode")
+				},
+				HTTPClient: client,
+			}
+			return f, opts, nil
+		case "":
+		default:
+			if runtime.GOOS == "windows" && filepath.VolumeName(p) != "" {
+				break
+			}
+			return f, opts, errors.Errorf("unsupported scheme %q", u.Scheme)
+		}
+	}
+	p = filepath.Clean(p)
+	_, fileName := filepath.Split(p)
+
+	data, err := readFile(p)
+	if err != nil {
+		return f, opts, err
+	}
+
+	f = file{
+		data:     data,
+		fileName: fileName,
+		source:   p,
+		rootURL:  &url.URL{Path: filepath.ToSlash(p)},
+	}
+	opts = gen.RemoteOptions{
+		HTTPClient: client,
+		ReadFile:   readFile,
+	}
+
+	return f, opts, nil
+}
+
 func run() error {
 	set := flag.NewFlagSet(os.Args[0], flag.ExitOnError)
 	set.Usage = func() {
@@ -202,7 +288,6 @@ func run() error {
 		set.Usage()
 		return errors.New("no spec provided")
 	}
-	specPath = filepath.Clean(specPath)
 
 	logger, err := ogenzap.Create(logOptions)
 	if err != nil {
@@ -247,8 +332,11 @@ func run() error {
 		}()
 	}
 
-	specDir, fileName := filepath.Split(specPath)
-	data, err := os.ReadFile(specPath)
+	f, remoteOpts, err := parseSpecPath(
+		specPath,
+		&http.Client{Timeout: time.Minute},
+		os.ReadFile,
+	)
 	if err != nil {
 		return err
 	}
@@ -261,18 +349,15 @@ func run() error {
 		SkipUnimplemented:    *skipUnimplemented,
 		InferSchemaType:      *inferTypes,
 		AllowRemote:          *allowRemote,
-		Remote: gen.RemoteOptions{
-			ReadFile: func(p string) ([]byte, error) {
-				return os.ReadFile(filepath.Join(specDir, p))
-			},
-		},
+		RootURL:              f.rootURL,
+		Remote:               remoteOpts,
 		Filters: gen.Filters{
 			PathRegex: filterPath,
 			Methods:   filterMethods,
 		},
 		IgnoreNotImplemented: strings.Split(*debugIgnoreNotImplemented, ","),
 		ContentTypeAliases:   ctAliases,
-		File:                 location.NewFile(fileName, specPath, data),
+		File:                 f.location(),
 		Logger:               logger,
 	}
 	if expr := *skipTestsRegex; expr != "" {
@@ -291,7 +376,7 @@ func run() error {
 		}
 	}
 
-	if err := generate(data, *packageName, *targetDir, *clean, opts); err != nil {
+	if err := generate(f, *packageName, *targetDir, *clean, opts); err != nil {
 		if handleGenerateError(os.Stderr, logOptions.Color, err) {
 			return errors.New("generation failed")
 		}
diff --git a/gen/external.go b/gen/external.go
deleted file mode 100644
index 43141a9d..00000000
--- a/gen/external.go
+++ /dev/null
@@ -1,106 +0,0 @@
-package gen
-
-import (
-	"context"
-	"io"
-	"net/http"
-	"net/url"
-	"os"
-
-	"github.com/go-faster/errors"
-
-	"github.com/ogen-go/ogen/jsonschema"
-)
-
-// RemoteOptions is remote reference resolver options.
-type RemoteOptions struct {
-	// HTTPClient sets http client to use. Defaults to http.DefaultClient.
-	HTTPClient *http.Client
-	// ReadFile sets function for reading files from fs. Defaults to os.ReadFile.
-	ReadFile func(p string) ([]byte, error)
-}
-
-func (r *RemoteOptions) setDefaults() {
-	if r.HTTPClient == nil {
-		r.HTTPClient = http.DefaultClient
-	}
-	if r.ReadFile == nil {
-		r.ReadFile = os.ReadFile
-	}
-}
-
-var _ jsonschema.ExternalResolver = externalResolver{}
-
-type externalResolver struct {
-	client   *http.Client
-	readFile func(p string) ([]byte, error)
-}
-
-func newExternalResolver(opts RemoteOptions) externalResolver {
-	opts.setDefaults()
-
-	return externalResolver{
-		client:   opts.HTTPClient,
-		readFile: opts.ReadFile,
-	}
-}
-
-func (e externalResolver) httpGet(ctx context.Context, u *url.URL) ([]byte, error) {
-	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
-	if err != nil {
-		return nil, errors.Wrap(err, "create request")
-	}
-	if pass, ok := u.User.Password(); ok && u.User != nil {
-		req.SetBasicAuth(u.User.Username(), pass)
-	}
-
-	resp, err := e.client.Do(req)
-	if err != nil {
-		return nil, errors.Wrap(err, "do")
-	}
-	defer func() {
-		if resp.Body != nil {
-			_ = resp.Body.Close()
-		}
-	}()
-
-	if code := resp.StatusCode; code >= 299 {
-		text := http.StatusText(code)
-		return nil, errors.Errorf("bad HTTP code %d (%s)", code, text)
-	}
-
-	data, err := io.ReadAll(resp.Body)
-	if err != nil {
-		return nil, errors.Wrap(err, "read data")
-	}
-
-	return data, nil
-}
-
-func (e externalResolver) Get(ctx context.Context, loc string) ([]byte, error) {
-	u, err := url.Parse(loc)
-	if err != nil {
-		return nil, err
-	}
-
-	var (
-		data   []byte
-		scheme = u.Scheme
-	)
-	switch scheme {
-	case "http", "https":
-		data, err = e.httpGet(ctx, u)
-	case "file", "":
-		data, err = e.readFile(u.Path)
-	default:
-		return nil, errors.Errorf("unsupported scheme %q", scheme)
-	}
-	if err != nil {
-		if scheme == "" {
-			scheme = "file"
-		}
-		return nil, errors.Wrap(err, scheme)
-	}
-
-	return data, nil
-}
diff --git a/gen/generator.go b/gen/generator.go
index 19676eed..3224865c 100644
--- a/gen/generator.go
+++ b/gen/generator.go
@@ -37,11 +37,12 @@ func NewGenerator(spec *ogen.Spec, opts Options) (*Generator, error) {
 
 	var external jsonschema.ExternalResolver
 	if opts.AllowRemote {
-		external = newExternalResolver(opts.Remote)
+		external = jsonschema.NewExternalResolver(opts.Remote)
 	}
 	api, err := parser.Parse(spec, parser.Settings{
 		External:   external,
 		File:       opts.File,
+		RootURL:    opts.RootURL,
 		InferTypes: opts.InferSchemaType,
 	})
 	if err != nil {
diff --git a/gen/options.go b/gen/options.go
index 401bb670..ea5751e8 100644
--- a/gen/options.go
+++ b/gen/options.go
@@ -1,6 +1,7 @@
 package gen
 
 import (
+	"net/url"
 	"regexp"
 	"strings"
 
@@ -10,9 +11,13 @@ import (
 	"github.com/ogen-go/ogen/gen/ir"
 	"github.com/ogen-go/ogen/internal/location"
 	"github.com/ogen-go/ogen/internal/xslices"
+	"github.com/ogen-go/ogen/jsonschema"
 	"github.com/ogen-go/ogen/openapi"
 )
 
+// RemoteOptions is remote reference resolver options.
+type RemoteOptions = jsonschema.ExternalOptions
+
 // Options is Generator options.
 type Options struct {
 	// NoClient disables client generation.
@@ -33,6 +38,8 @@ type Options struct {
 	//
 	// See https://github.com/ogen-go/ogen/issues/385.
 	AllowRemote bool
+	// RootURL is root URL for remote references resolving.
+	RootURL *url.URL
 	// Remote is remote reference resolver options.
 	Remote RemoteOptions
 
diff --git a/internal/jsonpointer/resolve_ctx.go b/internal/jsonpointer/resolve_ctx.go
index de7e56b6..83fa30ab 100644
--- a/internal/jsonpointer/resolve_ctx.go
+++ b/internal/jsonpointer/resolve_ctx.go
@@ -43,6 +43,8 @@ type ResolveCtx struct {
 	//
 	// "#/definitions/SchemaProperty" should be resolved against "https://example.com/schema".
 	locstack []locstackItem
+	// root is root location.
+	root *url.URL
 	// Store references to detect infinite recursive references.
 	refs       map[RefKey]struct{}
 	depthLimit int
@@ -53,13 +55,14 @@ const DefaultDepthLimit = 1000
 
 // DefaultCtx creates new ResolveCtx with default depth limit.
 func DefaultCtx() *ResolveCtx {
-	return NewResolveCtx(DefaultDepthLimit)
+	return NewResolveCtx(nil, DefaultDepthLimit)
 }
 
 // NewResolveCtx creates new ResolveCtx.
-func NewResolveCtx(depthLimit int) *ResolveCtx {
+func NewResolveCtx(root *url.URL, depthLimit int) *ResolveCtx {
 	return &ResolveCtx{
 		locstack:   nil,
+		root:       root,
 		refs:       map[RefKey]struct{}{},
 		depthLimit: depthLimit,
 	}
@@ -68,8 +71,11 @@ func NewResolveCtx(depthLimit int) *ResolveCtx {
 // Key creates new reference key.
 func (r *ResolveCtx) Key(ref string) (key RefKey, _ error) {
 	parser := url.Parse
-	if loc := r.LastLoc(); loc != "" {
-		base, err := url.Parse(loc)
+	if r.root != nil {
+		parser = r.root.Parse
+	}
+	if s := r.locstack; len(s) > 0 {
+		base, err := url.Parse(s[len(s)-1].loc)
 		if err != nil {
 			return key, err
 		}
@@ -78,15 +84,19 @@ func (r *ResolveCtx) Key(ref string) (key RefKey, _ error) {
 			if err != nil {
 				return nil, err
 			}
-			u.Path = strings.TrimPrefix(u.Path, "/")
 			return u, nil
 		}
+	} else if strings.HasPrefix(ref, "#") {
+		return RefKey{
+			Ref: ref,
+		}, nil
 	}
 
 	u, err := parser(ref)
 	if err != nil {
 		return RefKey{}, err
 	}
+	u.Path = strings.TrimPrefix(u.Path, "/")
 	key.FromURL(u)
 	return key, nil
 }
@@ -120,13 +130,9 @@ func (r *ResolveCtx) Delete(key RefKey) {
 	}
 }
 
-// LastLoc returns last location from stack.
-func (r *ResolveCtx) LastLoc() string {
-	s := r.locstack
-	if len(s) == 0 {
-		return ""
-	}
-	return s[len(s)-1].loc
+// IsRoot returns true if location stack is empty.
+func (r *ResolveCtx) IsRoot() bool {
+	return len(r.locstack) == 0
 }
 
 // File returns last file from stack.
diff --git a/jsonschema/external.go b/jsonschema/external.go
index a7aab772..c400b0d9 100644
--- a/jsonschema/external.go
+++ b/jsonschema/external.go
@@ -2,6 +2,10 @@ package jsonschema
 
 import (
 	"context"
+	"io"
+	"net/http"
+	"net/url"
+	"os"
 
 	"github.com/go-faster/errors"
 )
@@ -20,3 +24,99 @@ type NoExternal struct{}
 func (n NoExternal) Get(context.Context, string) ([]byte, error) {
 	return nil, errors.New("external references are disabled")
 }
+
+// ExternalOptions is external reference resolver options.
+type ExternalOptions struct {
+	// HTTPClient sets http client to use. Defaults to http.DefaultClient.
+	HTTPClient *http.Client
+	// ReadFile sets function for reading files from fs. Defaults to os.ReadFile.
+	ReadFile func(p string) ([]byte, error)
+}
+
+func (r *ExternalOptions) setDefaults() {
+	if r.HTTPClient == nil {
+		r.HTTPClient = http.DefaultClient
+	}
+	if r.ReadFile == nil {
+		r.ReadFile = os.ReadFile
+	}
+}
+
+var _ ExternalResolver = externalResolver{}
+
+type externalResolver struct {
+	client   *http.Client
+	readFile func(p string) ([]byte, error)
+}
+
+// NewExternalResolver creates new ExternalResolver.
+//
+// Currently only http(s) and file schemes are supported.
+func NewExternalResolver(opts ExternalOptions) ExternalResolver {
+	opts.setDefaults()
+
+	return externalResolver{
+		client:   opts.HTTPClient,
+		readFile: opts.ReadFile,
+	}
+}
+
+func (e externalResolver) httpGet(ctx context.Context, u *url.URL) ([]byte, error) {
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil)
+	if err != nil {
+		return nil, errors.Wrap(err, "create request")
+	}
+	if pass, ok := u.User.Password(); ok && u.User != nil {
+		req.SetBasicAuth(u.User.Username(), pass)
+	}
+
+	resp, err := e.client.Do(req)
+	if err != nil {
+		return nil, errors.Wrap(err, "do")
+	}
+	defer func() {
+		if resp.Body != nil {
+			_ = resp.Body.Close()
+		}
+	}()
+
+	if code := resp.StatusCode; code >= 299 {
+		text := http.StatusText(code)
+		return nil, errors.Errorf("bad HTTP code %d (%s)", code, text)
+	}
+
+	data, err := io.ReadAll(resp.Body)
+	if err != nil {
+		return nil, errors.Wrap(err, "read data")
+	}
+
+	return data, nil
+}
+
+func (e externalResolver) Get(ctx context.Context, loc string) ([]byte, error) {
+	u, err := url.Parse(loc)
+	if err != nil {
+		return nil, err
+	}
+
+	var (
+		data   []byte
+		scheme = u.Scheme
+	)
+	switch scheme {
+	case "http", "https":
+		data, err = e.httpGet(ctx, u)
+	case "file", "":
+		data, err = e.readFile(u.Path)
+	default:
+		return nil, errors.Errorf("unsupported scheme %q", scheme)
+	}
+	if err != nil {
+		if scheme == "" {
+			scheme = "file"
+		}
+		return nil, errors.Wrap(err, scheme)
+	}
+
+	return data, nil
+}
diff --git a/jsonschema/resolve.go b/jsonschema/resolve.go
index 35f9e221..f116f566 100644
--- a/jsonschema/resolve.go
+++ b/jsonschema/resolve.go
@@ -28,7 +28,7 @@ func (p *Parser) getResolver(loc string) (r resolver, rerr error) {
 
 	raw, err := p.external.Get(context.TODO(), loc)
 	if err != nil {
-		return r, errors.Wrap(err, "get")
+		return r, errors.Wrapf(err, "get %q", loc)
 	}
 
 	file := location.NewFile(loc, loc, raw)
diff --git a/openapi/parser/parse_components.go b/openapi/parser/parse_components.go
index 7f57c9fd..a481cbe2 100644
--- a/openapi/parser/parse_components.go
+++ b/openapi/parser/parse_components.go
@@ -6,7 +6,6 @@ import (
 	"github.com/go-faster/errors"
 
 	"github.com/ogen-go/ogen"
-	"github.com/ogen-go/ogen/internal/jsonpointer"
 	"github.com/ogen-go/ogen/internal/location"
 	"github.com/ogen-go/ogen/jsonschema"
 	"github.com/ogen-go/ogen/openapi"
@@ -114,7 +113,7 @@ func (p *parser) parseComponents(c *ogen.Components) (_ *openapi.Components, rer
 
 	for name := range c.Schemas {
 		ref := "#/components/schemas/" + name
-		s, err := p.schemaParser.Resolve(ref, jsonpointer.NewResolveCtx(p.depthLimit))
+		s, err := p.schemaParser.Resolve(ref, p.resolveCtx())
 		if err != nil {
 			return nil, wrapErr("schemas", name, err)
 		}
@@ -124,7 +123,7 @@ func (p *parser) parseComponents(c *ogen.Components) (_ *openapi.Components, rer
 
 	for name := range c.Responses {
 		ref := "#/components/responses/" + name
-		r, err := p.resolveResponse(ref, jsonpointer.NewResolveCtx(p.depthLimit))
+		r, err := p.resolveResponse(ref, p.resolveCtx())
 		if err != nil {
 			return nil, wrapErr("responses", name, err)
 		}
@@ -134,7 +133,7 @@ func (p *parser) parseComponents(c *ogen.Components) (_ *openapi.Components, rer
 
 	for name := range c.Parameters {
 		ref := "#/components/parameters/" + name
-		pp, err := p.resolveParameter(ref, jsonpointer.NewResolveCtx(p.depthLimit))
+		pp, err := p.resolveParameter(ref, p.resolveCtx())
 		if err != nil {
 			return nil, wrapErr("parameters", name, err)
 		}
@@ -144,7 +143,7 @@ func (p *parser) parseComponents(c *ogen.Components) (_ *openapi.Components, rer
 
 	for name := range c.Examples {
 		ref := "#/components/examples/" + name
-		ex, err := p.resolveExample(ref, jsonpointer.NewResolveCtx(p.depthLimit))
+		ex, err := p.resolveExample(ref, p.resolveCtx())
 		if err != nil {
 			return nil, wrapErr("examples", name, err)
 		}
@@ -154,7 +153,7 @@ func (p *parser) parseComponents(c *ogen.Components) (_ *openapi.Components, rer
 
 	for name := range c.RequestBodies {
 		ref := "#/components/requestBodies/" + name
-		b, err := p.resolveRequestBody(ref, jsonpointer.NewResolveCtx(p.depthLimit))
+		b, err := p.resolveRequestBody(ref, p.resolveCtx())
 		if err != nil {
 			return nil, wrapErr("requestBodies", name, err)
 		}
diff --git a/openapi/parser/parse_webhook.go b/openapi/parser/parse_webhook.go
index e38278c0..102d481e 100644
--- a/openapi/parser/parse_webhook.go
+++ b/openapi/parser/parse_webhook.go
@@ -28,7 +28,7 @@ func (p *parser) parseWebhooks(webhooks map[string]*ogen.PathItem) (r []openapi.
 	}
 	var (
 		locator = p.rootLoc.Field("webhooks")
-		ctx     = jsonpointer.NewResolveCtx(p.depthLimit)
+		ctx     = p.resolveCtx()
 	)
 	defer func() {
 		rerr = p.wrapLocation(ctx.File(), locator, rerr)
diff --git a/openapi/parser/parser.go b/openapi/parser/parser.go
index fd784252..5bfb1eb6 100644
--- a/openapi/parser/parser.go
+++ b/openapi/parser/parser.go
@@ -2,6 +2,8 @@
 package parser
 
 import (
+	"net/url"
+
 	"github.com/go-faster/errors"
 	"golang.org/x/exp/maps"
 
@@ -47,6 +49,7 @@ type parser struct {
 	operationIDs map[string]struct{}
 
 	external   jsonschema.ExternalResolver
+	rootURL    *url.URL
 	schemas    map[string]resolver
 	depthLimit int
 	file       location.File // optional, used for error messages
@@ -63,9 +66,7 @@ func Parse(spec *ogen.Spec, s Settings) (_ *openapi.API, rerr error) {
 
 	s.setDefaults()
 	p := &parser{
-		spec:         spec,
-		operations:   nil,
-		operationIDs: map[string]struct{}{},
+		spec: spec,
 		refs: struct {
 			requestBodies   map[refKey]*openapi.RequestBody
 			responses       map[refKey]*openapi.Response
@@ -84,7 +85,9 @@ func Parse(spec *ogen.Spec, s Settings) (_ *openapi.API, rerr error) {
 			pathItems:       map[refKey]pathItem{},
 		},
 		securitySchemes: maps.Clone(spec.Components.SecuritySchemes),
+		operationIDs:    map[string]struct{}{},
 		external:        s.External,
+		rootURL:         s.RootURL,
 		schemas: map[string]resolver{
 			"": {
 				node: spec.Raw,
@@ -125,7 +128,7 @@ func Parse(spec *ogen.Spec, s Settings) (_ *openapi.API, rerr error) {
 		return nil, errors.Wrap(err, "parse path items")
 	}
 
-	servers, err := p.parseServers(p.spec.Servers, jsonpointer.NewResolveCtx(p.depthLimit))
+	servers, err := p.parseServers(p.spec.Servers, p.resolveCtx())
 	if err != nil {
 		return nil, errors.Wrap(err, "parse servers")
 	}
@@ -175,7 +178,7 @@ func (p *parser) parsePathItems() error {
 			return p.wrapLocation(p.file, pathsLoc.Key(path), err)
 		}
 
-		ops, err := p.parsePathItem(path, item, jsonpointer.NewResolveCtx(p.depthLimit))
+		ops, err := p.parsePathItem(path, item, p.resolveCtx())
 		if err != nil {
 			err := errors.Wrapf(err, "path %q", path)
 			return p.wrapLocation(p.file, pathsLoc.Field(path), err)
diff --git a/openapi/parser/resolve.go b/openapi/parser/resolve.go
index 396a55ad..552e834f 100644
--- a/openapi/parser/resolve.go
+++ b/openapi/parser/resolve.go
@@ -26,7 +26,7 @@ func (p *parser) getResolver(loc string) (r resolver, rerr error) {
 
 	raw, err := p.external.Get(context.TODO(), loc)
 	if err != nil {
-		return r, errors.Wrap(err, "get")
+		return r, errors.Wrapf(err, "get %q", loc)
 	}
 
 	file := location.NewFile(loc, loc, raw)
@@ -93,7 +93,7 @@ func resolveComponent[Raw, Target any](
 
 	file := p.file
 	var raw Raw
-	if key.Loc == "" && ctx.LastLoc() == "" {
+	if key.Loc == "" && ctx.IsRoot() {
 		name := strings.TrimPrefix(ref, cr.prefix)
 		c, found := cr.components[name]
 		if found {
@@ -130,6 +130,10 @@ func resolveComponent[Raw, Target any](
 	return r, false, nil
 }
 
+func (p *parser) resolveCtx() *jsonpointer.ResolveCtx {
+	return jsonpointer.NewResolveCtx(p.rootURL, p.depthLimit)
+}
+
 func (p *parser) resolveRequestBody(ref string, ctx *jsonpointer.ResolveCtx) (*openapi.RequestBody, error) {
 	const prefix = "#/components/requestBodies/"
 	c, cached, err := resolveComponent(p, componentResolve[*ogen.RequestBody, *openapi.RequestBody]{
diff --git a/openapi/parser/settings.go b/openapi/parser/settings.go
index 719a103b..0bf606f0 100644
--- a/openapi/parser/settings.go
+++ b/openapi/parser/settings.go
@@ -1,6 +1,8 @@
 package parser
 
 import (
+	"net/url"
+
 	"github.com/ogen-go/ogen/internal/jsonpointer"
 	"github.com/ogen-go/ogen/internal/location"
 	"github.com/ogen-go/ogen/jsonschema"
@@ -16,6 +18,9 @@ type Settings struct {
 	// Used for error messages.
 	File location.File
 
+	// RootURL is the root URL of the spec.
+	RootURL *url.URL
+
 	// DepthLimit limits the number of nested references. Default is 1000.
 	DepthLimit int
 


Test patch:
diff --git a/cmd/ogen/main_test.go b/cmd/ogen/main_test.go
new file mode 100644
index 00000000..a8390cbe
--- /dev/null
+++ b/cmd/ogen/main_test.go
@@ -0,0 +1,91 @@
+package main
+
+import (
+	"bytes"
+	"fmt"
+	"io"
+	"net/http"
+	"net/url"
+	"runtime"
+	"testing"
+
+	"github.com/go-faster/errors"
+	"github.com/stretchr/testify/require"
+)
+
+type roundTripFunc func(req *http.Request) (*http.Response, error)
+
+func (r roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
+	return r(req)
+}
+
+func Test_parseSpecPath(t *testing.T) {
+	testdata := []byte(`{}`)
+	urlPath := func(p string) *url.URL {
+		return &url.URL{Path: p}
+	}
+	urlParse := func(s string) *url.URL {
+		u, err := url.Parse(s)
+		require.NoError(t, err)
+		return u
+	}
+
+	type testCase struct {
+		input        string
+		httpData     []byte
+		fileData     []byte
+		wantFilename string
+		wantURL      *url.URL
+	}
+
+	tests := []testCase{
+		{"spec.json", nil, testdata, "spec.json", urlPath("spec.json")},
+		{"./spec.json", nil, testdata, "spec.json", urlPath("spec.json")},
+		{"_testdata/spec.json", nil, testdata, "spec.json", urlPath("_testdata/spec.json")},
+
+		{"http://example.com/spec.json", testdata, nil, "spec.json", urlParse("http://example.com/spec.json")},
+	}
+	if runtime.GOOS == "windows" {
+		tests = append(tests, []testCase{
+			{`_testdata\spec.json`, nil, testdata, "spec.json", urlPath("_testdata/spec.json")},
+			{`C:\_testdata\spec.json`, nil, testdata, "spec.json", urlPath("C:/_testdata/spec.json")},
+		}...)
+	}
+	for i, tt := range tests {
+		tt := tt
+		t.Run(fmt.Sprintf("Test%d", i+1), func(t *testing.T) {
+			a := require.New(t)
+
+			var (
+				data     = tt.httpData
+				readFile = func(filename string) ([]byte, error) {
+					return nil, errors.Errorf("unexpected read file: %q", filename)
+				}
+				httpClient = &http.Client{
+					Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+						return &http.Response{
+							StatusCode: http.StatusOK,
+							Body:       io.NopCloser(bytes.NewReader(data)),
+						}, nil
+					}),
+				}
+			)
+			if data == nil {
+				data = tt.fileData
+				readFile = func(filename string) ([]byte, error) {
+					return data, nil
+				}
+				httpClient = &http.Client{
+					Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
+						return nil, errors.Errorf("unexpected http request: %q", req.URL)
+					}),
+				}
+			}
+
+			f, _, err := parseSpecPath(tt.input, httpClient, readFile)
+			a.NoError(err)
+			a.Equal(tt.wantFilename, f.fileName)
+			a.Equal(tt.wantURL, f.rootURL)
+		})
+	}
+}
diff --git a/jsonschema/resolve_test.go b/jsonschema/resolve_test.go
index cce87a12..f03942e1 100644
--- a/jsonschema/resolve_test.go
+++ b/jsonschema/resolve_test.go
@@ -175,7 +175,7 @@ func TestLimitDepth(t *testing.T) {
 		parser := NewParser(Settings{
 			Resolver: root,
 		})
-		_, err := parser.Resolve("#/components/schemas/Schema1", jsonpointer.NewResolveCtx(tt.limit))
+		_, err := parser.Resolve("#/components/schemas/Schema1", jsonpointer.NewResolveCtx(nil, tt.limit))
 		tt.checker(t, err, "limit: %d", tt.limit)
 	}
 }
diff --git a/openapi/parser/_testdata/remotes/api/response.json b/openapi/parser/_testdata/remotes/api/response.json
new file mode 100644
index 00000000..0586c044
--- /dev/null
+++ b/openapi/parser/_testdata/remotes/api/response.json
@@ -0,0 +1,10 @@
+{
+  "description": "User info",
+  "content": {
+    "application/json": {
+      "schema": {
+        "$ref": "../schemas.json#/definitions/User"
+      }
+    }
+  }
+}
diff --git a/openapi/parser/_testdata/remotes/api/spec.json b/openapi/parser/_testdata/remotes/api/spec.json
new file mode 100644
index 00000000..e7e15091
--- /dev/null
+++ b/openapi/parser/_testdata/remotes/api/spec.json
@@ -0,0 +1,18 @@
+{
+  "openapi": "3.0.3",
+  "info": {
+    "title": "title",
+    "version": "v0.1.0"
+  },
+  "paths": {
+    "/get": {
+      "get": {
+        "responses": {
+          "200": {
+            "$ref": "response.json#"
+          }
+        }
+      }
+    }
+  }
+}
diff --git a/openapi/parser/_testdata/remotes/schemas.json b/openapi/parser/_testdata/remotes/schemas.json
new file mode 100644
index 00000000..47744d83
--- /dev/null
+++ b/openapi/parser/_testdata/remotes/schemas.json
@@ -0,0 +1,17 @@
+{
+  "definitions": {
+    "User": {
+      "$schema": "http://json-schema.org/draft-04/schema#",
+      "type": "object",
+      "properties": {
+        "name": {
+          "type": "string"
+        },
+        "age": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    }
+  }
+}
diff --git a/openapi/parser/resolve_external_test.go b/openapi/parser/resolve_external_test.go
index b5eef13b..d6e12a71 100644
--- a/openapi/parser/resolve_external_test.go
+++ b/openapi/parser/resolve_external_test.go
@@ -2,10 +2,18 @@ package parser
 
 import (
 	"context"
+	"embed"
 	"encoding/json"
+	"io"
+	"io/fs"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"path"
 	"testing"
 
 	"github.com/go-faster/errors"
+	yaml "github.com/go-faster/yamlx"
 	"github.com/stretchr/testify/require"
 
 	"github.com/ogen-go/ogen"
@@ -359,21 +367,10 @@ func TestDuplicateOperationID(t *testing.T) {
 }
 
 // Ensure that parser adds location information to the error, even if the error is occurred in the external file.
-func TestLocationError(t *testing.T) {
+func TestExternalErrors(t *testing.T) {
 	root := &ogen.Spec{
 		OpenAPI: "3.0.3",
 		Paths: map[string]*ogen.PathItem{
-			"/get": {
-				Get: &ogen.Operation{
-					OperationID: "testGet",
-					Description: "local",
-					Responses: map[string]*ogen.Response{
-						"200": {
-							Description: "response description",
-						},
-					},
-				},
-			},
 			"/pathItem": {
 				Ref: "pathItem.json#",
 			},
@@ -383,12 +380,8 @@ func TestLocationError(t *testing.T) {
 	remote := external{
 		"pathItem.json": &ogen.PathItem{
 			Get: &ogen.Operation{
-				OperationID: "testGet",
-				Description: "remote",
 				Responses: map[string]*ogen.Response{
-					"200": {
-						Description: "response description",
-					},
+					"very bad status code": {},
 				},
 			},
 		},
@@ -398,41 +391,103 @@ func TestLocationError(t *testing.T) {
 	_, err := Parse(root, Settings{
 		External: remote,
 	})
-	a.ErrorContains(err, "duplicate operationId: \"testGet\"")
-	// Ensure that the error contains the file name.
+	a.ErrorContains(err, "parse status code")
+
 	var locErr *location.Error
 	a.ErrorAs(err, &locErr)
 	a.Equal("pathItem.json", locErr.File.Name)
+	a.True(locErr.PrettyPrint(io.Discard, false))
 }
 
-// Ensure that parser adds location information to the error, even if the error is occurred in the external file.
-func TestExternalErrors(t *testing.T) {
-	root := &ogen.Spec{
-		OpenAPI: "3.0.3",
-		Paths: map[string]*ogen.PathItem{
-			"/pathItem": {
-				Ref: "pathItem.json#",
-			},
-		},
-		Components: &ogen.Components{},
-	}
-	remote := external{
-		"pathItem.json": &ogen.PathItem{
-			Get: &ogen.Operation{
-				Responses: map[string]*ogen.Response{
-					"very bad status code": {},
-				},
-			},
-		},
+//go:embed _testdata/remotes
+var remotes embed.FS
+
+// TestInitialLocation ensures that the parser respects the RootURL setting.
+func TestInitialLocation(t *testing.T) {
+	const (
+		rootSpec   = "api/spec.json"
+		rootPrefix = "_testdata/remotes"
+	)
+
+	raw, spec := func() ([]byte, *ogen.Spec) {
+		raw, err := fs.ReadFile(remotes, path.Join(rootPrefix, rootSpec))
+		require.NoError(t, err)
+
+		var spec ogen.Spec
+		require.NoError(t, yaml.Unmarshal(raw, &spec))
+		return raw, &spec
+	}()
+
+	check := func(a *require.Assertions, parsed *openapi.API) {
+		ops := parsed.Operations
+		a.Len(ops, 1)
+		op := ops[0]
+		a.Equal("/get", op.Path.String())
+
+		resp, ok := op.Responses["200"]
+		a.True(ok)
+		content, ok := resp.Content["application/json"]
+		a.True(ok)
+
+		s := content.Schema
+		a.Equal(jsonschema.Object, s.Type)
+		a.Len(s.Properties, 2)
+		propAge := s.Properties[1]
+		a.Equal("age", propAge.Name)
+		a.Equal(jsonschema.Integer, propAge.Schema.Type)
 	}
 
-	a := require.New(t)
-	_, err := Parse(root, Settings{
-		External: remote,
+	t.Run("HTTP", func(t *testing.T) {
+		a := require.New(t)
+
+		remotes, err := fs.Sub(remotes, rootPrefix)
+		a.NoError(err)
+
+		h := http.FileServer(http.FS(remotes))
+		srv := httptest.NewServer(h)
+		defer srv.Close()
+
+		rootURL, err := url.Parse(srv.URL)
+		a.NoError(err)
+		rootURL.Path = path.Join(rootURL.Path, rootSpec)
+
+		parsed, err := Parse(spec, Settings{
+			External: jsonschema.NewExternalResolver(jsonschema.ExternalOptions{
+				HTTPClient: srv.Client(),
+				ReadFile: func(p string) ([]byte, error) {
+					return nil, errors.Errorf("unexpected call: %q", p)
+				},
+			}),
+			File:    location.NewFile(rootSpec, rootURL.String(), raw),
+			RootURL: rootURL,
+		})
+		a.NoError(err)
+		check(a, parsed)
 	})
-	a.ErrorContains(err, "parse status code")
+	t.Run("File", func(t *testing.T) {
+		a := require.New(t)
 
-	var locErr *location.Error
-	a.ErrorAs(err, &locErr)
-	a.Equal("pathItem.json", locErr.File.Name)
+		rootURL := &url.URL{
+			Path: path.Join(rootPrefix, rootSpec),
+		}
+
+		var called []string
+		parsed, err := Parse(spec, Settings{
+			External: jsonschema.NewExternalResolver(jsonschema.ExternalOptions{
+				ReadFile: func(p string) ([]byte, error) {
+					called = append(called, p)
+					return fs.ReadFile(remotes, p)
+				},
+			}),
+			File:    location.NewFile(rootSpec, rootURL.String(), raw),
+			RootURL: rootURL,
+		})
+		a.NoError(err)
+		a.Equal([]string{
+			"_testdata/remotes/api/response.json",
+			"_testdata/remotes/schemas.json",
+		}, called)
+
+		check(a, parsed)
+	})
 }