From 9163747cb65fbe1d7071bd680a7adebb5682ea00 Mon Sep 17 00:00:00 2001 From: Kyle Hodgetts Date: Tue, 5 Oct 2021 15:57:46 +0300 Subject: [PATCH] OpenAPI Parser and Options (#2) * add openapi spec parser and options --- go.mod | 4 ++ go.sum | 12 ++++ options/cluster.go | 16 +++++ options/cors.go | 42 +++++++++++ options/nginx_ingress.go | 11 +++ options/options.go | 122 ++++++++++++++++++++++++++++++++ options/path.go | 26 +++++++ options/ratelimit.go | 36 ++++++++++ options/service.go | 24 +++++++ options/timeout.go | 37 ++++++++++ spec/extension.go | 86 +++++++++++++++++++++++ spec/extension_test.go | 82 ++++++++++++++++++++++ spec/spec.go | 109 +++++++++++++++++++++++++++++ spec/spec_test.go | 146 +++++++++++++++++++++++++++++++++++++++ 14 files changed, 753 insertions(+) create mode 100644 options/cluster.go create mode 100644 options/cors.go create mode 100644 options/nginx_ingress.go create mode 100644 options/options.go create mode 100644 options/path.go create mode 100644 options/ratelimit.go create mode 100644 options/service.go create mode 100644 options/timeout.go create mode 100644 spec/extension.go create mode 100644 spec/extension_test.go create mode 100644 spec/spec.go create mode 100644 spec/spec_test.go diff --git a/go.mod b/go.mod index 8d8199afa..b6943e45d 100644 --- a/go.mod +++ b/go.mod @@ -4,10 +4,14 @@ go 1.16 require ( github.com/envoyproxy/go-control-plane v0.9.10-0.20210907150352-cf90f659a021 + github.com/getkin/kin-openapi v0.76.0 + github.com/ghodss/yaml v1.0.0 + github.com/go-ozzo/ozzo-validation/v4 v4.3.0 github.com/gofrs/uuid v4.0.0+incompatible github.com/golang/protobuf v1.4.3 github.com/onsi/ginkgo v1.14.1 github.com/onsi/gomega v1.10.2 + github.com/stretchr/testify v1.7.0 google.golang.org/grpc v1.36.0 k8s.io/apimachinery v0.20.2 k8s.io/client-go v0.20.2 diff --git a/go.sum b/go.sum index 5b9916daa..33f0619a9 100644 --- a/go.sum +++ b/go.sum @@ -56,6 +56,8 @@ github.com/armon/circbuf v0.0.0-20150827004946-bbbad097214e/go.mod h1:3U/XgcO3hC github.com/armon/go-metrics v0.0.0-20180917152333-f0300d1749da/go.mod h1:Q73ZrmVTwzkszR9V5SSuryQ31EELlFMUz1kKyl939pY= github.com/armon/go-radix v0.0.0-20180808171621-7fddfc383310/go.mod h1:ufUuZ+zHj4x4TnLV4JWEpy2hxWSpsRywHrMgIH9cCH8= github.com/asaskevich/govalidator v0.0.0-20190424111038-f61b66f89f4a/go.mod h1:lB+ZfQJz7igIIfQNfa7Ml4HSf2uFQQRzpGGRXenZAgY= +github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496 h1:zV3ejI06GQ59hwDQAvmK1qxOQGB3WuVTRoY0okPTAv0= +github.com/asaskevich/govalidator v0.0.0-20200108200545-475eaeb16496/go.mod h1:oGkLhpf+kjZl6xBf758TQhh5XrAeiJv/7FRz/2spLIg= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= @@ -118,7 +120,10 @@ github.com/form3tech-oss/jwt-go v3.2.2+incompatible/go.mod h1:pbq4aXjuKjdthFRnoD github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/getkin/kin-openapi v0.76.0 h1:j77zg3Ec+k+r+GA3d8hBoXpAc6KX9TbBPrwQGBIy2sY= +github.com/getkin/kin-openapi v0.76.0/go.mod h1:660oXbgy5JFMKreazJaQTw7o+X00qeSyhcnluiMv+Xg= github.com/ghodss/yaml v0.0.0-20150909031657-73d445a93680/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= +github.com/ghodss/yaml v1.0.0 h1:wQHKEahhL6wmXdzwWG11gIVCkOv05bNOh+Rxn0yngAk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8= @@ -135,11 +140,16 @@ github.com/go-logr/zapr v0.2.0 h1:v6Ji8yBW77pva6NkJKQdHLAJKrIJKRHz0RXwPqCHSR4= github.com/go-logr/zapr v0.2.0/go.mod h1:qhKdvif7YF5GI9NWEpyxTSSBdGmzkNguibrdCNVPunU= github.com/go-openapi/jsonpointer v0.19.2/go.mod h1:3akKfEdA7DF1sugOqz1dVQHBcuDBPKZGEoHC/NkiQRg= github.com/go-openapi/jsonpointer v0.19.3/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= +github.com/go-openapi/jsonpointer v0.19.5 h1:gZr+CIYByUqjcgeLXnQu2gHYQC9o73G2XUeOFYEICuY= +github.com/go-openapi/jsonpointer v0.19.5/go.mod h1:Pl9vOtqEWErmShwVjC8pYs9cog34VGT37dQOVbmoatg= github.com/go-openapi/jsonreference v0.19.2/go.mod h1:jMjeRr2HHw6nAVajTXJ4eiUwohSTlpa0o73RUL1owJc= github.com/go-openapi/jsonreference v0.19.3/go.mod h1:rjx6GuL8TTa9VaixXglHmQmIL98+wF9xc8zWvFonSJ8= github.com/go-openapi/spec v0.19.3/go.mod h1:FpwSN1ksY1eteniUU7X0N/BgJ7a4WvBFVA8Lj9mJglo= github.com/go-openapi/swag v0.19.2/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-openapi/swag v0.19.5 h1:lTz6Ys4CmqqCQmZPBlbQENR1/GucA2bzYTE12Pw4tFY= github.com/go-openapi/swag v0.19.5/go.mod h1:POnQmlKehdgb5mhVOsnJFsivZCEZ/vjK9gh66Z9tfKk= +github.com/go-ozzo/ozzo-validation/v4 v4.3.0 h1:byhDUpfEwjsVQb1vBunvIjh2BHQ9ead57VkAEY4V+Es= +github.com/go-ozzo/ozzo-validation/v4 v4.3.0/go.mod h1:2NKgrcHl3z6cJs+3Oo940FPRiTzuqKbvfrL2RxCj6Ew= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= github.com/gofrs/uuid v4.0.0+incompatible h1:1SD/1F5pU8p29ybwgQSwpQk+mwdRrXCYuPhW6m+TnJw= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= @@ -202,6 +212,7 @@ github.com/googleapis/gnostic v0.4.1/go.mod h1:LRhVm6pbyptWbWbuZ38d1eyptfvIytN3i github.com/googleapis/gnostic v0.5.1 h1:A8Yhf6EtqTv9RMsU6MQTyrtV1TjWlR6xU9BsZIwuTCM= github.com/googleapis/gnostic v0.5.1/go.mod h1:6U4PtQXGIEt/Z3h5MAT7FNofLnw9vXk2cUuW7uA/OeU= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= +github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So= github.com/gorilla/websocket v0.0.0-20170926233335-4201258b820c/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gregjones/httpcache v0.0.0-20180305231024-9cad4c3443a7/go.mod h1:FecbI9+v66THATjSRHfNgh1IVFe/9kFxbXtjV0ctIMA= @@ -264,6 +275,7 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/magiconair/properties v1.8.1/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czPbwD3XqdrwzmxQ= github.com/mailru/easyjson v0.0.0-20190614124828-94de47d64c63/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= github.com/mailru/easyjson v0.0.0-20190626092158-b2ccc519800e/go.mod h1:C1wdFJiN94OJF2b5HbByQZoLdCWB1Yqtg26g4irojpc= +github.com/mailru/easyjson v0.7.0 h1:aizVhC/NAAcKWb+5QsU1iNOZb4Yws5UO2I+aIprQITM= github.com/mailru/easyjson v0.7.0/go.mod h1:KAzv3t3aY1NaHWoQz1+4F1ccyAH66Jk7yos7ldAVICs= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= diff --git a/options/cluster.go b/options/cluster.go new file mode 100644 index 000000000..6b3062ffe --- /dev/null +++ b/options/cluster.go @@ -0,0 +1,16 @@ +package options + +import ( + validation "github.com/go-ozzo/ozzo-validation/v4" +) + +type ClusterOptions struct { + // ClusterDomain is the base DNS domain for the cluster. Default value is "cluster.local". + ClusterDomain string `yaml:"cluster_domain,omitempty" json:"cluster_domain,omitempty"` +} + +func (o *ClusterOptions) Validate() error { + return validation.ValidateStruct(o, + validation.Field(&o.ClusterDomain, validation.Required.Error("cluster_domain is required")), + ) +} diff --git a/options/cors.go b/options/cors.go new file mode 100644 index 000000000..d7c72046d --- /dev/null +++ b/options/cors.go @@ -0,0 +1,42 @@ +package options + +import "reflect" + +type CORSOptions struct { + Origins []string `yaml:"origins,omitempty" json:"origins,omitempty"` + Methods []string `yaml:"methods,omitempty" json:"methods,omitempty"` + Headers []string `yaml:"headers,omitempty" json:"headers,omitempty"` + ExposeHeaders []string `yaml:"expose_headers,omitempty" json:"expose_headers,omitempty"` + + // Pointer because default value of bool is false which could have unintended side effects + // Check if not nil to ensure it's been set by user + Credentials *bool `yaml:"credentials,omitempty" json:"credentials,omitempty"` + MaxAge int `yaml:"max_age,omitempty" json:"max_age,omitempty"` +} + +func (o *Options) GetCORSOpts(path, method string) CORSOptions { + // take global CORS options + corsOpts := o.CORS + + // if non-zero path-level CORS options are different, override with them + if pathSubOpts, ok := o.PathSubOptions[path]; ok { + if !reflect.DeepEqual(CORSOptions{}, pathSubOpts.CORS) && + !reflect.DeepEqual(corsOpts, pathSubOpts.CORS) { + corsOpts = pathSubOpts.CORS + } + } + + // if non-zero operation-level CORS options are different, override them + if opSubOpts, ok := o.OperationSubOptions[path]; ok { + if !reflect.DeepEqual(CORSOptions{}, opSubOpts.CORS) && + !reflect.DeepEqual(corsOpts, opSubOpts.CORS) { + corsOpts = opSubOpts.CORS + } + } + + return corsOpts +} + +func (o *CORSOptions) Validate() error { + return nil +} diff --git a/options/nginx_ingress.go b/options/nginx_ingress.go new file mode 100644 index 000000000..903de5b66 --- /dev/null +++ b/options/nginx_ingress.go @@ -0,0 +1,11 @@ +package options + +type NGINXIngressOptions struct { + // RewriteTarget is a custom rewrite target for ingress-nginx. + // See https://kubernetes.github.io/ingress-nginx/examples/rewrite/ for additional documentation. + RewriteTarget string `yaml:"rewrite_target,omitempty" json:"rewrite_target,omitempty"` +} + +func (o *NGINXIngressOptions) Validate() error { + return nil +} diff --git a/options/options.go b/options/options.go new file mode 100644 index 000000000..5f47a5cc1 --- /dev/null +++ b/options/options.go @@ -0,0 +1,122 @@ +package options + +import ( + v "github.com/go-ozzo/ozzo-validation/v4" +) + +// SubOptions allow user to overwrite certain options at path/operation level +// using x-kusk extension +type SubOptions struct { + Disabled *bool `yaml:"disabled,omitempty" json:"disabled,omitempty"` + + Host string `yaml:"host,omitempty" json:"host,omitempty"` + CORS CORSOptions `yaml:"cors,omitempty" json:"cors,omitempty"` + RateLimits RateLimitOptions `yaml:"rate_limits,omitempty" json:"rate_limits,omitempty"` + Timeouts TimeoutOptions `yaml:"timeouts,omitempty" json:"timeouts,omitempty"` +} + +type Options struct { + Disabled bool `yaml:"disabled,omitempty" json:"disabled,omitempty"` + + // Namespace for the generated resource. Default value is "default". + Namespace string `yaml:"namespace,omitempty" json:"namespace,omitempty"` + + // Service is a set of options of a target service to receive traffic. + Service ServiceOptions `yaml:"service,omitempty" json:"service,omitempty"` + + // Path is a set of options to configure service endpoints paths. + Path PathOptions `yaml:"path,omitempty" json:"path,omitempty"` + + // Cluster is a set of cluster-wide options. + Cluster ClusterOptions `yaml:"cluster,omitempty" json:"cluster,omitempty"` + + // Host is an ingress host rule. + // See https://kubernetes.io/docs/concepts/services-networking/ingress/#ingress-rules for additional documentation. + Host string `yaml:"host,omitempty" json:"host,omitempty"` + + CORS CORSOptions `yaml:"cors,omitempty" json:"cors,omitempty"` + + // NGINXIngress is a set of custom nginx-ingress options. + NGINXIngress NGINXIngressOptions `yaml:"nginx_ingress,omitempty" json:"nginx_ingress,omitempty"` + + // PathSubOptions allow to overwrite specific subset of Options for a given path. + // They are filled during extension parsing, the map key is path. + PathSubOptions map[string]SubOptions `yaml:"-" json:"-"` + + // OperationSubOptions allow to overwrite specific subset of Options for a given operation. + // They are filled during extension parsing, the map key is method+path. + OperationSubOptions map[string]SubOptions `yaml:"-" json:"-"` + + RateLimits RateLimitOptions `yaml:"rate_limits,omitempty" json:"rate_limits,omitempty"` + + Timeouts TimeoutOptions `yaml:"timeouts,omitempty" json:"timeouts,omitempty"` +} + +func (o *Options) fillDefaults() { + if o.Namespace == "" { + o.Namespace = "default" + } + + if o.Path.Base == "" { + o.Path.Base = "/" + } + + if o.Cluster.ClusterDomain == "" { + o.Cluster.ClusterDomain = "cluster.local" + } + + if o.Service.Port == 0 { + o.Service.Port = 80 + } +} + +func (o *Options) Validate() error { + err := v.ValidateStruct(o, + v.Field(&o.Namespace, v.Required.Error("Target namespace is required")), + ) + + if err != nil { + return err + } + + return nil +} + +func (o *Options) FillDefaultsAndValidate() error { + o.fillDefaults() + + return v.Validate([]v.Validatable{ + o, + &o.Service, + &o.Path, + &o.Cluster, + &o.CORS, + &o.NGINXIngress, + &o.RateLimits, + &o.Timeouts, + }) + +} + +func (o *Options) IsOperationDisabled(path, method string) bool { + opSubOptions, ok := o.OperationSubOptions[method+path] + + // If the operation has an explicit value set, return that (takes precedence over the path level setting) + if ok && opSubOptions.Disabled != nil { + return *opSubOptions.Disabled + } + + // No explicit value set for `Disabled` at the operation level, check the path level + return o.IsPathDisabled(path) +} + +func (o *Options) IsPathDisabled(path string) bool { + pathSubOptions, ok := o.PathSubOptions[path] + + // If the path has an explicit value set, return that (takes precedence over the global level setting) + if ok && pathSubOptions.Disabled != nil { + return *pathSubOptions.Disabled + } + + return o.Disabled +} diff --git a/options/path.go b/options/path.go new file mode 100644 index 000000000..628482abd --- /dev/null +++ b/options/path.go @@ -0,0 +1,26 @@ +package options + +import ( + "github.com/go-ozzo/ozzo-validation/v4" +) + +type PathOptions struct { + // Base is the preceding prefix for the route (i.e. /your-prefix/here/rest/of/the/route). + // Default value is "/". + Base string `yaml:"base,omitempty" json:"base,omitempty"` + + // TrimPrefix is the prefix that would be omitted from the URL when request is being forwarded + // to the upstream service, i.e. given that Base is set to "/petstore/api/v3", TrimPrefix is set to "/petstore", + // path that would be generated is "/petstore/api/v3/pets", URL that the upstream service would receive + // is "/api/v3/pets". + TrimPrefix string `yaml:"trim_prefix,omitempty" json:"trim_prefix,omitempty"` + + // Split forces Kusk to generate a separate resource for each Path or Operation, where appropriate. + Split bool `yaml:"split,omitempty" json:"split,omitempty"` +} + +func (o *PathOptions) Validate() error { + return validation.ValidateStruct(o, + validation.Field(&o.Base, validation.Required.Error("Base path required")), + ) +} diff --git a/options/ratelimit.go b/options/ratelimit.go new file mode 100644 index 000000000..6e7c152fd --- /dev/null +++ b/options/ratelimit.go @@ -0,0 +1,36 @@ +package options + +import "reflect" + +type RateLimitOptions struct { + RPS uint32 `json:"rps,omitempty" yaml:"rps,omitempty"` + Burst uint32 `json:"burst,omitempty" yaml:"burst,omitempty"` + Group string `json:"group,omitempty" yaml:"group,omitempty"` +} + +func (o *Options) GetRateLimitOpts(path, method string) RateLimitOptions { + // take global rate limit options + rateLimitOpts := o.RateLimits + + // if non-zero path-level rate limit options are different, override with them + if pathSubOpts, ok := o.PathSubOptions[path]; ok && + pathSubOpts.RateLimits.ShouldOverride(rateLimitOpts) { + rateLimitOpts = pathSubOpts.RateLimits + } + + // if non-zero operation-level rate limit options are different, override them + if opSubOpts, ok := o.OperationSubOptions[path]; ok && + opSubOpts.RateLimits.ShouldOverride(rateLimitOpts) { + rateLimitOpts = opSubOpts.RateLimits + } + + return rateLimitOpts +} + +func (o *RateLimitOptions) ShouldOverride(opts RateLimitOptions) bool { + return !reflect.DeepEqual(CORSOptions{}, o) && !reflect.DeepEqual(opts, o) +} + +func (o *RateLimitOptions) Validate() error { + return nil +} diff --git a/options/service.go b/options/service.go new file mode 100644 index 000000000..8834fc02c --- /dev/null +++ b/options/service.go @@ -0,0 +1,24 @@ +package options + +import ( + v "github.com/go-ozzo/ozzo-validation/v4" +) + +type ServiceOptions struct { + // Namespace is the namespace containing the upstream Service. + Namespace string `yaml:"namespace,omitempty" json:"namespace,omitempty"` + + // Name is the upstream Service's name. + Name string `yaml:"name,omitempty" json:"name,omitempty"` + + // Port is the upstream Service's port. Default value is 80. + Port int32 `yaml:"port,omitempty" json:"port,omitempty"` +} + +func (o *ServiceOptions) Validate() error { + return v.ValidateStruct(o, + v.Field(&o.Namespace, v.Required.Error("service.namespace is required")), + v.Field(&o.Name, v.Required.Error("service.name is required")), + v.Field(&o.Port, v.Required.Error("service.port is required"), v.Min(1), v.Max(65535)), + ) +} diff --git a/options/timeout.go b/options/timeout.go new file mode 100644 index 000000000..cf8134e2c --- /dev/null +++ b/options/timeout.go @@ -0,0 +1,37 @@ +package options + +import "reflect" + +type TimeoutOptions struct { + // RequestTimeout is total request timeout + RequestTimeout uint32 `yaml:"request_timeout,omitempty" json:"request_timeout,omitempty"` + // IdleTimeout is timeout for idle connection + IdleTimeout uint32 `yaml:"idle_timeout,omitempty" json:"idle_timeout,omitempty"` +} + +func (o *Options) GetTimeoutOpts(path, method string) TimeoutOptions { + // take global timeout options + timeoutOpts := o.Timeouts + + // if non-zero path-level timeout options are different, override them + if pathSubOpts, ok := o.PathSubOptions[path]; ok { + if !reflect.DeepEqual(TimeoutOptions{}, pathSubOpts.Timeouts) && + !reflect.DeepEqual(timeoutOpts, pathSubOpts.Timeouts) { + timeoutOpts = pathSubOpts.Timeouts + } + } + + // if non-zero operation-level timeout options are different, override them + if opSubOpts, ok := o.OperationSubOptions[path]; ok { + if !reflect.DeepEqual(TimeoutOptions{}, opSubOpts.Timeouts) && + !reflect.DeepEqual(timeoutOpts, opSubOpts.Timeouts) { + timeoutOpts = opSubOpts.Timeouts + } + } + + return timeoutOpts +} + +func (o *TimeoutOptions) Validate() error { + return nil +} diff --git a/spec/extension.go b/spec/extension.go new file mode 100644 index 000000000..d495b74c4 --- /dev/null +++ b/spec/extension.go @@ -0,0 +1,86 @@ +package spec + +import ( + "encoding/json" + "fmt" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/ghodss/yaml" + + "github.com/kubeshop/kusk-gateway/options" +) + +const kuskExtensionKey = "x-kusk" + +func getPathOptions(path *openapi3.PathItem) (options.SubOptions, bool, error) { + var res options.SubOptions + + ok, err := parseExtension(&path.ExtensionProps, &res) + + return res, ok, err +} + +func getOperationOptions(operation *openapi3.Operation) (options.SubOptions, bool, error) { + var res options.SubOptions + + ok, err := parseExtension(&operation.ExtensionProps, &res) + + return res, ok, err +} + +// GetOptions would retrieve and parse x-kusk top-level OpenAPI extension +// that contains Kusk options. If there's no extension found, an empty object will be returned. +func GetOptions(spec *openapi3.T) (*options.Options, error) { + var res options.Options + + if _, err := parseExtension(&spec.ExtensionProps, &res); err != nil { + return nil, err + } + + for path, pathItem := range spec.Paths { + pathSubOptions, ok, err := getPathOptions(pathItem) + if err != nil { + return nil, fmt.Errorf("failed to extract path suboptions: %w", err) + } + + if ok { + if res.PathSubOptions == nil { + res.PathSubOptions = map[string]options.SubOptions{} + } + + res.PathSubOptions[path] = pathSubOptions + } + + for method, operation := range pathItem.Operations() { + operationSubOptions, ok, err := getOperationOptions(operation) + if err != nil { + return nil, fmt.Errorf("failed to extract operation suboptions: %w", err) + } + + if ok { + if res.OperationSubOptions == nil { + res.OperationSubOptions = map[string]options.SubOptions{} + } + + res.OperationSubOptions[method+path] = operationSubOptions + } + } + } + + return &res, nil +} + +func parseExtension(extensionProps *openapi3.ExtensionProps, target interface{}) (bool, error) { + if extension, ok := extensionProps.Extensions[kuskExtensionKey]; ok { + if kuskExtension, ok := extension.(json.RawMessage); ok { + err := yaml.Unmarshal(kuskExtension, target) + if err != nil { + return false, fmt.Errorf("failed to parse extension: %w", err) + } + + return true, nil + } + } + + return false, nil +} diff --git a/spec/extension_test.go b/spec/extension_test.go new file mode 100644 index 000000000..75c7be5ee --- /dev/null +++ b/spec/extension_test.go @@ -0,0 +1,82 @@ +package spec + +import ( + "encoding/json" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/require" + + "github.com/kubeshop/kusk-gateway/options" +) + +func TestGetOptions(t *testing.T) { + trueValue := true + + var testCases = []struct { + name string + spec *openapi3.T + res options.Options + }{ + { + name: "no extensions", + spec: &openapi3.T{}, + res: options.Options{}, + }, + { + name: "path level options set", + spec: &openapi3.T{ + Paths: openapi3.Paths{ + "/pet": &openapi3.PathItem{ + ExtensionProps: openapi3.ExtensionProps{ + Extensions: map[string]interface{}{ + kuskExtensionKey: json.RawMessage(`{"disabled":true}`), + }, + }, + }, + }, + }, + res: options.Options{ + PathSubOptions: map[string]options.SubOptions{ + "/pet": { + Disabled: &trueValue, + }, + }, + }, + }, + { + name: "operation level options set", + spec: &openapi3.T{ + Paths: openapi3.Paths{ + "/pet": &openapi3.PathItem{ + Put: &openapi3.Operation{ + ExtensionProps: openapi3.ExtensionProps{ + Extensions: map[string]interface{}{ + kuskExtensionKey: json.RawMessage(`{"disabled":true}`), + }, + }, + }, + }, + }, + }, + res: options.Options{ + OperationSubOptions: map[string]options.SubOptions{ + "PUT/pet": { + Disabled: &trueValue, + }, + }, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + r := require.New(t) + + actual, err := GetOptions(testCase.spec) + r.NoError(err, "failed to get options") + r.Equal(testCase.res, *actual) + }) + } + +} diff --git a/spec/spec.go b/spec/spec.go new file mode 100644 index 000000000..50df1f35a --- /dev/null +++ b/spec/spec.go @@ -0,0 +1,109 @@ +package spec + +import ( + "bytes" + "fmt" + "io" + "io/ioutil" + "net/url" + + "github.com/getkin/kin-openapi/openapi2" + "github.com/getkin/kin-openapi/openapi2conv" + "github.com/getkin/kin-openapi/openapi3" + "github.com/ghodss/yaml" +) + +// isSwagger tries to decode the spec header +func isSwagger(spec []byte) bool { + // internal helper struct to help us differentiate + // between openapi spec 2.0 (swagger) and openapi 3+ + var header struct { + Swagger string `json:"swagger"` + OpenAPI string `json:"openapi"` // we might need that later to distinguish 3.1.x vs 3.0.x + } + + _ = yaml.Unmarshal(spec, &header) + + return header.Swagger != "" +} + +type Loader interface { + LoadFromURI(location *url.URL) (*openapi3.T, error) + LoadFromFile(location string) (*openapi3.T, error) +} + +type Parser struct { + loader Loader +} + +func NewParser(loader Loader) Parser { + return Parser{ + loader: loader, + } +} + +// Parse is the entrypoint for the spec package +// Accepts a path that should be parseable into a resource locater +// i.e. a URL or relative file path +func (p Parser) Parse(path string) (*openapi3.T, error) { + u, err := url.Parse(path) + if err != nil { + return nil, fmt.Errorf("invalid resource path %s: %w", path, err) + } + + var spec *openapi3.T + if isURLRelative := u.Host == ""; isURLRelative { + spec, err = p.loader.LoadFromFile(path) + } else { + spec, err = p.loader.LoadFromURI(u) + } + + if err != nil { + return nil, fmt.Errorf("unable to load spec: %w", err) + } + + // we need to marshal the struct back to yaml while we support + // both openapi spec 2.0 and 3.0, so we can differentiate between the two + // and convert 2.0 to 3.0 if needed + bSpec, err := yaml.Marshal(&spec) + if err != nil { + return nil, fmt.Errorf("unable to marshal spec to yaml: %w", err) + } + + return p.ParseFromReader(bytes.NewReader(bSpec)) +} + +// ParseFromReader allows for providing your own Reader implementation +// to parse the API spec from +func (p Parser) ParseFromReader(contents io.Reader) (*openapi3.T, error) { + spec, err := ioutil.ReadAll(contents) + if err != nil { + return nil, fmt.Errorf("could not read contents of api spec: %w", err) + } + + if isSwagger(spec) { + return parseSwagger(spec) + } + + return parseOpenAPI3(spec) +} + +func parseSwagger(spec []byte) (*openapi3.T, error) { + spec, err := yaml.YAMLToJSON(spec) + if err != nil { + return nil, fmt.Errorf("failed to convert YAML to JSON: %w", err) + } + + var swaggerSpec openapi2.T + + err = swaggerSpec.UnmarshalJSON(spec) + if err != nil { + return nil, fmt.Errorf("failed to unmarshal Swagger: %w", err) + } + + return openapi2conv.ToV3(&swaggerSpec) +} + +func parseOpenAPI3(spec []byte) (*openapi3.T, error) { + return openapi3.NewLoader().LoadFromData(spec) +} diff --git a/spec/spec_test.go b/spec/spec_test.go new file mode 100644 index 000000000..2588ef59f --- /dev/null +++ b/spec/spec_test.go @@ -0,0 +1,146 @@ +package spec + +import ( + "net/url" + "strings" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/require" +) + +const ( + loadedFromURI = "loaded from URI" + loadedFromFile = "loaded from file" +) + +type mockLoader struct{} + +func (m mockLoader) LoadFromURI(_ *url.URL) (*openapi3.T, error) { + return &openapi3.T{ + OpenAPI: "3.0.3", + Info: &openapi3.Info{ + Title: "Sample API", + Description: loadedFromURI, + Version: "1.0.0", + }, + }, nil +} + +func (m mockLoader) LoadFromFile(_ string) (*openapi3.T, error) { + return &openapi3.T{ + OpenAPI: "3.0.3", + Info: &openapi3.Info{ + Title: "Sample API", + Description: loadedFromFile, + Version: "1.0.0", + }, + }, nil +} + +func TestParse(t *testing.T) { + testCases := []struct { + name string + url string + result string + }{ + { + name: "load spec from url", + url: "https://someurl.io/swagger.yaml", + result: loadedFromURI, + }, + { + name: "load spec from local file", + url: "some-folder/swagger.yaml", + result: loadedFromFile, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + r := require.New(t) + + parser := Parser{loader: mockLoader{}} + u, err := url.Parse(testCase.url) + r.NoError(err, "please provide a valid url") + + actual, err := parser.Parse(u.String()) + r.NoError(err, "expected no error when running parse from mocked loader") + r.True(actual.Info.Description == testCase.result) + }) + } +} + +func TestParseFromReader(t *testing.T) { + testCases := []struct { + name string + spec string + result *openapi3.T + }{ + { + name: "swagger", + spec: `swagger: "2.0" +info: + title: Sample API + description: API description in Markdown. + version: 1.0.0 +paths: + /users: + get: {} +`, + result: &openapi3.T{ + OpenAPI: "3.0.3", + Info: &openapi3.Info{ + Title: "Sample API", + Description: "API description in Markdown.", + Version: "1.0.0", + }, + Paths: openapi3.Paths{ + "/users": &openapi3.PathItem{ + Get: &openapi3.Operation{}, + }, + }, + }, + }, + { + name: "openapi", + spec: `openapi: "3.0.3" +info: + title: Sample API + description: API description in Markdown. + version: 1.0.0 +paths: + /users: + get: {} +`, + result: &openapi3.T{ + OpenAPI: "3.0.3", + Info: &openapi3.Info{ + Title: "Sample API", + Description: "API description in Markdown.", + Version: "1.0.0", + }, + Paths: openapi3.Paths{ + "/users": &openapi3.PathItem{ + Get: &openapi3.Operation{}, + }, + }, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + r := require.New(t) + + actual, err := Parser{loader: openapi3.NewLoader()}.ParseFromReader(strings.NewReader(testCase.spec)) + r.NoError(err, "failed to parse spec from reader") + r.Equal(testCase.result.OpenAPI, actual.OpenAPI) + r.Equal(testCase.result.Info.Title, actual.Info.Title) + r.Equal(testCase.result.Info.Description, actual.Info.Description) + r.Equal(testCase.result.Info.Version, actual.Info.Version) + r.NotNil(testCase.result.Paths.Find("/users")) + }) + + } +}