diff --git a/_testdata/negative/cyclic/example.json b/_testdata/negative/cyclic/example.json new file mode 100644 index 000000000..8df5d255b --- /dev/null +++ b/_testdata/negative/cyclic/example.json @@ -0,0 +1,39 @@ +{ + "openapi": "3.0.3", + "info": { + "title": "title", + "version": "v0.1.0" + }, + "paths": { + "/foo": { + "get": { + "parameters": [ + { + "name": "param", + "in": "query", + "schema": { + "type": "string" + }, + "examples": { + "ParameterExample": { + "$ref": "#/components/examples/Example" + } + } + } + ], + "responses": { + "200": { + "description": "User info" + } + } + } + } + }, + "components": { + "examples": { + "Example": { + "$ref": "#/components/examples/Example" + } + } + } +} diff --git a/_testdata/negative/cyclic/pathItem.json b/_testdata/negative/cyclic/pathItem.json new file mode 100644 index 000000000..edff80593 --- /dev/null +++ b/_testdata/negative/cyclic/pathItem.json @@ -0,0 +1,19 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "title", + "version": "v0.1.0" + }, + "paths": { + "/foo": { + "$ref": "#/components/pathItems/PathItem" + } + }, + "components": { + "pathItems": { + "PathItem": { + "$ref": "#/components/pathItems/PathItem" + } + } + } +} diff --git a/_testdata/negative/null/components_pathItem.json b/_testdata/negative/null/components_pathItem.json new file mode 100644 index 000000000..43aecc74a --- /dev/null +++ b/_testdata/negative/null/components_pathItem.json @@ -0,0 +1,17 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "title", + "version": "v0.1.0" + }, + "paths": { + "/foo": { + "$ref": "#/components/pathItems/PathItem" + } + }, + "components": { + "pathItems": { + "PathItem": null + } + } +} diff --git a/_testdata/positive/referenced_pathItem.json b/_testdata/positive/referenced_pathItem.json new file mode 100644 index 000000000..d7d0d35aa --- /dev/null +++ b/_testdata/positive/referenced_pathItem.json @@ -0,0 +1,32 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "title", + "version": "v0.1.0" + }, + "paths": { + "/foo": { + "$ref": "#/components/pathItems/Foo" + } + }, + "components": { + "pathItems": { + "Foo": { + "get": { + "responses": { + "200": { + "description": "Response", + "content": { + "application/json": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + } + } +} diff --git a/internal/generate.go b/internal/generate.go index 041267da4..653c1ffc4 100644 --- a/internal/generate.go +++ b/internal/generate.go @@ -17,4 +17,6 @@ package internal //go:generate go run ../cmd/ogen -v --clean --target test_http_requests ../_testdata/positive/http_requests.json //go:generate go run ../cmd/ogen -v --clean --target test_form ../_testdata/positive/form.json // +//go:generate go run ../cmd/ogen -v --clean --target referenced_path_item ../_testdata/positive/referenced_pathItem.json +// //go:generate go run ../cmd/ogen -v --clean --generate-tests --target test_allof ../_testdata/positive/allof.yml diff --git a/internal/jsonpointer/jsonpointer.go b/internal/jsonpointer/jsonpointer.go index 2a9e510c1..c045e76ec 100644 --- a/internal/jsonpointer/jsonpointer.go +++ b/internal/jsonpointer/jsonpointer.go @@ -13,6 +13,9 @@ import ( // Resolve takes given pointer and returns byte slice of requested value if any. // If value not found, returns NotFoundError. func Resolve(ptr string, node *yaml.Node) (*yaml.Node, error) { + if node == nil { + return nil, errors.New("root is nil") + } if node.Kind == yaml.DocumentNode && len(node.Content) > 0 { node = node.Content[0] } diff --git a/internal/jsonpointer/jsonpointer_test.go b/internal/jsonpointer/jsonpointer_test.go index f9cc03ca7..7139af2fb 100644 --- a/internal/jsonpointer/jsonpointer_test.go +++ b/internal/jsonpointer/jsonpointer_test.go @@ -105,6 +105,15 @@ func TestSpecification(t *testing.T) { } } +func TestResolveNilNode(t *testing.T) { + a := require.New(t) + var err error + a.NotPanics(func() { + _, err = Resolve("", nil) + }) + a.EqualError(err, "root is nil") +} + func BenchmarkResolve(b *testing.B) { var specExample = getNode(b, []byte(`{ "openapi": "3.0.3", diff --git a/internal/referenced_path_item/oas_cfg_gen.go b/internal/referenced_path_item/oas_cfg_gen.go new file mode 100644 index 000000000..8a1dce12d --- /dev/null +++ b/internal/referenced_path_item/oas_cfg_gen.go @@ -0,0 +1,143 @@ +// Code generated by ogen, DO NOT EDIT. + +package api + +import ( + "context" + "net/http" + + "github.com/go-faster/errors" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" + + ht "github.com/ogen-go/ogen/http" + "github.com/ogen-go/ogen/json" + "github.com/ogen-go/ogen/ogenerrors" + "github.com/ogen-go/ogen/otelogen" +) + +// ErrorHandler is error handler. +type ErrorHandler func(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) + +func respondError(ctx context.Context, w http.ResponseWriter, r *http.Request, err error) { + var ( + code = http.StatusInternalServerError + ogenErr ogenerrors.Error + ) + switch { + case errors.Is(err, ht.ErrNotImplemented): + code = http.StatusNotImplemented + case errors.As(err, &ogenErr): + code = ogenErr.Code() + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + data, writeErr := json.Marshal(struct { + ErrorMessage string `json:"error_message"` + }{ + ErrorMessage: err.Error(), + }) + if writeErr == nil { + w.Write(data) + } +} + +type config struct { + TracerProvider trace.TracerProvider + Tracer trace.Tracer + MeterProvider metric.MeterProvider + Meter metric.Meter + Client ht.Client + NotFound http.HandlerFunc + ErrorHandler ErrorHandler + MaxMultipartMemory int64 +} + +func newConfig(opts ...Option) config { + cfg := config{ + TracerProvider: otel.GetTracerProvider(), + MeterProvider: metric.NewNoopMeterProvider(), + Client: http.DefaultClient, + NotFound: http.NotFound, + ErrorHandler: respondError, + MaxMultipartMemory: 32 << 20, // 32 MB + } + for _, opt := range opts { + opt.apply(&cfg) + } + cfg.Tracer = cfg.TracerProvider.Tracer(otelogen.Name, + trace.WithInstrumentationVersion(otelogen.SemVersion()), + ) + cfg.Meter = cfg.MeterProvider.Meter(otelogen.Name) + return cfg +} + +type Option interface { + apply(*config) +} + +type optionFunc func(*config) + +func (o optionFunc) apply(c *config) { + o(c) +} + +// WithTracerProvider specifies a tracer provider to use for creating a tracer. +// +// If none is specified, the global provider is used. +func WithTracerProvider(provider trace.TracerProvider) Option { + return optionFunc(func(cfg *config) { + if provider != nil { + cfg.TracerProvider = provider + } + }) +} + +// WithMeterProvider specifies a meter provider to use for creating a meter. +// +// If none is specified, the metric.NewNoopMeterProvider is used. +func WithMeterProvider(provider metric.MeterProvider) Option { + return optionFunc(func(cfg *config) { + if provider != nil { + cfg.MeterProvider = provider + } + }) +} + +// WithClient specifies http client to use. +func WithClient(client ht.Client) Option { + return optionFunc(func(cfg *config) { + if client != nil { + cfg.Client = client + } + }) +} + +// WithNotFound specifies Not Found handler to use. +func WithNotFound(notFound http.HandlerFunc) Option { + return optionFunc(func(cfg *config) { + if notFound != nil { + cfg.NotFound = notFound + } + }) +} + +// WithErrorHandler specifies error handler to use. +func WithErrorHandler(h ErrorHandler) Option { + return optionFunc(func(cfg *config) { + if h != nil { + cfg.ErrorHandler = h + } + }) +} + +// WithMaxMultipartMemory specifies limit of memory for storing file parts. +// File parts which can't be stored in memory will be stored on disk in temporary files. +func WithMaxMultipartMemory(max int64) Option { + return optionFunc(func(cfg *config) { + if max > 0 { + cfg.MaxMultipartMemory = max + } + }) +} diff --git a/internal/referenced_path_item/oas_client_gen.go b/internal/referenced_path_item/oas_client_gen.go new file mode 100644 index 000000000..96c60a36e --- /dev/null +++ b/internal/referenced_path_item/oas_client_gen.go @@ -0,0 +1,89 @@ +// Code generated by ogen, DO NOT EDIT. + +package api + +import ( + "context" + "net/url" + "time" + + "github.com/go-faster/errors" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric/instrument/syncint64" + "go.opentelemetry.io/otel/trace" + + ht "github.com/ogen-go/ogen/http" + "github.com/ogen-go/ogen/otelogen" + "github.com/ogen-go/ogen/uri" +) + +// Client implements OAS client. +type Client struct { + serverURL *url.URL + cfg config + requests syncint64.Counter + errors syncint64.Counter + duration syncint64.Histogram +} + +// NewClient initializes new Client defined by OAS. +func NewClient(serverURL string, opts ...Option) (*Client, error) { + u, err := url.Parse(serverURL) + if err != nil { + return nil, err + } + c := &Client{ + cfg: newConfig(opts...), + serverURL: u, + } + if c.requests, err = c.cfg.Meter.SyncInt64().Counter(otelogen.ClientRequestCount); err != nil { + return nil, err + } + if c.errors, err = c.cfg.Meter.SyncInt64().Counter(otelogen.ClientErrorsCount); err != nil { + return nil, err + } + if c.duration, err = c.cfg.Meter.SyncInt64().Histogram(otelogen.ClientDuration); err != nil { + return nil, err + } + return c, nil +} + +// FooGet invokes GET /foo operation. +// +// GET /foo +func (c *Client) FooGet(ctx context.Context) (res string, err error) { + startTime := time.Now() + otelAttrs := []attribute.KeyValue{} + ctx, span := c.cfg.Tracer.Start(ctx, "FooGet", + trace.WithAttributes(otelAttrs...), + trace.WithSpanKind(trace.SpanKindClient), + ) + defer func() { + if err != nil { + span.RecordError(err) + c.errors.Add(ctx, 1, otelAttrs...) + } else { + elapsedDuration := time.Since(startTime) + c.duration.Record(ctx, elapsedDuration.Microseconds(), otelAttrs...) + } + span.End() + }() + c.requests.Add(ctx, 1, otelAttrs...) + u := uri.Clone(c.serverURL) + u.Path += "/foo" + + r := ht.NewRequest(ctx, "GET", u, nil) + + resp, err := c.cfg.Client.Do(r) + if err != nil { + return res, errors.Wrap(err, "do request") + } + defer resp.Body.Close() + + result, err := decodeFooGetResponse(resp, span) + if err != nil { + return res, errors.Wrap(err, "decode response") + } + + return result, nil +} diff --git a/internal/referenced_path_item/oas_defaults_gen.go b/internal/referenced_path_item/oas_defaults_gen.go new file mode 100644 index 000000000..ae379a2db --- /dev/null +++ b/internal/referenced_path_item/oas_defaults_gen.go @@ -0,0 +1,3 @@ +// Code generated by ogen, DO NOT EDIT. + +package api diff --git a/internal/referenced_path_item/oas_handlers_gen.go b/internal/referenced_path_item/oas_handlers_gen.go new file mode 100644 index 000000000..409aa7253 --- /dev/null +++ b/internal/referenced_path_item/oas_handlers_gen.go @@ -0,0 +1,61 @@ +// Code generated by ogen, DO NOT EDIT. + +package api + +import ( + "context" + "net/http" + "time" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +// HandleFooGetRequest handles GET /foo operation. +// +// GET /foo +func (s *Server) handleFooGetRequest(args [0]string, w http.ResponseWriter, r *http.Request) { + startTime := time.Now() + otelAttrs := []attribute.KeyValue{} + ctx, span := s.cfg.Tracer.Start(r.Context(), "FooGet", + trace.WithAttributes(otelAttrs...), + trace.WithSpanKind(trace.SpanKindServer), + ) + s.requests.Add(ctx, 1, otelAttrs...) + defer span.End() + + var err error + + response, err := s.h.FooGet(ctx) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "Internal") + s.errors.Add(ctx, 1, otelAttrs...) + s.cfg.ErrorHandler(ctx, w, r, err) + return + } + + if err := encodeFooGetResponse(response, w, span); err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, "Response") + s.errors.Add(ctx, 1, otelAttrs...) + return + } + elapsedDuration := time.Since(startTime) + s.duration.Record(ctx, elapsedDuration.Microseconds(), otelAttrs...) +} + +func (s *Server) badRequest( + ctx context.Context, + w http.ResponseWriter, + r *http.Request, + span trace.Span, + otelAttrs []attribute.KeyValue, + err error, +) { + span.RecordError(err) + span.SetStatus(codes.Error, "BadRequest") + s.errors.Add(ctx, 1, otelAttrs...) + s.cfg.ErrorHandler(ctx, w, r, err) +} diff --git a/internal/referenced_path_item/oas_interfaces_gen.go b/internal/referenced_path_item/oas_interfaces_gen.go new file mode 100644 index 000000000..114f4d6a1 --- /dev/null +++ b/internal/referenced_path_item/oas_interfaces_gen.go @@ -0,0 +1,2 @@ +// Code generated by ogen, DO NOT EDIT. +package api diff --git a/internal/referenced_path_item/oas_json_gen.go b/internal/referenced_path_item/oas_json_gen.go new file mode 100644 index 000000000..ae379a2db --- /dev/null +++ b/internal/referenced_path_item/oas_json_gen.go @@ -0,0 +1,3 @@ +// Code generated by ogen, DO NOT EDIT. + +package api diff --git a/internal/referenced_path_item/oas_parameters_gen.go b/internal/referenced_path_item/oas_parameters_gen.go new file mode 100644 index 000000000..ae379a2db --- /dev/null +++ b/internal/referenced_path_item/oas_parameters_gen.go @@ -0,0 +1,3 @@ +// Code generated by ogen, DO NOT EDIT. + +package api diff --git a/internal/referenced_path_item/oas_request_decoders_gen.go b/internal/referenced_path_item/oas_request_decoders_gen.go new file mode 100644 index 000000000..ae379a2db --- /dev/null +++ b/internal/referenced_path_item/oas_request_decoders_gen.go @@ -0,0 +1,3 @@ +// Code generated by ogen, DO NOT EDIT. + +package api diff --git a/internal/referenced_path_item/oas_request_encoders_gen.go b/internal/referenced_path_item/oas_request_encoders_gen.go new file mode 100644 index 000000000..ae379a2db --- /dev/null +++ b/internal/referenced_path_item/oas_request_encoders_gen.go @@ -0,0 +1,3 @@ +// Code generated by ogen, DO NOT EDIT. + +package api diff --git a/internal/referenced_path_item/oas_response_decoders_gen.go b/internal/referenced_path_item/oas_response_decoders_gen.go new file mode 100644 index 000000000..6c907c9c6 --- /dev/null +++ b/internal/referenced_path_item/oas_response_decoders_gen.go @@ -0,0 +1,50 @@ +// Code generated by ogen, DO NOT EDIT. + +package api + +import ( + "io" + "mime" + "net/http" + + "github.com/go-faster/errors" + "github.com/go-faster/jx" + "go.opentelemetry.io/otel/trace" + + "github.com/ogen-go/ogen/validate" +) + +func decodeFooGetResponse(resp *http.Response, span trace.Span) (res string, err error) { + switch resp.StatusCode { + case 200: + // Code 200. + ct, _, err := mime.ParseMediaType(resp.Header.Get("Content-Type")) + if err != nil { + return res, errors.Wrap(err, "parse media type") + } + switch { + case ct == "application/json": + b, err := io.ReadAll(resp.Body) + if err != nil { + return res, err + } + + d := jx.DecodeBytes(b) + var response string + if err := func() error { + v, err := d.Str() + response = string(v) + if err != nil { + return err + } + return nil + }(); err != nil { + return res, err + } + return response, nil + default: + return res, validate.InvalidContentType(ct) + } + } + return res, validate.UnexpectedStatusCode(resp.StatusCode) +} diff --git a/internal/referenced_path_item/oas_response_encoders_gen.go b/internal/referenced_path_item/oas_response_encoders_gen.go new file mode 100644 index 000000000..ee95a0c37 --- /dev/null +++ b/internal/referenced_path_item/oas_response_encoders_gen.go @@ -0,0 +1,26 @@ +// Code generated by ogen, DO NOT EDIT. + +package api + +import ( + "net/http" + + "github.com/go-faster/errors" + "github.com/go-faster/jx" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" +) + +func encodeFooGetResponse(response string, w http.ResponseWriter, span trace.Span) error { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + span.SetStatus(codes.Ok, http.StatusText(200)) + e := jx.GetEncoder() + + e.Str(response) + if _, err := e.WriteTo(w); err != nil { + return errors.Wrap(err, "write") + } + return nil + +} diff --git a/internal/referenced_path_item/oas_router_gen.go b/internal/referenced_path_item/oas_router_gen.go new file mode 100644 index 000000000..3ca930694 --- /dev/null +++ b/internal/referenced_path_item/oas_router_gen.go @@ -0,0 +1,98 @@ +// Code generated by ogen, DO NOT EDIT. + +package api + +import ( + "net/http" +) + +func (s *Server) notFound(w http.ResponseWriter, r *http.Request) { + s.cfg.NotFound(w, r) +} + +// ServeHTTP serves http request as defined by OpenAPI v3 specification, +// calling handler that matches the path or returning not found error. +func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { + elem := r.URL.Path + if len(elem) == 0 { + s.notFound(w, r) + return + } + // Static code generated router with unwrapped path search. + switch r.Method { + case "GET": + if len(elem) == 0 { + break + } + switch elem[0] { + case '/': // Prefix: "/foo" + if l := len("/foo"); len(elem) >= l && elem[0:l] == "/foo" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf: FooGet + s.handleFooGetRequest([0]string{}, w, r) + + return + } + } + } + s.notFound(w, r) +} + +// Route is route object. +type Route struct { + name string + count int + args [0]string +} + +// OperationID returns OpenAPI operationId. +func (r Route) OperationID() string { + return r.name +} + +// Args returns parsed arguments. +func (r Route) Args() []string { + return r.args[:r.count] +} + +// FindRoute finds Route for given method and path. +func (s *Server) FindRoute(method, path string) (r Route, _ bool) { + var ( + args = [0]string{} + elem = path + ) + r.args = args + if elem == "" { + return r, false + } + + // Static code generated router with unwrapped path search. + switch method { + case "GET": + if len(elem) == 0 { + break + } + switch elem[0] { + case '/': // Prefix: "/foo" + if l := len("/foo"); len(elem) >= l && elem[0:l] == "/foo" { + elem = elem[l:] + } else { + break + } + + if len(elem) == 0 { + // Leaf: FooGet + r.name = "FooGet" + r.args = args + r.count = 0 + return r, true + } + } + } + return r, false +} diff --git a/internal/referenced_path_item/oas_schemas_gen.go b/internal/referenced_path_item/oas_schemas_gen.go new file mode 100644 index 000000000..ae379a2db --- /dev/null +++ b/internal/referenced_path_item/oas_schemas_gen.go @@ -0,0 +1,3 @@ +// Code generated by ogen, DO NOT EDIT. + +package api diff --git a/internal/referenced_path_item/oas_security_gen.go b/internal/referenced_path_item/oas_security_gen.go new file mode 100644 index 000000000..ae379a2db --- /dev/null +++ b/internal/referenced_path_item/oas_security_gen.go @@ -0,0 +1,3 @@ +// Code generated by ogen, DO NOT EDIT. + +package api diff --git a/internal/referenced_path_item/oas_server_gen.go b/internal/referenced_path_item/oas_server_gen.go new file mode 100644 index 000000000..6550f64a9 --- /dev/null +++ b/internal/referenced_path_item/oas_server_gen.go @@ -0,0 +1,48 @@ +// Code generated by ogen, DO NOT EDIT. + +package api + +import ( + "context" + + "go.opentelemetry.io/otel/metric/instrument/syncint64" + + "github.com/ogen-go/ogen/otelogen" +) + +// Handler handles operations described by OpenAPI v3 specification. +type Handler interface { + // FooGet implements GET /foo operation. + // + // GET /foo + FooGet(ctx context.Context) (string, error) +} + +// Server implements http server based on OpenAPI v3 specification and +// calls Handler to handle requests. +type Server struct { + h Handler + cfg config + + requests syncint64.Counter + errors syncint64.Counter + duration syncint64.Histogram +} + +func NewServer(h Handler, opts ...Option) (*Server, error) { + s := &Server{ + h: h, + cfg: newConfig(opts...), + } + var err error + if s.requests, err = s.cfg.Meter.SyncInt64().Counter(otelogen.ServerRequestCount); err != nil { + return nil, err + } + if s.errors, err = s.cfg.Meter.SyncInt64().Counter(otelogen.ServerErrorsCount); err != nil { + return nil, err + } + if s.duration, err = s.cfg.Meter.SyncInt64().Histogram(otelogen.ServerDuration); err != nil { + return nil, err + } + return s, nil +} diff --git a/internal/referenced_path_item/oas_unimplemented_gen.go b/internal/referenced_path_item/oas_unimplemented_gen.go new file mode 100644 index 000000000..4524fc0cd --- /dev/null +++ b/internal/referenced_path_item/oas_unimplemented_gen.go @@ -0,0 +1,21 @@ +// Code generated by ogen, DO NOT EDIT. + +package api + +import ( + "context" + + ht "github.com/ogen-go/ogen/http" +) + +var _ Handler = UnimplementedHandler{} + +// UnimplementedHandler is no-op Handler which returns http.ErrNotImplemented. +type UnimplementedHandler struct{} + +// FooGet implements GET /foo operation. +// +// GET /foo +func (UnimplementedHandler) FooGet(ctx context.Context) (r string, _ error) { + return r, ht.ErrNotImplemented +} diff --git a/internal/referenced_path_item/oas_validators_gen.go b/internal/referenced_path_item/oas_validators_gen.go new file mode 100644 index 000000000..ae379a2db --- /dev/null +++ b/internal/referenced_path_item/oas_validators_gen.go @@ -0,0 +1,3 @@ +// Code generated by ogen, DO NOT EDIT. + +package api diff --git a/openapi/parser/parse_path_item.go b/openapi/parser/parse_path_item.go new file mode 100644 index 000000000..893f8d228 --- /dev/null +++ b/openapi/parser/parse_path_item.go @@ -0,0 +1,160 @@ +package parser + +import ( + "github.com/go-faster/errors" + + "github.com/ogen-go/ogen" + "github.com/ogen-go/ogen/internal/location" + "github.com/ogen-go/ogen/openapi" +) + +type pathItem = []*openapi.Operation + +func (p *parser) parsePathItem( + path string, + item *ogen.PathItem, + operationIDs map[string]struct{}, + ctx *resolveCtx, +) (_ pathItem, rerr error) { + if item == nil { + return nil, errors.New("pathItem object is empty or null") + } + defer func() { + rerr = p.wrapLocation(ctx.lastLoc(), item.Locator, rerr) + }() + + if ref := item.Ref; ref != "" { + ops, err := p.resolvePathItem(path, ref, operationIDs, ctx) + if err != nil { + return nil, errors.Wrap(err, "resolve pathItem") + } + return ops, nil + } + + itemParams, err := p.parseParams(item.Parameters, item.Locator.Field("parameters"), ctx) + if err != nil { + return nil, errors.Wrap(err, "parameters") + } + + var ops []*openapi.Operation + if err := forEachOps(item, func(method string, op ogen.Operation) error { + if id := op.OperationID; id != "" { + if _, ok := operationIDs[id]; ok { + return errors.Errorf("duplicate operationId: %q", id) + } + operationIDs[id] = struct{}{} + } + + parsedOp, err := p.parseOp(path, method, op, itemParams, ctx) + if err != nil { + if op.OperationID != "" { + return errors.Wrapf(err, "operation %q", op.OperationID) + } + return err + } + + ops = append(ops, parsedOp) + return nil + }); err != nil { + return nil, err + } + + return ops, nil +} + +func (p *parser) parseOp( + path, httpMethod string, + spec ogen.Operation, + itemParams []*openapi.Parameter, + ctx *resolveCtx, +) (_ *openapi.Operation, err error) { + defer func() { + err = p.wrapLocation(ctx.lastLoc(), spec.Locator, err) + }() + + op := &openapi.Operation{ + OperationID: spec.OperationID, + Summary: spec.Summary, + Description: spec.Description, + Deprecated: spec.Deprecated, + HTTPMethod: httpMethod, + Locator: spec.Locator, + } + + opParams, err := p.parseParams(spec.Parameters, spec.Locator.Field("parameters"), ctx) + if err != nil { + return nil, errors.Wrap(err, "parameters") + } + + // Merge operation parameters with pathItem parameters. + op.Parameters = mergeParams(opParams, itemParams) + + op.Path, err = parsePath(path, op.Parameters) + if err != nil { + return nil, errors.Wrapf(err, "parse path %q", path) + } + + if spec.RequestBody != nil { + op.RequestBody, err = p.parseRequestBody(spec.RequestBody, ctx) + if err != nil { + return nil, errors.Wrap(err, "requestBody") + } + } + + { + locator := spec.Locator.Field("responses") + op.Responses, err = p.parseResponses(spec.Responses, locator, ctx) + if err != nil { + err := errors.Wrap(err, "responses") + return nil, p.wrapLocation(ctx.lastLoc(), locator, err) + } + } + + parseSecurity := func(spec ogen.SecurityRequirements, locator location.Locator) (err error) { + op.Security, err = p.parseSecurityRequirements(spec, locator, ctx) + if err != nil { + err := errors.Wrap(err, "security") + return p.wrapLocation(ctx.lastLoc(), locator, err) + } + return nil + } + + var ( + security = p.spec.Security + securityParent = p.rootLoc + ) + if spec.Security != nil { + // Use operation level security. + security = spec.Security + securityParent = spec.Locator + } + if err := parseSecurity(security, securityParent.Field("security")); err != nil { + return nil, err + } + + return op, nil +} + +func forEachOps(item *ogen.PathItem, f func(method string, op ogen.Operation) error) error { + var err error + handle := func(method string, op *ogen.Operation) { + if err != nil || op == nil { + return + } + + err = f(method, *op) + if err != nil { + err = errors.Wrap(err, method) + } + } + + handle("get", item.Get) + handle("put", item.Put) + handle("post", item.Post) + handle("delete", item.Delete) + handle("options", item.Options) + handle("head", item.Head) + handle("patch", item.Patch) + handle("trace", item.Trace) + return err +} diff --git a/openapi/parser/parser.go b/openapi/parser/parser.go index e96c0fcd3..4d273c883 100644 --- a/openapi/parser/parser.go +++ b/openapi/parser/parser.go @@ -1,6 +1,8 @@ package parser import ( + "sort" + "github.com/go-faster/errors" yaml "github.com/go-faster/yamlx" @@ -25,6 +27,7 @@ type parser struct { headers map[string]*openapi.Header examples map[string]*openapi.Example securitySchemes map[string]*ogen.SecurityScheme + pathItems map[string]pathItem } external jsonschema.ExternalResolver @@ -53,6 +56,7 @@ func Parse(spec *ogen.Spec, s Settings) (*openapi.API, error) { headers map[string]*openapi.Header examples map[string]*openapi.Example securitySchemes map[string]*ogen.SecurityScheme + pathItems map[string]pathItem }{ requestBodies: map[string]*openapi.RequestBody{}, responses: map[string]*openapi.Response{}, @@ -60,6 +64,7 @@ func Parse(spec *ogen.Spec, s Settings) (*openapi.API, error) { headers: map[string]*openapi.Header{}, examples: map[string]*openapi.Example{}, securitySchemes: map[string]*ogen.SecurityScheme{}, + pathItems: map[string]pathItem{}, }, external: s.External, schemas: map[string]*yaml.Node{ @@ -117,8 +122,15 @@ func (p *parser) parsePathItems() error { paths = map[string]struct{}{} pathsLoc = p.rootLoc.Field("paths") + keys = make([]string, 0, len(p.spec.Paths)) ) - for path, item := range p.spec.Paths { + for k := range p.spec.Paths { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, path := range keys { + item := p.spec.Paths[path] if err := func() error { id, err := pathID(path) if err != nil { @@ -135,149 +147,12 @@ func (p *parser) parsePathItems() error { return p.wrapLocation("", pathsLoc.Key(path), err) } - if err := p.parsePathItem(path, item, operationIDs, newResolveCtx(p.depthLimit)); err != nil { + ops, err := p.parsePathItem(path, item, operationIDs, newResolveCtx(p.depthLimit)) + if err != nil { err := errors.Wrapf(err, "path %q", path) return p.wrapLocation("", pathsLoc.Field(path), err) } + p.operations = append(p.operations, ops...) } return nil } - -func (p *parser) parsePathItem( - path string, - item *ogen.PathItem, - operationIDs map[string]struct{}, - ctx *resolveCtx, -) (rerr error) { - if item == nil { - return errors.New("pathItem object is empty or null") - } - defer func() { - rerr = p.wrapLocation(ctx.lastLoc(), item.Locator, rerr) - }() - if item.Ref != "" { - return errors.New("referenced pathItem not supported") - } - - itemParams, err := p.parseParams(item.Parameters, item.Locator.Field("parameters"), ctx) - if err != nil { - return errors.Wrap(err, "parameters") - } - - return forEachOps(item, func(method string, op ogen.Operation) error { - if id := op.OperationID; id != "" { - if _, ok := operationIDs[id]; ok { - return errors.Errorf("duplicate operationId: %q", id) - } - operationIDs[id] = struct{}{} - } - - parsedOp, err := p.parseOp(path, method, op, itemParams, ctx) - if err != nil { - if op.OperationID != "" { - return errors.Wrapf(err, "operation %q", op.OperationID) - } - return err - } - - p.operations = append(p.operations, parsedOp) - return nil - }) -} - -func (p *parser) parseOp( - path, httpMethod string, - spec ogen.Operation, - itemParams []*openapi.Parameter, - ctx *resolveCtx, -) (_ *openapi.Operation, err error) { - defer func() { - err = p.wrapLocation(ctx.lastLoc(), spec.Locator, err) - }() - - op := &openapi.Operation{ - OperationID: spec.OperationID, - Summary: spec.Summary, - Description: spec.Description, - Deprecated: spec.Deprecated, - HTTPMethod: httpMethod, - Locator: spec.Locator, - } - - opParams, err := p.parseParams(spec.Parameters, spec.Locator.Field("parameters"), ctx) - if err != nil { - return nil, errors.Wrap(err, "parameters") - } - - // Merge operation parameters with pathItem parameters. - op.Parameters = mergeParams(opParams, itemParams) - - op.Path, err = parsePath(path, op.Parameters) - if err != nil { - return nil, errors.Wrapf(err, "parse path %q", path) - } - - if spec.RequestBody != nil { - op.RequestBody, err = p.parseRequestBody(spec.RequestBody, ctx) - if err != nil { - return nil, errors.Wrap(err, "requestBody") - } - } - - { - locator := spec.Locator.Field("responses") - op.Responses, err = p.parseResponses(spec.Responses, locator, ctx) - if err != nil { - err := errors.Wrap(err, "responses") - return nil, p.wrapLocation(ctx.lastLoc(), locator, err) - } - } - - parseSecurity := func(spec ogen.SecurityRequirements, locator location.Locator) (err error) { - op.Security, err = p.parseSecurityRequirements(spec, locator, ctx) - if err != nil { - err := errors.Wrap(err, "security") - return p.wrapLocation(ctx.lastLoc(), locator, err) - } - return nil - } - - var ( - security = p.spec.Security - securityParent = p.rootLoc - ) - if spec.Security != nil { - // Use operation level security. - security = spec.Security - securityParent = spec.Locator - } - if err := parseSecurity(security, securityParent.Field("security")); err != nil { - return nil, err - } - - return op, nil -} - -func forEachOps(item *ogen.PathItem, f func(method string, op ogen.Operation) error) error { - var err error - handle := func(method string, op *ogen.Operation) { - if err != nil || op == nil { - return - } - - err = f(method, *op) - if err != nil { - err = errors.Wrap(err, method) - } - } - - handle("get", item.Get) - handle("put", item.Put) - handle("post", item.Post) - handle("delete", item.Delete) - handle("options", item.Options) - handle("head", item.Head) - handle("patch", item.Patch) - handle("trace", item.Trace) - return err -} diff --git a/openapi/parser/resolve.go b/openapi/parser/resolve.go index 4bf1dda36..bf78cf067 100644 --- a/openapi/parser/resolve.go +++ b/openapi/parser/resolve.go @@ -303,3 +303,48 @@ func (p *parser) resolveSecurityScheme(ref string, ctx *resolveCtx) (*ogen.Secur p.refs.securitySchemes[ref] = component return component, nil } + +func (p *parser) resolvePathItem( + itemPath, ref string, + operationIDs map[string]struct{}, + ctx *resolveCtx, +) (pathItem, error) { + const prefix = "#/components/pathItems/" + + if r, ok := p.refs.pathItems[ref]; ok { + return r, nil + } + + key, err := ctx.add(ref) + if err != nil { + return nil, err + } + defer func() { + ctx.delete(key) + }() + + var component *ogen.PathItem + if key.loc == "" && ctx.lastLoc() == "" { + name := strings.TrimPrefix(ref, prefix) + c, found := p.spec.Components.PathItems[name] + if found { + component = c + } else { + if err := resolvePointer(p.spec.Raw, ref, &component); err != nil { + return nil, err + } + } + } else { + if err := p.resolve(key, ctx, &component); err != nil { + return nil, err + } + } + + r, err := p.parsePathItem(itemPath, component, operationIDs, ctx) + if err != nil { + return nil, err + } + + p.refs.pathItems[ref] = r + return r, nil +} diff --git a/openapi/parser/resolve_external_test.go b/openapi/parser/resolve_external_test.go index 96911655f..10a6ca2f0 100644 --- a/openapi/parser/resolve_external_test.go +++ b/openapi/parser/resolve_external_test.go @@ -45,6 +45,9 @@ func TestExternalReference(t *testing.T) { }, }, }, + "/pathItem": { + Ref: "#/components/pathItems/LocalPathItem", + }, }, Components: &ogen.Components{ Schemas: map[string]*ogen.Schema{ @@ -62,11 +65,6 @@ func TestExternalReference(t *testing.T) { Ref: "foo.json#/components/parameters/RemoteParameter", }, }, - Headers: map[string]*ogen.Header{ - "LocalHeader": { - Ref: "foo.json#/components/headers/RemoteHeader", - }, - }, Examples: map[string]*ogen.Example{ "LocalExample": { Ref: "foo.json#/components/examples/RemoteExample", @@ -77,11 +75,21 @@ func TestExternalReference(t *testing.T) { Ref: "foo.json#/components/requestBodies/RemoteRequestBody", }, }, + Headers: map[string]*ogen.Header{ + "LocalHeader": { + Ref: "foo.json#/components/headers/RemoteHeader", + }, + }, SecuritySchemes: map[string]*ogen.SecurityScheme{ "LocalSecurityScheme": { Ref: "foo.json#/components/securitySchemes/RemoteSecurityScheme", }, }, + PathItems: map[string]*ogen.PathItem{ + "LocalPathItem": { + Ref: "pathItem.json", + }, + }, }, } remote := external{ @@ -140,6 +148,17 @@ func TestExternalReference(t *testing.T) { SecuritySchemes: nil, }, }, + "pathItem.json": &ogen.PathItem{ + Get: &ogen.Operation{ + OperationID: "remoteGet", + Description: "remote operation description", + Responses: map[string]*ogen.Response{ + "200": { + Ref: "response.json#", + }, + }, + }, + }, "bar.json": &ogen.Spec{ Components: &ogen.Components{ Schemas: map[string]*ogen.Schema{ @@ -255,6 +274,20 @@ func TestExternalReference(t *testing.T) { "200": response, }, }, + { + OperationID: "remoteGet", + Description: "remote operation description", + HTTPMethod: "get", + Path: openapi.Path{ + {Raw: "/pathItem"}, + }, + Parameters: []*openapi.Parameter{}, + RequestBody: nil, + Security: []openapi.SecurityRequirement{}, + Responses: map[string]*openapi.Response{ + "200": response, + }, + }, }, Components: &openapi.Components{ Schemas: map[string]*jsonschema.Schema{ @@ -275,3 +308,44 @@ func TestExternalReference(t *testing.T) { }, }, spec) } + +// Ensure that parser checks for duplicate operation IDs even if there is pathItem reference. +func TestDuplicateOperationID(t *testing.T) { + root := &ogen.Spec{ + 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#", + }, + }, + Components: &ogen.Components{}, + } + remote := external{ + "pathItem.json": &ogen.PathItem{ + Get: &ogen.Operation{ + OperationID: "testGet", + Description: "remote", + Responses: map[string]*ogen.Response{ + "200": { + Description: "response description", + }, + }, + }, + }, + } + + _, err := Parse(root, Settings{ + External: remote, + }) + require.ErrorContains(t, err, "duplicate operationId: \"testGet\"") +} diff --git a/spec.go b/spec.go index 4778d3a24..e0e151697 100644 --- a/spec.go +++ b/spec.go @@ -210,6 +210,7 @@ type Components struct { SecuritySchemes map[string]*SecurityScheme `json:"securitySchemes,omitempty" yaml:"securitySchemes,omitempty"` Links map[string]*Link `json:"links,omitempty" yaml:"links,omitempty"` Callbacks map[string]*Callback `json:"callbacks,omitempty" yaml:"callbacks,omitempty"` + PathItems map[string]*PathItem `json:"pathItems,omitempty" yaml:"pathItems,omitempty"` Locator Locator `json:"-" yaml:",inline"` }