From 21f9c46c464c32bd560f1933117fbf0dd3e2f328 Mon Sep 17 00:00:00 2001 From: Guilherme Cassolato Date: Thu, 30 May 2024 18:11:46 +0200 Subject: [PATCH] OpenAPI Provider (#157) * Base openapi provider implementation Signed-off-by: Guilherme Cassolato * Return without error from unimplemented ReadResourcesFromCluster func * Move slice helper functions Map nd Filter into the providers/common package * openapi package renamed openapi3 * thread-safe storage of specs * refactor: resource reader simplified as part of Provider and removing converter's unused fields * make provider resilient to invalid input openapi specs * Gateway and parentRefs * Declare github.com/getkin/kin-openapi as a direct dependency * Use github.com/samber/lo for handling slices based on common patterns (mapping, filtering), instead of custom implementation * Remove initialization of non converted kinds of resources TLSRoutes, TCPRoutes, ReferenceGrants * init func brought further upwards * code format * Provider-specific options --openapi3-backend and --openapi3-gateway-class-name Defined using a newly introduced system of dynamically registered provider-specific configuration flags. * provider-specific flag: --openapi3-gateway-tls-secret * ReferenceGrants for HTTPRoute to Backends and Gateway to TLS Secrets * fix: provider-specific configs for providers with dashes in the name * name Gateway and HTTPRoutes after the OAS title * fix: missing backend ref argument * update README * Support for backend port numbers * refactor: addressed comments from the pr * provider-specific conf renamed as provided-specific flags * mutex to read/write provider-specific flag definitions wrapped within a type along with the definitions themselves * minor string handling enhancements (concatenation, trim prefix) * additional comments explaining logics and reasoning throughout the code (thread-safety, helper funcs and expressions, etc) * log message in case of provider-specific flag supplied without a matching provider * more comments to explain the flow and decision of the converter * lint: typos, gofmt and false positives * return error in case of invalid OpenAPI 3.x spec --------- Signed-off-by: Guilherme Cassolato --- PROVIDER.md | 36 +- cmd/print.go | 35 +- cmd/print_test.go | 58 ++ go.mod | 10 +- go.sum | 17 + pkg/i2gw/ingress2gateway.go | 7 +- pkg/i2gw/provider.go | 51 +- pkg/i2gw/providers/openapi3/README.md | 334 ++++++++ pkg/i2gw/providers/openapi3/converter.go | 685 +++++++++++++++ pkg/i2gw/providers/openapi3/converter_test.go | 231 +++++ .../openapi3/fixtures/input/1-petstore3.yaml | 803 ++++++++++++++++++ .../openapi3/fixtures/input/2-hostnames.yaml | 91 ++ .../openapi3/fixtures/input/3-parameters.yaml | 60 ++ .../fixtures/input/4-too-many-rules.json | 138 +++ .../fixtures/input/5-invalid-spec.yaml | 19 + .../fixtures/input/6-reference-grants.yaml | 13 + .../openapi3/fixtures/output/1-petstore3.yaml | 111 +++ .../openapi3/fixtures/output/2-hostnames.yaml | 134 +++ .../fixtures/output/3-parameters.yaml | 45 + .../fixtures/output/4-too-many-rules.json | 272 ++++++ .../fixtures/output/5-invalid-spec.yaml | 0 .../fixtures/output/6-reference-grants.yaml | 67 ++ pkg/i2gw/providers/openapi3/openapi.go | 109 +++ pkg/i2gw/providers/openapi3/storage.go | 66 ++ 24 files changed, 3380 insertions(+), 12 deletions(-) create mode 100644 pkg/i2gw/providers/openapi3/README.md create mode 100644 pkg/i2gw/providers/openapi3/converter.go create mode 100644 pkg/i2gw/providers/openapi3/converter_test.go create mode 100644 pkg/i2gw/providers/openapi3/fixtures/input/1-petstore3.yaml create mode 100644 pkg/i2gw/providers/openapi3/fixtures/input/2-hostnames.yaml create mode 100644 pkg/i2gw/providers/openapi3/fixtures/input/3-parameters.yaml create mode 100644 pkg/i2gw/providers/openapi3/fixtures/input/4-too-many-rules.json create mode 100644 pkg/i2gw/providers/openapi3/fixtures/input/5-invalid-spec.yaml create mode 100644 pkg/i2gw/providers/openapi3/fixtures/input/6-reference-grants.yaml create mode 100644 pkg/i2gw/providers/openapi3/fixtures/output/1-petstore3.yaml create mode 100644 pkg/i2gw/providers/openapi3/fixtures/output/2-hostnames.yaml create mode 100644 pkg/i2gw/providers/openapi3/fixtures/output/3-parameters.yaml create mode 100644 pkg/i2gw/providers/openapi3/fixtures/output/4-too-many-rules.json create mode 100644 pkg/i2gw/providers/openapi3/fixtures/output/5-invalid-spec.yaml create mode 100644 pkg/i2gw/providers/openapi3/fixtures/output/6-reference-grants.yaml create mode 100644 pkg/i2gw/providers/openapi3/openapi.go create mode 100644 pkg/i2gw/providers/openapi3/storage.go diff --git a/PROVIDER.md b/PROVIDER.md index 54aea701..1de05c20 100644 --- a/PROVIDER.md +++ b/PROVIDER.md @@ -96,7 +96,7 @@ func newConverter(conf *i2gw.ProviderConf) *converter { } } ``` -4. Create a new struct named after the provider you are implementing. This struct should embed the previous 2 structs +4. Create a new struct named after the provider you are implementing. This struct should embed the previous 2 structs you created. ```go package examplegateway @@ -152,12 +152,12 @@ import ( In case you want to add support for the conversion of a specific feature within a provider (see for example the canary feature of ingress-nginx) you'll want to implement a `FeatureParser` function. -Different `FeatureParsers` within the same provider will run in undetermined order. This means that when building a +Different `FeatureParsers` within the same provider will run in undetermined order. This means that when building a `Gateway API` resource manifest, you cannot assume anything about previously initialized fields. The function must modify / create only the required fields of the resource manifest and nothing else. For example, lets say we are implementing the canary feature of some provider. When building the `HTTPRoute`, we cannot -assume that the `BackendRefs` is already initialized with every `BackendRef` required. The canary `FeatureParser` +assume that the `BackendRefs` is already initialized with every `BackendRef` required. The canary `FeatureParser` function must add every missing `BackendRef` and update existing ones. ### Testing the feature parser @@ -165,4 +165,32 @@ There are 2 main things that needs to be tested when creating a feature parser: 1. The conversion logic is actually correct. 2. The new function doesn't override other functions modifications. For example, if one implemented the mirror backend feature and it deletes canary weight from `BackendRefs`, we have a -problem. \ No newline at end of file +problem. + +## Provider-specific flags +To define provider-specific flags the user can supply in the `print` command, call the +`i2gw.RegisterProviderSpecificFlag(ProviderName, i2gw.ProviderSpecificFlag)` function in the init function of the +provider. E.g.: +```go +const Name = "example-gateway-provider" + +func init() { + i2gw.ProviderConstructorByName[Name] = NewProvider + + i2gw.RegisterProviderSpecificFlag(ProviderName, i2gw.ProviderSpecificFlag{ + Name: "infrastructure-labels", + Description: "Comma-separated list of Gateway infrastructure key=value labels", + DefaultValue: "", + }) +} +``` +Users can provide a value to the flag as follows: +```sh +./ingress2gateway print --providers=example-gateway-provider --example-gateway-provider-infrastructure-labels="app=my-app" +``` +The values of all provider-specific flags supplied by the user can be retrieved from the provider `conf`: +```go +if ps := conf.ProviderSpecificFlags[ProviderName]; ps != nil { + labels := ps["infrastructure-labels"] +} +``` diff --git a/cmd/print.go b/cmd/print.go index 997a3ed3..ec0a96ca 100644 --- a/cmd/print.go +++ b/cmd/print.go @@ -18,10 +18,12 @@ package cmd import ( "fmt" + "log" "os" "strings" "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw" + "github.com/samber/lo" "github.com/spf13/cobra" "k8s.io/cli-runtime/pkg/genericclioptions" "k8s.io/cli-runtime/pkg/printers" @@ -32,6 +34,7 @@ import ( _ "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/ingressnginx" _ "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/istio" _ "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/kong" + _ "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/openapi3" ) type PrintRunner struct { @@ -59,6 +62,9 @@ type PrintRunner struct { // providers indicates which providers are used to execute convert action. providers []string + + // Provider specific flags ---. + providerSpecificFlags map[string]*string } // PrintGatewayAPIObjects performs necessary steps to digest and print @@ -75,7 +81,7 @@ func (pr *PrintRunner) PrintGatewayAPIObjects(cmd *cobra.Command, _ []string) er return fmt.Errorf("failed to initialize namespace filter: %w", err) } - gatewayResources, err := i2gw.ToGatewayAPIResources(cmd.Context(), pr.namespaceFilter, pr.inputFile, pr.providers) + gatewayResources, err := i2gw.ToGatewayAPIResources(cmd.Context(), pr.namespaceFilter, pr.inputFile, pr.providers, pr.getProviderSpecificFlags()) if err != nil { return err } @@ -249,6 +255,14 @@ if specified with --namespace.`) cmd.Flags().StringSliceVar(&pr.providers, "providers", i2gw.GetSupportedProviders(), fmt.Sprintf("If present, the tool will try to convert only resources related to the specified providers, supported values are %v.", i2gw.GetSupportedProviders())) + pr.providerSpecificFlags = make(map[string]*string) + for provider, flags := range i2gw.GetProviderSpecificFlagDefinitions() { + for _, flag := range flags { + flagName := fmt.Sprintf("%s-%s", provider, flag.Name) + pr.providerSpecificFlags[flagName] = cmd.Flags().String(flagName, flag.DefaultValue, fmt.Sprintf("Provider-specific: %s. %s", provider, flag.Description)) + } + } + cmd.MarkFlagsMutuallyExclusive("namespace", "all-namespaces") return cmd } @@ -262,3 +276,22 @@ func getNamespaceInCurrentContext() (string, error) { return currentNamespace, err } + +// getProviderSpecificFlags returns the provider specific flags input by the user. +// The flags are returned in a map where the key is the provider name and the value is a map of flag name to flag value. +func (pr *PrintRunner) getProviderSpecificFlags() map[string]map[string]string { + providerSpecificFlags := make(map[string]map[string]string) + for flagName, value := range pr.providerSpecificFlags { + provider, found := lo.Find(pr.providers, func(p string) bool { return strings.HasPrefix(flagName, fmt.Sprintf("%s-", p)) }) + if !found { + log.Printf("Warning: Ignoring flag %s as it does not match any of the providers", flagName) + continue + } + flagNameWithoutProvider := strings.TrimPrefix(flagName, fmt.Sprintf("%s-", provider)) + if providerSpecificFlags[provider] == nil { + providerSpecificFlags[provider] = make(map[string]string) + } + providerSpecificFlags[provider][flagNameWithoutProvider] = *value + } + return providerSpecificFlags +} diff --git a/cmd/print_test.go b/cmd/print_test.go index 3c95dcc9..fc10d273 100644 --- a/cmd/print_test.go +++ b/cmd/print_test.go @@ -24,6 +24,7 @@ import ( "reflect" "testing" + "github.com/google/go-cmp/cmp" "k8s.io/cli-runtime/pkg/printers" ) @@ -236,3 +237,60 @@ func Test_getNamespaceInCurrentContext(t *testing.T) { actualNamespace, err, expectedNamespace, nil) } } + +func Test_getProviderSpecificFlags(t *testing.T) { + value1 := "value1" + value2 := "value2" + testCases := []struct { + name string + providerSpecificFlags map[string]*string + providers []string + expected map[string]map[string]string + }{ + { + name: "No provider specific configuration", + providerSpecificFlags: make(map[string]*string), + providers: []string{"provider"}, + expected: map[string]map[string]string{}, + }, + { + name: "Provider specific configuration matching provider in the list", + providerSpecificFlags: map[string]*string{"provider-conf": &value1}, + providers: []string{"provider"}, + expected: map[string]map[string]string{ + "provider": {"conf": value1}, + }, + }, + { + name: "Provider specific configuration matching providers in the list with multiple providers", + providerSpecificFlags: map[string]*string{ + "provider-a-conf1": &value1, + "provider-b-conf2": &value2, + }, + providers: []string{"provider-a", "provider-b", "provider-c"}, + expected: map[string]map[string]string{ + "provider-a": {"conf1": value1}, + "provider-b": {"conf2": value2}, + }, + }, + { + name: "Provider specific configuration not matching provider in the list", + providerSpecificFlags: map[string]*string{"provider-conf": &value1}, + providers: []string{"provider-a", "provider-b", "provider-c"}, + expected: map[string]map[string]string{}, + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + pr := PrintRunner{ + providerSpecificFlags: tc.providerSpecificFlags, + providers: tc.providers, + } + actual := pr.getProviderSpecificFlags() + if diff := cmp.Diff(tc.expected, actual); diff != "" { + t.Errorf("Unexpected provider-specific flags, \n want: %+v\n got: %+v\n diff (-want +got):\n%s", tc.expected, actual, diff) + } + }) + } +} diff --git a/go.mod b/go.mod index 63a073a1..04fb14a0 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/kubernetes-sigs/ingress2gateway go 1.21 require ( + github.com/getkin/kin-openapi v0.124.0 github.com/google/go-cmp v0.6.0 github.com/kong/kubernetes-ingress-controller/v2 v2.12.3 github.com/spf13/cobra v1.8.0 @@ -18,8 +19,13 @@ require ( ) require ( + github.com/invopop/yaml v0.2.0 // indirect github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/samber/lo v1.39.0 // indirect + golang.org/x/exp v0.0.0-20231006140011-7918f672742d // indirect ) require ( @@ -29,9 +35,9 @@ require ( github.com/evanphx/json-patch/v5 v5.7.0 // indirect github.com/go-errors/errors v1.5.1 // indirect github.com/go-logr/logr v1.3.0 // indirect - github.com/go-openapi/jsonpointer v0.20.0 // indirect + github.com/go-openapi/jsonpointer v0.20.2 // indirect github.com/go-openapi/jsonreference v0.20.2 // indirect - github.com/go-openapi/swag v0.22.4 // indirect + github.com/go-openapi/swag v0.22.8 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.3 // indirect github.com/google/btree v1.1.2 // indirect diff --git a/go.sum b/go.sum index 408d2739..c000507f 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/evanphx/json-patch/v5 v5.7.0 h1:nJqP7uwL84RJInrohHfW0Fx3awjbm8qZeFv0n github.com/evanphx/json-patch/v5 v5.7.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= +github.com/getkin/kin-openapi v0.124.0 h1:VSFNMB9C9rTKBnQ/fpyDU8ytMTr4dWI9QovSKj9kz/M= +github.com/getkin/kin-openapi v0.124.0/go.mod h1:wb1aSZA/iWmorQP9KTAS/phLj/t17B5jT7+fS8ed9NM= github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8bk= github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= github.com/go-logr/logr v1.3.0 h1:2y3SDp0ZXuc6/cjLSZ+Q3ir+QB9T/iG5yYRXqsagWSY= @@ -25,11 +27,15 @@ github.com/go-logr/zapr v1.2.4/go.mod h1:FyHWQIzQORZ0QVE1BtVHv3cKtNLuXsbNLtpuhNa github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= github.com/go-openapi/jsonpointer v0.20.0 h1:ESKJdU9ASRfaPNOPRx12IUyA1vn3R9GiE3KYD14BXdQ= github.com/go-openapi/jsonpointer v0.20.0/go.mod h1:6PGzBjjIIumbLYysB73Klnms1mwnU4G3YHOECG3CedA= +github.com/go-openapi/jsonpointer v0.20.2 h1:mQc3nmndL8ZBzStEo3JYF8wzmeWffDH4VbXz58sAx6Q= +github.com/go-openapi/jsonpointer v0.20.2/go.mod h1:bHen+N0u1KEO3YlmqOjTT9Adn1RfD91Ar825/PuiRVs= github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= +github.com/go-openapi/swag v0.22.8 h1:/9RjDSQ0vbFR+NyjGMkFTsA1IA0fmhKSThmfGZjicbw= +github.com/go-openapi/swag v0.22.8/go.mod h1:6QT22icPLEqAM/z/TChgb4WAveCHF92+2gF0CNjHpPI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572 h1:tfuBGBXKqDEevZMzYi5KSi8KkcZtzBcTgAUUtapy0OI= github.com/go-task/slim-sprig v0.0.0-20230315185526-52ccab3ef572/go.mod h1:9Pwr4B2jHnOSGXyyzV8ROjYa2ojvAY6HCGYYfMoC3Ls= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= @@ -51,6 +57,7 @@ github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1 h1:K6RDEckDVWvDI9JAJYCmNdQXq6neHJOYx3V6jnqNEec= github.com/google/pprof v0.0.0-20210720184732-4bb14d4b1be1/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= +github.com/google/pprof v0.0.0-20211214055906-6f57359322fd h1:1FjCyPC+syAzJ5/2S8fqdZK1R22vvA0J7JZKcuOIQ7Y= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4= @@ -61,6 +68,8 @@ github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/invopop/yaml v0.2.0 h1:7zky/qH+O0DwAyoobXUqvVBwgBFRxKoQ/3FjcVpjTMY= +github.com/invopop/yaml v0.2.0/go.mod h1:2XuRLgs/ouIrW3XNzuNj7J3Nvu/Dig5MXvbCEdiBN3Q= github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= @@ -87,6 +96,8 @@ github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 h1:n6/2gBQ3RWajuToeY6ZtZTIKv2v7ThUy5KKusIT0yc0= github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00/go.mod h1:Pm3mSP3c5uWn86xMLZ5Sa7JB9GsEZySvHYXCTK4E9q4= github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= @@ -96,6 +107,8 @@ github.com/onsi/ginkgo/v2 v2.11.0 h1:WgqUCUt/lT6yXoQ8Wef0fsNn5cAuMK7+KT9UFRz2tcU github.com/onsi/ginkgo/v2 v2.11.0/go.mod h1:ZhrRA5XmEE3x3rhlzamx/JJvujdZoJ2uvgI7kR0iZvM= github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= @@ -113,7 +126,10 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjRBZyWFQ= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/samber/lo v1.39.0 h1:4gTz1wUhNYLhFSKl6O+8peW0v2F4BCY034GRpU9WnuA= +github.com/samber/lo v1.39.0/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0= @@ -223,6 +239,7 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= istio.io/api v1.20.0 h1:heE1eQoMsuZlwWOf7Xm8TKqKLNKVs11G/zMe5QyR1u4= diff --git a/pkg/i2gw/ingress2gateway.go b/pkg/i2gw/ingress2gateway.go index 479408d5..dc4c7cef 100644 --- a/pkg/i2gw/ingress2gateway.go +++ b/pkg/i2gw/ingress2gateway.go @@ -30,7 +30,7 @@ import ( gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" ) -func ToGatewayAPIResources(ctx context.Context, namespace string, inputFile string, providers []string) ([]GatewayResources, error) { +func ToGatewayAPIResources(ctx context.Context, namespace string, inputFile string, providers []string, providerSpecificFlags map[string]map[string]string) ([]GatewayResources, error) { var clusterClient client.Client if inputFile == "" { @@ -47,8 +47,9 @@ func ToGatewayAPIResources(ctx context.Context, namespace string, inputFile stri } providerByName, err := constructProviders(&ProviderConf{ - Client: clusterClient, - Namespace: namespace, + Client: clusterClient, + Namespace: namespace, + ProviderSpecificFlags: providerSpecificFlags, }, providers) if err != nil { return nil, err diff --git a/pkg/i2gw/provider.go b/pkg/i2gw/provider.go index 1d7e6c61..c7141998 100644 --- a/pkg/i2gw/provider.go +++ b/pkg/i2gw/provider.go @@ -18,6 +18,7 @@ package i2gw import ( "context" + "sync" networkingv1 "k8s.io/api/networking/v1" "k8s.io/apimachinery/pkg/types" @@ -43,8 +44,9 @@ type ProviderConstructor func(conf *ProviderConf) Provider // ProviderConf contains all the configuration required for every concrete // Provider implementation. type ProviderConf struct { - Client client.Client - Namespace string + Client client.Client + Namespace string + ProviderSpecificFlags map[string]map[string]string } // The Provider interface specifies the required functionality which needs to be @@ -105,3 +107,48 @@ type GatewayResources struct { // Different FeatureParsers will run in undetermined order. The function must // modify / create only the required fields of the gateway resources and nothing else. type FeatureParser func([]networkingv1.Ingress, *GatewayResources) field.ErrorList + +var providerSpecificFlagDefinitions = providerSpecificFlags{ + flags: make(map[ProviderName]map[string]ProviderSpecificFlag), + mu: sync.RWMutex{}, +} + +type providerSpecificFlags struct { + flags map[ProviderName]map[string]ProviderSpecificFlag + mu sync.RWMutex // thread-safe, so provider-specific flags can be registered concurrently. +} + +type ProviderSpecificFlag struct { + Name string + Description string + DefaultValue string +} + +func (f *providerSpecificFlags) add(provider ProviderName, flag ProviderSpecificFlag) { + f.mu.Lock() + defer f.mu.Unlock() + if f.flags[provider] == nil { + f.flags[provider] = map[string]ProviderSpecificFlag{} + } + f.flags[provider][flag.Name] = flag +} + +func (f *providerSpecificFlags) all() map[ProviderName]map[string]ProviderSpecificFlag { + f.mu.RLock() + defer f.mu.RUnlock() + return f.flags +} + +// RegisterProviderSpecificFlag registers a provider-specific flag. +// Each provider-specific flag is exposed to the user as an optional command-line flag ---. +// If the flag is not provided, it is up to the provider to decide to use the default value or raise an error. +// The provider can read the values of provider-specific flags input by the user from the ProviderConf. +// RegisterProviderSpecificFlag is thread-safe. +func RegisterProviderSpecificFlag(provider ProviderName, flag ProviderSpecificFlag) { + providerSpecificFlagDefinitions.add(provider, flag) +} + +// GetProviderSpecificFlagDefinitions returns the provider specific confs registered by the providers. +func GetProviderSpecificFlagDefinitions() map[ProviderName]map[string]ProviderSpecificFlag { + return providerSpecificFlagDefinitions.all() +} diff --git a/pkg/i2gw/providers/openapi3/README.md b/pkg/i2gw/providers/openapi3/README.md new file mode 100644 index 00000000..f8bbba79 --- /dev/null +++ b/pkg/i2gw/providers/openapi3/README.md @@ -0,0 +1,334 @@ +# OpenAPI Provider + +The provider translates OpenAPI Specification (OAS) 3.x documents to Kubernetes Gateway API resources – Gateway, HTTPRoutes and ReferenceGrants. + +## Usage + +```sh +./ingress2gateway print --providers=openapi3 --input-file=FILEPATH +``` + +Where `FILEPATH` is the path to a file containing a valid OpenAPI Specification in YAML or JSON format. + +**Gateway class name** + +To specify the name of the gateway class for the Gateway resources, use `--openapi3-gateway-class-name=NAME`. + +**Gateways with TLS configuration** + +If one or more servers specified in the OAS start with `https`, TLS configuration will be added to the corresponding gateway listener. + +To specify the reference to the gateway TLS secret, use `--openapi3-gateway-tls-secret=SECRET-NAME` or `--openapi3-gateway-tls-secret=SECRET-NAMESPACE/SECRET-NAME`. + +**Backend references** + +All routes generated will point to a single backend service. + +To specify the backend reference, use `--openapi3-backend=[namespace/]name[:port]`. Examples of valid values: +* `my-service` +* `my-namespace/my-service` +* `my-service:3000` +* `my-namespace/my-service:3000` + +**Resource names** + +Gateway and HTTPRoute names are prefixed with the [title](https://swagger.io/specification/v3/) of the OAS converted to Kubernetes object name format. + +In case of multiple resources of a kind, the names are suffixed with the corresponding sequential number from 1. + +In all cases, ensure the title of the OAS is not long enough that would cause invalid [Kubernetes object names](https://kubernetes.io/docs/concepts/overview/working-with-objects/names/). + +## Examples + +The examples below are based on the [Swagger Petstore Sample API](https://petstore3.swagger.io). + +```sh +./ingress2gateway print --providers=openapi3 \ + --openapi3-gateway-class-name=istio \ + --openapi3-backend=my-app:3000 \ + --input-file=petstore3-openapi.json +``` + +
+ Expected output + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + creationTimestamp: null + name: swagger-petstore-openapi-3-0-gateway + namespace: default +spec: + gatewayClassName: istio + listeners: + - hostname: '*' + name: http + port: 80 + protocol: HTTP +status: {} +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + creationTimestamp: null + name: swagger-petstore-openapi-3-0-route + namespace: default +spec: + parentRefs: + - name: swagger-petstore-openapi-3-0-gateway + rules: + - backendRefs: + - name: my-app + port: 3000 + matches: + - method: POST + path: + type: Exact + value: /api/v3/pet + - method: PUT + path: + type: Exact + value: /api/v3/pet + - method: GET + path: + type: Exact + value: /api/v3/pet/findByStatus + - method: GET + path: + type: Exact + value: /api/v3/pet/findByTags + - method: DELETE + path: + type: Exact + value: /api/v3/pet/{petId} + - method: GET + path: + type: Exact + value: /api/v3/pet/{petId} + - method: POST + path: + type: Exact + value: /api/v3/pet/{petId} + - method: POST + path: + type: Exact + value: /api/v3/pet/{petId}/uploadImage + - backendRefs: + - name: my-app + port: 3000 + matches: + - method: GET + path: + type: Exact + value: /api/v3/store/inventory + - method: POST + path: + type: Exact + value: /api/v3/store/order + - method: DELETE + path: + type: Exact + value: /api/v3/store/order/{orderId} + - method: GET + path: + type: Exact + value: /api/v3/store/order/{orderId} + - method: POST + path: + type: Exact + value: /api/v3/user + - method: POST + path: + type: Exact + value: /api/v3/user/createWithList + - method: GET + path: + type: Exact + value: /api/v3/user/login + - method: GET + path: + type: Exact + value: /api/v3/user/logout + - backendRefs: + - name: my-app + port: 3000 + matches: + - method: DELETE + path: + type: Exact + value: /api/v3/user/{username} + - method: GET + path: + type: Exact + value: /api/v3/user/{username} + - method: PUT + path: + type: Exact + value: /api/v3/user/{username} +status: + parents: null +``` + +
+ +ReferenceGrants are only generated if a namespace is specified and/or the references to gateway TLS secrets or backends do not match the target namespace, which can occasionally unspecified. E.g.: + +```sh +./ingress2gateway print --providers=openapi3 \ + --namespace=networking \ + --openapi3-gateway-class-name=istio \ + --openapi3-backend=apps/my-app \ + --input-file=petstore3-openapi.json +``` + +
+ Expected output + +```yaml +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + creationTimestamp: null + name: swagger-petstore-openapi-3-0-gateway + namespace: networking +spec: + gatewayClassName: istio + listeners: + - hostname: '*' + name: http + port: 80 + protocol: HTTP +status: {} +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + creationTimestamp: null + name: swagger-petstore-openapi-3-0-route + namespace: networking +spec: + parentRefs: + - name: swagger-petstore-openapi-3-0-gateway + rules: + - backendRefs: + - name: my-app + namespace: apps + matches: + - method: POST + path: + type: Exact + value: /api/v3/pet + - method: PUT + path: + type: Exact + value: /api/v3/pet + - method: GET + path: + type: Exact + value: /api/v3/pet/findByStatus + - method: GET + path: + type: Exact + value: /api/v3/pet/findByTags + - method: DELETE + path: + type: Exact + value: /api/v3/pet/{petId} + - method: GET + path: + type: Exact + value: /api/v3/pet/{petId} + - method: POST + path: + type: Exact + value: /api/v3/pet/{petId} + - method: POST + path: + type: Exact + value: /api/v3/pet/{petId}/uploadImage + - backendRefs: + - name: my-app + namespace: apps + matches: + - method: GET + path: + type: Exact + value: /api/v3/store/inventory + - method: POST + path: + type: Exact + value: /api/v3/store/order + - method: DELETE + path: + type: Exact + value: /api/v3/store/order/{orderId} + - method: GET + path: + type: Exact + value: /api/v3/store/order/{orderId} + - method: POST + path: + type: Exact + value: /api/v3/user + - method: POST + path: + type: Exact + value: /api/v3/user/createWithList + - method: GET + path: + type: Exact + value: /api/v3/user/login + - method: GET + path: + type: Exact + value: /api/v3/user/logout + - backendRefs: + - name: my-app + namespace: apps + matches: + - method: DELETE + path: + type: Exact + value: /api/v3/user/{username} + - method: GET + path: + type: Exact + value: /api/v3/user/{username} + - method: PUT + path: + type: Exact + value: /api/v3/user/{username} +status: + parents: null +--- +apiVersion: gateway.networking.k8s.io/v1beta1 +kind: ReferenceGrant +metadata: + creationTimestamp: null + name: from-networking-to-service-my-app + namespace: apps +spec: + from: + - group: gateway.networking.k8s.io + kind: HTTPRoute + namespace: networking + to: + - group: "" + kind: Service + name: my-app +``` +
+ +## Limitations + +* Only offline translation supported – i.e. `--input-file` is required +* An input file can only declare one OpenAPI Specification +* All API operation [paths](https://swagger.io/specification/v3/#paths-object) are treated as `Exact` type – i.e. no support for [path templating](https://swagger.io/specification/v3/#path-templating), therefore no `PathPrefix`, nor `RegularExpression` path types output +* Limited support for [parameters](https://swagger.io/specification/v3/#parameter-object) – only required `header` and `query` parameters supported +* Limited support to [server variables](https://swagger.io/specification/v3/#server-variable-object) – only limited sets (`enum`) supported +* No support to [references](https://swagger.io/specification/v3/#reference-object) (`$ref`) +* No support to [external documents](https://swagger.io/specification/v3/#external-documentation-object) +* OpenAPI Specification with a large number of server combinations may generate Gateway resources with more listeners than allowed + +Additionally, no support to any OpenAPI feature with no direct equivalent to core Gateway API fields, such as [request bodies](https://swagger.io/specification/v3/#request-body-object), [examples](https://swagger.io/specification/v3/#example-object), [security schemes](https://swagger.io/specification/v3/#security-scheme-object), [callbacks](https://swagger.io/specification/v3/#callback-object), [extensions](https://swagger.io/specification/v3/#specification-extensions), etc. diff --git a/pkg/i2gw/providers/openapi3/converter.go b/pkg/i2gw/providers/openapi3/converter.go new file mode 100644 index 00000000..1b559c44 --- /dev/null +++ b/pkg/i2gw/providers/openapi3/converter.go @@ -0,0 +1,685 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package openapi3 + +import ( + "fmt" + "log" + "regexp" + "slices" + "sort" + "strconv" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/samber/lo" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/types" + "k8s.io/apimachinery/pkg/util/validation/field" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/common" +) + +const ( + HostWildcard = "*" + HostSeparator = "," + ParamSeparator = "," + + HTTPRouteRulesMax = 16 + HTTPRouteMatchesMax = 8 + HTTPRouteMatchesMaxMax = HTTPRouteRulesMax * HTTPRouteMatchesMax +) + +// uriRegexp allows parsing HTTP URIs where, for each string submatch, the following values are returned +// respectively to each index position in the slice: +// +// 0: full match +// 1: full match without the path +// 2: http scheme +// 3: host name +// 4: path +var uriRegexp = regexp.MustCompile(`^((https?)://([^/]+))?(/.*)?$`) + +type Converter interface { + Convert(Storage) (i2gw.GatewayResources, field.ErrorList) +} + +// NewConverter returns a converter of OpenAPI Specifications 3.x from a storage into Gateway API resources. +func NewConverter(conf *i2gw.ProviderConf) Converter { + converter := &converter{ + namespace: conf.Namespace, + tlsSecretRef: types.NamespacedName{}, + backendRef: toBackendRef(""), + } + + if ps := conf.ProviderSpecificFlags[ProviderName]; ps != nil { + converter.gatewayClassName = ps[GatewayClassFlag] + converter.tlsSecretRef = toNamespacedName(ps[TLSSecretFlag]) + converter.backendRef = toBackendRef(ps[BackendFlag]) + } + + return converter +} + +type backendRef struct { + types.NamespacedName + port *gatewayv1.PortNumber +} + +type converter struct { + namespace string + gatewayClassName string + tlsSecretRef types.NamespacedName + backendRef backendRef +} + +var _ Converter = &converter{} + +func (c *converter) Convert(storage Storage) (i2gw.GatewayResources, field.ErrorList) { + gatewayResources := i2gw.GatewayResources{ + Gateways: make(map[types.NamespacedName]gatewayv1.Gateway), + HTTPRoutes: make(map[types.NamespacedName]gatewayv1.HTTPRoute), + ReferenceGrants: make(map[types.NamespacedName]gatewayv1beta1.ReferenceGrant), + } + + var errors field.ErrorList + resourcesNamePrefixes := make(map[string]int) + + for _, spec := range storage.GetResources() { + // prefixes all resource names with the title of the spec to avoid conflicts between resources from different specs + // in case of multiple specs with the same title, a counter, starting at 1, is appended to the prefix from the 2nd + // spec and onwards + resourcesNamePrefix := toResourcesNamePrefix(spec) + if _, exists := resourcesNamePrefixes[resourcesNamePrefix]; !exists { + resourcesNamePrefixes[resourcesNamePrefix] = 0 + } + resourcesNamePrefixes[resourcesNamePrefix]++ + if resourcesNamePrefixes[resourcesNamePrefix] > 1 { + resourcesNamePrefix = fmt.Sprintf("%s-%d", resourcesNamePrefix, resourcesNamePrefixes[resourcesNamePrefix]+1) + } + + // convert the spec to Gateway API resources + httpRoutes, gateways := c.toHTTPRoutesAndGateways(spec, resourcesNamePrefix, errors) + for _, httpRoute := range httpRoutes { + gatewayResources.HTTPRoutes[types.NamespacedName{Name: httpRoute.GetName(), Namespace: httpRoute.GetNamespace()}] = httpRoute + } + + // build reference grants for the resources + if referenceGrant := c.buildHTTPRouteBackendReferenceGrant(); referenceGrant != nil { + gatewayResources.ReferenceGrants[types.NamespacedName{Name: referenceGrant.GetName(), Namespace: referenceGrant.GetNamespace()}] = *referenceGrant + } + for _, gateway := range gateways { + gatewayResources.Gateways[types.NamespacedName{Name: gateway.GetName(), Namespace: gateway.GetNamespace()}] = gateway + if referenceGrant := c.buildGatewayTLSSecretReferenceGrant(gateway); referenceGrant != nil { + gatewayResources.ReferenceGrants[types.NamespacedName{Name: referenceGrant.GetName(), Namespace: referenceGrant.GetNamespace()}] = *referenceGrant + } + } + } + + return gatewayResources, errors +} + +// toHTTPRoutesAndGateways converts an OpenAPI Specification 3.x to Gateway API HTTPRoutes and Gateways. +func (c *converter) toHTTPRoutesAndGateways(spec *openapi3.T, resourcesNamePrefix string, errors field.ErrorList) ([]gatewayv1.HTTPRoute, []gatewayv1.Gateway) { + var matchers []httpRouteMatcher + + servers := spec.Servers + if len(servers) == 0 { + servers = openapi3.Servers{{URL: "/"}} + } + + // get a list of http matchers for all path items in the spec. + // servers are expanded to account for all enum variables + paths := spec.Paths.Map() + for _, relativePath := range spec.Paths.InMatchingOrder() { + pathItem := paths[relativePath] + matchers = append(matchers, pathItemToHTTPMatchers(pathItem, relativePath, servers, errors)...) + } + + // group each expected listener (given by the hostnames) by the sets of http matchers related to the listener + listenersByHTTPRouteRuleMatcher := make(map[httpRouteRuleMatcher][]string) + for _, matcher := range matchers { + listener := fmt.Sprintf("%s://%s", matcher.protocol, matcher.host) + listenersByHTTPRouteRuleMatcher[matcher.httpRouteRuleMatcher] = append(listenersByHTTPRouteRuleMatcher[matcher.httpRouteRuleMatcher], listener) + } + + // invert the grouping into a map of listener groups as keys and their corresponding common http matchers as values + var listenerGroups []string + httpRouteRuleMatchersByListeners := make(map[string]httpRouteRuleMatchers) + for matcher, listeners := range listenersByHTTPRouteRuleMatcher { + group := strings.Join(listeners, HostSeparator) + if _, exists := httpRouteRuleMatchersByListeners[group]; !exists { + listenerGroups = append(listenerGroups, group) + } + httpRouteRuleMatchersByListeners[group] = append(httpRouteRuleMatchersByListeners[group], matcher) + } + + // sort listener groups for deterministic output + sort.Strings(listenerGroups) + + // build the gateway object + gatewayName := fmt.Sprintf("%s-gateway", resourcesNamePrefix) + gateway := gatewayv1.Gateway{ + ObjectMeta: metav1.ObjectMeta{ + Name: gatewayName, + }, + Spec: gatewayv1.GatewaySpec{ + GatewayClassName: gatewayv1.ObjectName(c.gatewayClassName), + }, + } + gateway.SetGroupVersionKind(common.GatewayGVK) + if c.namespace != "" { + gateway.SetNamespace(c.namespace) + } + + // declare unique listeners in the gateway for each hostname in the listener groups + uniqueListeners := make(map[string]struct{}) + for _, group := range listenerGroups { + listeners := lo.Filter(strings.Split(group, HostSeparator), func(listener string, _ int) bool { + _, exists := uniqueListeners[listener] + if !exists { + uniqueListeners[listener] = struct{}{} + } + return !exists + }) + gateway.Spec.Listeners = append(gateway.Spec.Listeners, lo.Map(listeners, c.toListener)...) // TODO: gateways cannot have more than 64 listeners + } + + var routes []gatewayv1.HTTPRoute + + // build the unique backend reference to be used in all route rules + backendRefs := []gatewayv1.HTTPBackendRef{ + gatewayv1.HTTPBackendRef{ //nolint:gofmt + BackendRef: gatewayv1.BackendRef{ + BackendObjectReference: gatewayv1.BackendObjectReference{ + Name: gatewayv1.ObjectName(c.backendRef.Name), + }, + }, + }, + } + if ns := c.backendRef.Namespace; ns != "" { + backendRefs[0].Namespace = common.PtrTo(gatewayv1.Namespace(ns)) + } + if port := c.backendRef.port; port != nil { + backendRefs[0].Port = port + } + + // build the HTTPRoutes respectively to the listener groups + i := 0 + for _, group := range listenerGroups { + listeners := strings.Split(group, HostSeparator) + hosts := lo.Map(listeners, uriToHostname) + matchers := httpRouteRuleMatchersByListeners[group] + + var listenerName gatewayv1.SectionName + if len(uniqueListeners) > 1 && len(listeners) == 1 { + listenerName, _, _ = toListenerName(listeners[0]) + } + + // sort hostnames and matchers for deterministic output inside each route object + sort.Sort(matchers) + sort.Strings(hosts) + hosts = slices.Compact(hosts) + + // split the matchers into nRoutes HTTPRoutes, each with a maximum of HTTPRouteMatchesMaxMax matchers + nMatchers := len(matchers) + nRoutes := nMatchers / HTTPRouteMatchesMaxMax + if nMatchers%HTTPRouteMatchesMaxMax != 0 { + nRoutes++ + } + for j := 0; j < nRoutes; j++ { + // generate a unique name for the route object + routeName := fmt.Sprintf("%s-route", resourcesNamePrefix) + if len(listenerGroups) > 1 { + routeName = fmt.Sprintf("%s-%d", routeName, i+1) // appends a grouping counter to the route name, starting at 1, if there are multiple listener groups, to avoid conflicts + } + if nRoutes > 1 { + routeName = fmt.Sprintf("%s-%d", routeName, j+1) // appends a counter to the route name, starting at 1, if there are more multiple routes, to avoid conflicts + } + last := (j + 1) * HTTPRouteMatchesMaxMax + if last > nMatchers { + last = nMatchers + } + // build the route object for the given slice of route matchers + routes = append(routes, c.toHTTPRoute(routeName, gatewayName, listenerName, hosts, matchers[j*HTTPRouteMatchesMaxMax:last], backendRefs)) + } + i++ + } + + return routes, []gatewayv1.Gateway{gateway} +} + +// toListener converts a http scheme (protocol) and host string to a Gateway API Listener. +// The listener name is derived from the protocol and hostname. +// The listener port is assumed 80 for http protocol and 443 for https. +// If the protocol is https, the listener TLS configuration is set from the general TLS secret reference. +func (c *converter) toListener(protocolAndHostname string, _ int) gatewayv1.Listener { + name, protocol, hostname := toListenerName(protocolAndHostname) + + listener := gatewayv1.Listener{ + Name: name, + Protocol: gatewayv1.ProtocolType(strings.ToUpper(protocol)), + Hostname: common.PtrTo(gatewayv1.Hostname(hostname)), + } + + switch protocol { + case "http": + listener.Port = 80 + case "https": + listener.Port = 443 + + tlsSecretRef := gatewayv1.SecretObjectReference{ + Name: gatewayv1.ObjectName(c.tlsSecretRef.Name), + } + if c.tlsSecretRef.Namespace != "" { + tlsSecretRef.Namespace = common.PtrTo(gatewayv1.Namespace(c.tlsSecretRef.Namespace)) + } + + listener.TLS = &gatewayv1.GatewayTLSConfig{ + CertificateRefs: []gatewayv1.SecretObjectReference{tlsSecretRef}, + } + } + + return listener +} + +// toListenerName extract a listener name, protocol and hostname from a protocol (http scheme) and hostname string. +// If the protocol is not provided, "http" is assumed by default. +// If the hostname is not provided, "*" is assumed by default. +func toListenerName(protocolAndHostname string) (listenerName gatewayv1.SectionName, protocol string, hostname string) { + protocol = "http" + hostname = HostWildcard + + if s := uriRegexp.FindAllStringSubmatch(protocolAndHostname, 1); len(s) > 0 { + if s[0][2] != "" { + protocol = s[0][2] + } + if s[0][3] != "" { + hostname = s[0][3] + } + } + + var listenerNamePrefix string + if hostname != HostWildcard { + listenerNamePrefix = fmt.Sprintf("%s-", common.NameFromHost(hostname)) + } + + return gatewayv1.SectionName(listenerNamePrefix + protocol), protocol, hostname +} + +// toHTTPRoute builds a Gateway API HTTPRoute object with a given name, for a given gateway parent, set of hostnames, +// and HTTP route matchers out of which HTTPRouteMatches are built for the rules. +// All HTTPRouteRules in the HTTPRoute are built with the same set of backendRefs, provided as argument. +func (c *converter) toHTTPRoute(name, gatewayName string, listenerName gatewayv1.SectionName, hostnames []string, matchers httpRouteRuleMatchers, backendRefs []gatewayv1.HTTPBackendRef) gatewayv1.HTTPRoute { + parentRef := gatewayv1.ParentReference{Name: gatewayv1.ObjectName(gatewayName)} + if listenerName != "" { + parentRef.SectionName = common.PtrTo(listenerName) + } + route := gatewayv1.HTTPRoute{ + TypeMeta: metav1.TypeMeta{ + APIVersion: gatewayv1.GroupVersion.String(), + Kind: "HTTPRoute", + }, + ObjectMeta: metav1.ObjectMeta{ + Name: name, + }, + Spec: gatewayv1.HTTPRouteSpec{ + CommonRouteSpec: gatewayv1.CommonRouteSpec{ + ParentRefs: []gatewayv1.ParentReference{parentRef}, + }, + Rules: toHTTPRouteRules(matchers, backendRefs), + }, + } + if c.namespace != "" { + route.SetNamespace(c.namespace) + } + if len(hostnames) > 1 || !slices.Contains(hostnames, HostWildcard) { + route.Spec.Hostnames = lo.Map(hostnames, toGatewayAPIHostname) + } + return route +} + +// buildHTTPRouteBackendReferenceGrant builds a Gateway API ReferenceGrant object for the general backend reference +// to be used in all HTTPRoute rules. +func (c *converter) buildHTTPRouteBackendReferenceGrant() *gatewayv1beta1.ReferenceGrant { + return c.buildReferenceGrant(common.HTTPRouteGVK, gatewayv1.Kind("Service"), c.backendRef.NamespacedName) +} + +// buildGatewayTLSSecretReferenceGrant builds a Gateway API ReferenceGrant object for the general TLS secret +// reference to be used in all https gateway listeners. +func (c *converter) buildGatewayTLSSecretReferenceGrant(gateway gatewayv1.Gateway) *gatewayv1beta1.ReferenceGrant { + if slices.IndexFunc(gateway.Spec.Listeners, func(listener gatewayv1.Listener) bool { return listener.TLS != nil }) == -1 { + return nil + } + return c.buildReferenceGrant(common.GatewayGVK, gatewayv1.Kind("Secret"), c.tlsSecretRef) +} + +// buildReferenceGrant builds a Gateway API ReferenceGrant object for a given source and destination resource. +// The name of the reference grant is derived from the source resource namespace and the destination resource kind and name. +func (c *converter) buildReferenceGrant(fromGVK schema.GroupVersionKind, toKind gatewayv1.Kind, toRef types.NamespacedName) *gatewayv1beta1.ReferenceGrant { + if c.namespace == "" || toRef.Namespace == "" { + return nil + } + rg := &gatewayv1beta1.ReferenceGrant{ + ObjectMeta: metav1.ObjectMeta{ + Name: fmt.Sprintf("from-%s-to-%s-%s", c.namespace, strings.ToLower(string(toKind)), toRef.Name), + Namespace: toRef.Namespace, + }, + Spec: gatewayv1beta1.ReferenceGrantSpec{ + From: []gatewayv1beta1.ReferenceGrantFrom{ + { + Group: gatewayv1.Group(fromGVK.Group), + Kind: gatewayv1.Kind(fromGVK.Kind), + Namespace: gatewayv1.Namespace(c.namespace), + }, + }, + To: []gatewayv1beta1.ReferenceGrantTo{ + { + Kind: toKind, + Name: common.PtrTo(gatewayv1.ObjectName(toRef.Name)), + }, + }, + }, + } + rg.SetGroupVersionKind(common.ReferenceGrantGVK) + return rg +} + +// httpRouteRuleMatcher is abstraction from which to build Gateway API HTTPRouteRules. +type httpRouteRuleMatcher struct { + path string + method string + headers string + params string +} + +type httpRouteRuleMatchers []httpRouteRuleMatcher + +func (m httpRouteRuleMatchers) Len() int { return len(m) } +func (m httpRouteRuleMatchers) Swap(i, j int) { m[i], m[j] = m[j], m[i] } +func (m httpRouteRuleMatchers) Less(i, j int) bool { + if m[i].path != m[j].path { + return m[i].path < m[j].path + } + return m[i].method < m[j].method +} + +// httpRouteMatcher is an abstraction used to associate a http route match to a hostname and protocol that +// will be used to build gateway listeners and references from the routes. +type httpRouteMatcher struct { + protocol string + host string + httpRouteRuleMatcher +} + +// toHTTPRouteRules builds Gateway API HTTPRouteRules from a list of httpRouteRuleMatchers and fixed backendRefs. +func toHTTPRouteRules(matchers httpRouteRuleMatchers, backendRefs []gatewayv1.HTTPBackendRef) []gatewayv1.HTTPRouteRule { + var rules []gatewayv1.HTTPRouteRule + + // split the matchers into nRules HTTPRouteRules, each with a maximum of HTTPRouteMatchesMax matchers + nMatches := len(matchers) + nRules := nMatches / HTTPRouteMatchesMax + if len(matchers)%HTTPRouteMatchesMax != 0 { + nRules++ + } + + for i := 0; i < nRules; i++ { + rule := gatewayv1.HTTPRouteRule{ + BackendRefs: backendRefs, + } + offfset := i * HTTPRouteMatchesMax + for j := 0; j < HTTPRouteMatchesMax && offfset+j < nMatches; j++ { + matcher := matchers[offfset+j] + ruleMatch := gatewayv1.HTTPRouteMatch{ + Path: &gatewayv1.HTTPPathMatch{ + Type: common.PtrTo(gatewayv1.PathMatchExact), + Value: &matcher.path, + }, + Method: common.PtrTo(gatewayv1.HTTPMethod(matcher.method)), + } + if matcher.headers != "" { + ruleMatch.Headers = lo.Map(strings.Split(matcher.headers, ParamSeparator), func(header string, _ int) gatewayv1.HTTPHeaderMatch { + return gatewayv1.HTTPHeaderMatch{ + Name: gatewayv1.HTTPHeaderName(header), + Type: common.PtrTo(gatewayv1.HeaderMatchExact), + } + }) + } + if matcher.params != "" { + ruleMatch.QueryParams = lo.Map(strings.Split(matcher.params, ParamSeparator), func(param string, _ int) gatewayv1.HTTPQueryParamMatch { + return gatewayv1.HTTPQueryParamMatch{ + Name: gatewayv1.HTTPHeaderName(param), + Type: common.PtrTo(gatewayv1.QueryParamMatchExact), + } + }) + } + rule.Matches = append(rule.Matches, ruleMatch) + } + rules = append(rules, rule) + } + return rules +} + +// pathItemToHTTPMatchers converts an OpenAPI Specification 3.x PathItem to a list of httpRouteMatchers. +// The servers are expanded to account for all enum variables. +func pathItemToHTTPMatchers(pathItem *openapi3.PathItem, relativePath string, servers openapi3.Servers, errors field.ErrorList) []httpRouteMatcher { + var matchers []httpRouteMatcher + + if len(pathItem.Servers) > 0 { + servers = pathItem.Servers + } + + operations := map[string]*openapi3.Operation{ + "CONNECT": pathItem.Connect, + "DELETE": pathItem.Delete, + "GET": pathItem.Get, + "HEAD": pathItem.Head, + "OPTIONS": pathItem.Options, + "PATCH": pathItem.Patch, + "POST": pathItem.Post, + "PUT": pathItem.Put, + "TRACE": pathItem.Trace, + } + + // build httpRouteMatchers for each operation of the path item + for method, operation := range operations { + if operation == nil { + continue + } + matchers = append(matchers, operationToHTTPMatchers(operation, relativePath, method, pathItem.Parameters, servers, errors)...) + } + + return matchers +} + +// pathItemToHTTPMatchers converts an OpenAPI Specification 3.x Operation (http method + relative path) to a list of +// httpRouteMatchers. The servers are expanded to account for all enum variables. +func operationToHTTPMatchers(operation *openapi3.Operation, relativePath string, method string, parameters openapi3.Parameters, servers openapi3.Servers, errors field.ErrorList) []httpRouteMatcher { + if operation.Servers != nil && len(*operation.Servers) > 0 { + servers = *operation.Servers + } + + if operation.Parameters != nil { + parameters = operation.Parameters + } + + var expandedServers []openapi3.Server + for _, server := range servers { + expandedServers = append(expandedServers, expandServerVariables(*server)...) + } + + // build httpRouteMatchers for each expanded server + return lo.Map(expandedServers, toHTTPMatcher(relativePath, method, parameters, errors)) +} + +// toHTTPMatcher converts a HTTP method and relative path to a httpRouteMatcher. +func toHTTPMatcher(relativePath string, method string, parameters openapi3.Parameters, errors field.ErrorList) func(server openapi3.Server, _ int) httpRouteMatcher { + paramNameFunc := func(in string) func(p *openapi3.ParameterRef, _ int) (string, bool) { + return func(p *openapi3.ParameterRef, _ int) (string, bool) { + if p.Value != nil && p.Value.Required && p.Value.In == in { + return p.Value.Name, true + } + return "", false + } + } + headers := strings.Join(lo.FilterMap(parameters, paramNameFunc("header")), ParamSeparator) + params := strings.Join(lo.FilterMap(parameters, paramNameFunc("query")), ParamSeparator) + + return func(server openapi3.Server, _ int) httpRouteMatcher { + basePath, err := server.BasePath() + if err != nil { + errors = append(errors, field.Invalid(field.NewPath("servers"), server, err.Error())) + } + if basePath == "/" { + basePath = "" + } + protocol := "http" + if s := uriRegexp.FindAllStringSubmatch(server.URL, 1); len(s) > 0 && s[0][2] != "" { + protocol = s[0][2] + } + return httpRouteMatcher{ + protocol: strings.ToLower(protocol), + host: uriToHostname(server.URL, 0), + httpRouteRuleMatcher: httpRouteRuleMatcher{ + path: basePath + relativePath, + method: method, + headers: headers, + params: params, + }, + } + } +} + +// expandNonEnumServerVariables expands all non-enum variables in an OpenAPI Specification 3.x Server. +// Each variable is replaced by its default value. +// Values other than the default for non-enum variables are not supported. +func expandNonEnumServerVariables(server openapi3.Server) openapi3.Server { + if len(server.Variables) == 0 { + return server + } + // non-enum variables + uri := server.URL + variables := make(map[string]*openapi3.ServerVariable) + for name, svar := range server.Variables { + if len(svar.Enum) > 0 { + variables[name] = svar + continue + } + uri = strings.ReplaceAll(uri, "{"+name+"}", svar.Default) + } + return openapi3.Server{ + URL: uri, + Variables: variables, + } +} + +// expandServerVariables expands an OpenAPI Specification 3.x Server into N servers with all enum variables resolved. +func expandServerVariables(server openapi3.Server) []openapi3.Server { + servers := []openapi3.Server{expandNonEnumServerVariables(server)} + for { + var newServers []openapi3.Server + for _, server := range servers { + if len(server.Variables) == 0 { + newServers = append(newServers, server) + continue + } + var name string + var svar *openapi3.ServerVariable + for name, svar = range server.Variables { + break + } + var uris []string + for _, enum := range svar.Enum { + uri := strings.ReplaceAll(server.URL, "{"+name+"}", enum) + uris = append(uris, uri) + } + variables := make(map[string]*openapi3.ServerVariable, len(server.Variables)-1) + for n, v := range server.Variables { + if n != name { + variables[n] = v + } + } + for _, uri := range uris { + newServers = append(newServers, openapi3.Server{ + URL: uri, + Variables: variables, + }) + } + } + servers = newServers + if slices.IndexFunc(servers, func(server openapi3.Server) bool { return len(server.Variables) > 0 }) == -1 { + break + } + } + return servers +} + +// uriToHostname converts a URI string to a hostname. +// If the URI does not contain a hostname, "*" is returned. +func uriToHostname(uri string, _ int) string { + host := HostWildcard + if s := uriRegexp.FindAllStringSubmatch(uri, 1); len(s) > 0 && s[0][3] != "" { + host = s[0][3] + } + return host +} + +// toGatewayAPIHostname converts a hostname string to a Gateway API Hostname. +func toGatewayAPIHostname(hostname string, _ int) gatewayv1.Hostname { + return gatewayv1.Hostname(hostname) +} + +// toResourcesNamePrefix returns a base common prefix for the names of the resources, from the title of a spec. +func toResourcesNamePrefix(spec *openapi3.T) string { + return strings.ToLower(common.NameFromHost(spec.Info.Title)) +} + +// toNamespacedName converts a string in the format "namespace/name" to a types.NamespacedName object. +func toNamespacedName(s string) types.NamespacedName { + if s == "" { + return types.NamespacedName{} + } + parts := strings.SplitN(s, "/", 2) + if len(parts) == 1 { + return types.NamespacedName{Name: parts[0]} + } + return types.NamespacedName{Namespace: parts[0], Name: parts[1]} +} + +// toBackendRef converts a backend reference string to a backendRef object, including namespaced reference to the +// Backend and port number if available. +func toBackendRef(s string) backendRef { + ref := backendRef{NamespacedName: types.NamespacedName{}} + if s == "" { + return ref + } + parts := strings.SplitN(s, ":", 2) + ref.NamespacedName = toNamespacedName(parts[0]) + if len(parts) > 1 { + port, err := strconv.ParseUint(parts[1], 10, 32) + if err != nil { + log.Printf("%s provider: invalid backend: %v", ProviderName, err) + return ref + } + ref.port = common.PtrTo(gatewayv1.PortNumber(port)) + } + return ref +} diff --git a/pkg/i2gw/providers/openapi3/converter_test.go b/pkg/i2gw/providers/openapi3/converter_test.go new file mode 100644 index 00000000..d994b8f7 --- /dev/null +++ b/pkg/i2gw/providers/openapi3/converter_test.go @@ -0,0 +1,231 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package openapi3 + +import ( + "bytes" + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" + "testing" + + apiequality "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/apimachinery/pkg/types" + gatewayv1 "sigs.k8s.io/gateway-api/apis/v1" + gatewayv1alpha2 "sigs.k8s.io/gateway-api/apis/v1alpha2" + gatewayv1beta1 "sigs.k8s.io/gateway-api/apis/v1beta1" + + "github.com/google/go-cmp/cmp" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw" + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw/providers/common" +) + +const fixturesDir = "./fixtures" + +func TestFileConvertion(t *testing.T) { + ctx := context.Background() + + type testData struct { + providerConf *i2gw.ProviderConf + expectedReadFileError error + } + + defaultTestData := testData{ + providerConf: &i2gw.ProviderConf{ + ProviderSpecificFlags: map[string]map[string]string{ + "openapi3": { + "gateway-class-name": "external", + "gateway-tls-secret": "gateway-tls-cert", + "backend": "backend-1:3000", + }, + }, + }, + } + + customTestData := map[string]testData{ + "reference-grants.yaml": { + providerConf: &i2gw.ProviderConf{ + Namespace: "networking", + ProviderSpecificFlags: map[string]map[string]string{ + "openapi3": { + "gateway-class-name": "external", + "gateway-tls-secret": "secrets/gateway-tls-cert", + "backend": "apps/backend-1", + }, + }, + }, + }, + "invalid-spec.yaml": { + expectedReadFileError: fmt.Errorf("failed to read resources from file: invalid OpenAPI 3.x spec"), + }, + } + + filepath.WalkDir(filepath.Join(fixturesDir, "input"), func(path string, d fs.DirEntry, err error) error { + if err != nil { + t.Fatalf(err.Error()) + } + if d.IsDir() { + return nil + } + + providerConf := defaultTestData.providerConf + expectedReadFileError := defaultTestData.expectedReadFileError + + inputFileName := regexp.MustCompile(`\d+-(.+\.(json|yaml))$`).FindAllStringSubmatch(d.Name(), -1)[0][1] + data, ok := customTestData[inputFileName] + if ok { + if data.providerConf != nil { + providerConf = data.providerConf + } + if data.expectedReadFileError != nil { + expectedReadFileError = data.expectedReadFileError + } + } + + provider := NewProvider(providerConf) + + if readFileErr := provider.ReadResourcesFromFile(ctx, path); readFileErr != nil { + if expectedReadFileError == nil { + t.Fatalf("unexpected error during reading test file %v: %v", d.Name(), readFileErr.Error()) + } else if !strings.Contains(readFileErr.Error(), expectedReadFileError.Error()) { + t.Fatalf("unexpected error during reading test file %v: '%v' does not contain expected '%v'", d.Name(), readFileErr.Error(), expectedReadFileError.Error()) + } else { + return nil // success + } + } else if expectedReadFileError != nil { + t.Fatalf("missing expected error during reading test file %v: %v", d.Name(), expectedReadFileError.Error()) + } + + gotGatewayResources, errList := provider.ToGatewayAPI() + if len(errList) > 0 { + t.Fatalf("unexpected errors during input conversion for file %v: %v", d.Name(), errList.ToAggregate().Error()) + } + + outputFile := filepath.Join(fixturesDir, "output", d.Name()) + wantGatewayResources, err := readGatewayResourcesFromFile(t, outputFile) + if err != nil { + t.Fatalf("failed to read wantGatewayResources from file %v: %v", outputFile, err.Error()) + } + + if !apiequality.Semantic.DeepEqual(gotGatewayResources.Gateways, wantGatewayResources.Gateways) { + t.Errorf("Gateways diff for file %v (-want +got): %s", d.Name(), cmp.Diff(wantGatewayResources.Gateways, gotGatewayResources.Gateways)) + } + + if !apiequality.Semantic.DeepEqual(gotGatewayResources.HTTPRoutes, wantGatewayResources.HTTPRoutes) { + t.Errorf("HTTPRoutes diff for file %v (-want +got): %s", d.Name(), cmp.Diff(wantGatewayResources.HTTPRoutes, gotGatewayResources.HTTPRoutes)) + } + + if !apiequality.Semantic.DeepEqual(gotGatewayResources.TLSRoutes, wantGatewayResources.TLSRoutes) { + t.Errorf("TLSRoutes diff for file %v (-want +got): %s", d.Name(), cmp.Diff(wantGatewayResources.TLSRoutes, gotGatewayResources.TLSRoutes)) + } + + if !apiequality.Semantic.DeepEqual(gotGatewayResources.TCPRoutes, wantGatewayResources.TCPRoutes) { + t.Errorf("TCPRoutes diff for file %v (-want +got): %s", d.Name(), cmp.Diff(wantGatewayResources.TCPRoutes, gotGatewayResources.TCPRoutes)) + } + + if !apiequality.Semantic.DeepEqual(gotGatewayResources.ReferenceGrants, wantGatewayResources.ReferenceGrants) { + t.Errorf("ReferenceGrants diff for file %v (-want +got): %s", d.Name(), cmp.Diff(wantGatewayResources.ReferenceGrants, gotGatewayResources.ReferenceGrants)) + } + + return nil + }) +} + +func readGatewayResourcesFromFile(t *testing.T, filename string) (*i2gw.GatewayResources, error) { + t.Helper() + + stream, err := os.ReadFile(filename) + if err != nil { + return nil, fmt.Errorf("failed to read file %v: %w", filename, err) + } + + unstructuredObjects, err := common.ExtractObjectsFromReader(bytes.NewReader(stream), "") + if err != nil { + return nil, fmt.Errorf("failed to extract objects: %w", err) + } + + res := i2gw.GatewayResources{ + Gateways: make(map[types.NamespacedName]gatewayv1.Gateway), + HTTPRoutes: make(map[types.NamespacedName]gatewayv1.HTTPRoute), + TLSRoutes: make(map[types.NamespacedName]gatewayv1alpha2.TLSRoute), + TCPRoutes: make(map[types.NamespacedName]gatewayv1alpha2.TCPRoute), + ReferenceGrants: make(map[types.NamespacedName]gatewayv1beta1.ReferenceGrant), + } + + for _, obj := range unstructuredObjects { + switch objKind := obj.GetKind(); objKind { + case "Gateway": + var gw gatewayv1.Gateway + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.UnstructuredContent(), &gw); err != nil { + return nil, fmt.Errorf("failed to parse k8s gateway object: %w", err) + } + res.Gateways[types.NamespacedName{ + Namespace: gw.Namespace, + Name: gw.Name, + }] = gw + case "HTTPRoute": + var httpRoute gatewayv1.HTTPRoute + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.UnstructuredContent(), &httpRoute); err != nil { + return nil, fmt.Errorf("failed to parse k8s gateway HTTPRoute object: %w", err) + } + + res.HTTPRoutes[types.NamespacedName{ + Namespace: httpRoute.Namespace, + Name: httpRoute.Name, + }] = httpRoute + case "TLSRoute": + var tlsRoute gatewayv1alpha2.TLSRoute + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.UnstructuredContent(), &tlsRoute); err != nil { + return nil, fmt.Errorf("failed to parse k8s gateway TLSRoute object: %w", err) + } + + res.TLSRoutes[types.NamespacedName{ + Namespace: tlsRoute.Namespace, + Name: tlsRoute.Name, + }] = tlsRoute + case "TCPRoute": + var tcpRoute gatewayv1alpha2.TCPRoute + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.UnstructuredContent(), &tcpRoute); err != nil { + return nil, fmt.Errorf("failed to parse k8s gateway TCPRoute object: %w", err) + } + + res.TCPRoutes[types.NamespacedName{ + Namespace: tcpRoute.Namespace, + Name: tcpRoute.Name, + }] = tcpRoute + case "ReferenceGrant": + var referenceGrant gatewayv1beta1.ReferenceGrant + if err := runtime.DefaultUnstructuredConverter.FromUnstructured(obj.UnstructuredContent(), &referenceGrant); err != nil { + return nil, fmt.Errorf("failed to parse k8s gateway ReferenceGrant object: %w", err) + } + + res.ReferenceGrants[types.NamespacedName{ + Namespace: referenceGrant.Namespace, + Name: referenceGrant.Name, + }] = referenceGrant + default: + return nil, fmt.Errorf("unknown object kind: %v", objKind) + } + } + + return &res, nil +} diff --git a/pkg/i2gw/providers/openapi3/fixtures/input/1-petstore3.yaml b/pkg/i2gw/providers/openapi3/fixtures/input/1-petstore3.yaml new file mode 100644 index 00000000..3eca6fb3 --- /dev/null +++ b/pkg/i2gw/providers/openapi3/fixtures/input/1-petstore3.yaml @@ -0,0 +1,803 @@ +openapi: 3.0.2 +info: + title: Swagger Petstore - OpenAPI 3.0 + description: |- + This is a sample Pet Store Server based on the OpenAPI 3.0 specification. You can find out more about + Swagger at [http://swagger.io](http://swagger.io). In the third iteration of the pet store, we've switched to the design first approach! + You can now help us improve the API whether it's by making changes to the definition itself or to the code. + That way, with time, we can improve the API in general, and expose some of the new features in OAS3. + + Some useful links: + - [The Pet Store repository](https://github.com/swagger-api/swagger-petstore) + - [The source API definition for the Pet Store](https://github.com/swagger-api/swagger-petstore/blob/master/src/main/resources/openapi.yaml) + termsOfService: http://swagger.io/terms/ + contact: + email: apiteam@swagger.io + license: + name: Apache 2.0 + url: http://www.apache.org/licenses/LICENSE-2.0.html + version: 1.0.19 +externalDocs: + description: Find out more about Swagger + url: http://swagger.io +servers: +- url: /api/v3 +tags: +- name: pet + description: Everything about your Pets + externalDocs: + description: Find out more + url: http://swagger.io +- name: store + description: Access to Petstore orders + externalDocs: + description: Find out more about our store + url: http://swagger.io +- name: user + description: Operations about user +paths: + /pet: + put: + tags: + - pet + summary: Update an existing pet + description: Update an existing pet by Id + operationId: updatePet + requestBody: + description: Update an existent pet in the store + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Pet' + required: true + responses: + "200": + description: Successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + "400": + description: Invalid ID supplied + "404": + description: Pet not found + "405": + description: Validation exception + security: + - petstore_auth: + - write:pets + - read:pets + post: + tags: + - pet + summary: Add a new pet to the store + description: Add a new pet to the store + operationId: addPet + requestBody: + description: Create a new pet in the store + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Pet' + required: true + responses: + "200": + description: Successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + "405": + description: Invalid input + security: + - petstore_auth: + - write:pets + - read:pets + /pet/findByStatus: + get: + tags: + - pet + summary: Finds Pets by status + description: Multiple status values can be provided with comma separated strings + operationId: findPetsByStatus + parameters: + - name: status + in: query + description: Status values that need to be considered for filter + required: false + explode: true + schema: + type: string + default: available + enum: + - available + - pending + - sold + responses: + "200": + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + "400": + description: Invalid status value + security: + - petstore_auth: + - write:pets + - read:pets + /pet/findByTags: + get: + tags: + - pet + summary: Finds Pets by tags + description: "Multiple tags can be provided with comma separated strings. Use\ + \ tag1, tag2, tag3 for testing." + operationId: findPetsByTags + parameters: + - name: tags + in: query + description: Tags to filter by + required: false + explode: true + schema: + type: array + items: + type: string + responses: + "200": + description: successful operation + content: + application/xml: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Pet' + "400": + description: Invalid tag value + security: + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}: + get: + tags: + - pet + summary: Find pet by ID + description: Returns a single pet + operationId: getPetById + parameters: + - name: petId + in: path + description: ID of pet to return + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Pet' + application/json: + schema: + $ref: '#/components/schemas/Pet' + "400": + description: Invalid ID supplied + "404": + description: Pet not found + security: + - api_key: [] + - petstore_auth: + - write:pets + - read:pets + post: + tags: + - pet + summary: Updates a pet in the store with form data + description: "" + operationId: updatePetWithForm + parameters: + - name: petId + in: path + description: ID of pet that needs to be updated + required: true + schema: + type: integer + format: int64 + - name: name + in: query + description: Name of pet that needs to be updated + schema: + type: string + - name: status + in: query + description: Status of pet that needs to be updated + schema: + type: string + responses: + "405": + description: Invalid input + security: + - petstore_auth: + - write:pets + - read:pets + delete: + tags: + - pet + summary: Deletes a pet + description: "" + operationId: deletePet + parameters: + - name: api_key + in: header + description: "" + required: false + schema: + type: string + - name: petId + in: path + description: Pet id to delete + required: true + schema: + type: integer + format: int64 + responses: + "400": + description: Invalid pet value + security: + - petstore_auth: + - write:pets + - read:pets + /pet/{petId}/uploadImage: + post: + tags: + - pet + summary: uploads an image + description: "" + operationId: uploadFile + parameters: + - name: petId + in: path + description: ID of pet to update + required: true + schema: + type: integer + format: int64 + - name: additionalMetadata + in: query + description: Additional Metadata + required: false + schema: + type: string + requestBody: + content: + application/octet-stream: + schema: + type: string + format: binary + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/ApiResponse' + security: + - petstore_auth: + - write:pets + - read:pets + /store/inventory: + get: + tags: + - store + summary: Returns pet inventories by status + description: Returns a map of status codes to quantities + operationId: getInventory + responses: + "200": + description: successful operation + content: + application/json: + schema: + type: object + additionalProperties: + type: integer + format: int32 + security: + - api_key: [] + /store/order: + post: + tags: + - store + summary: Place an order for a pet + description: Place a new order in the store + operationId: placeOrder + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + application/xml: + schema: + $ref: '#/components/schemas/Order' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/Order' + responses: + "200": + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/Order' + "405": + description: Invalid input + /store/order/{orderId}: + get: + tags: + - store + summary: Find purchase order by ID + description: For valid response try integer IDs with value <= 5 or > 10. Other + values will generate exceptions. + operationId: getOrderById + parameters: + - name: orderId + in: path + description: ID of order that needs to be fetched + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/Order' + application/json: + schema: + $ref: '#/components/schemas/Order' + "400": + description: Invalid ID supplied + "404": + description: Order not found + delete: + tags: + - store + summary: Delete purchase order by ID + description: For valid response try integer IDs with value < 1000. Anything + above 1000 or nonintegers will generate API errors + operationId: deleteOrder + parameters: + - name: orderId + in: path + description: ID of the order that needs to be deleted + required: true + schema: + type: integer + format: int64 + responses: + "400": + description: Invalid ID supplied + "404": + description: Order not found + /user: + post: + tags: + - user + summary: Create user + description: This can only be done by the logged in user. + operationId: createUser + requestBody: + description: Created user object + content: + application/json: + schema: + $ref: '#/components/schemas/User' + application/xml: + schema: + $ref: '#/components/schemas/User' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/User' + responses: + default: + description: successful operation + content: + application/json: + schema: + $ref: '#/components/schemas/User' + application/xml: + schema: + $ref: '#/components/schemas/User' + /user/createWithList: + post: + tags: + - user + summary: Creates list of users with given input array + description: Creates list of users with given input array + operationId: createUsersWithListInput + requestBody: + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + responses: + "200": + description: Successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/User' + application/json: + schema: + $ref: '#/components/schemas/User' + default: + description: successful operation + /user/login: + get: + tags: + - user + summary: Logs user into the system + description: "" + operationId: loginUser + parameters: + - name: username + in: query + description: The user name for login + required: false + schema: + type: string + - name: password + in: query + description: The password for login in clear text + required: false + schema: + type: string + responses: + "200": + description: successful operation + headers: + X-Rate-Limit: + description: calls per hour allowed by the user + schema: + type: integer + format: int32 + X-Expires-After: + description: date in UTC when token expires + schema: + type: string + format: date-time + content: + application/xml: + schema: + type: string + application/json: + schema: + type: string + "400": + description: Invalid username/password supplied + /user/logout: + get: + tags: + - user + summary: Logs out current logged in user session + description: "" + operationId: logoutUser + parameters: [] + responses: + default: + description: successful operation + /user/{username}: + get: + tags: + - user + summary: Get user by user name + description: "" + operationId: getUserByName + parameters: + - name: username + in: path + description: 'The name that needs to be fetched. Use user1 for testing. ' + required: true + schema: + type: string + responses: + "200": + description: successful operation + content: + application/xml: + schema: + $ref: '#/components/schemas/User' + application/json: + schema: + $ref: '#/components/schemas/User' + "400": + description: Invalid username supplied + "404": + description: User not found + put: + tags: + - user + summary: Update user + description: This can only be done by the logged in user. + operationId: updateUser + parameters: + - name: username + in: path + description: name that needs to be updated + required: true + schema: + type: string + requestBody: + description: Update an existent user in the store + content: + application/json: + schema: + $ref: '#/components/schemas/User' + application/xml: + schema: + $ref: '#/components/schemas/User' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/User' + responses: + default: + description: successful operation + delete: + tags: + - user + summary: Delete user + description: This can only be done by the logged in user. + operationId: deleteUser + parameters: + - name: username + in: path + description: The name that needs to be deleted + required: true + schema: + type: string + responses: + "400": + description: Invalid username supplied + "404": + description: User not found +components: + schemas: + Order: + type: object + properties: + id: + type: integer + format: int64 + example: 10 + petId: + type: integer + format: int64 + example: 198772 + quantity: + type: integer + format: int32 + example: 7 + shipDate: + type: string + format: date-time + status: + type: string + description: Order Status + example: approved + enum: + - placed + - approved + - delivered + complete: + type: boolean + xml: + name: order + Customer: + type: object + properties: + id: + type: integer + format: int64 + example: 100000 + username: + type: string + example: fehguy + address: + type: array + xml: + name: addresses + wrapped: true + items: + $ref: '#/components/schemas/Address' + xml: + name: customer + Address: + type: object + properties: + street: + type: string + example: 437 Lytton + city: + type: string + example: Palo Alto + state: + type: string + example: CA + zip: + type: string + example: "94301" + xml: + name: address + Category: + type: object + properties: + id: + type: integer + format: int64 + example: 1 + name: + type: string + example: Dogs + xml: + name: category + User: + type: object + properties: + id: + type: integer + format: int64 + example: 10 + username: + type: string + example: theUser + firstName: + type: string + example: John + lastName: + type: string + example: James + email: + type: string + example: john@email.com + password: + type: string + example: "12345" + phone: + type: string + example: "12345" + userStatus: + type: integer + description: User Status + format: int32 + example: 1 + xml: + name: user + Tag: + type: object + properties: + id: + type: integer + format: int64 + name: + type: string + xml: + name: tag + Pet: + required: + - name + - photoUrls + type: object + properties: + id: + type: integer + format: int64 + example: 10 + name: + type: string + example: doggie + category: + $ref: '#/components/schemas/Category' + photoUrls: + type: array + xml: + wrapped: true + items: + type: string + xml: + name: photoUrl + tags: + type: array + xml: + wrapped: true + items: + $ref: '#/components/schemas/Tag' + status: + type: string + description: pet status in the store + enum: + - available + - pending + - sold + xml: + name: pet + ApiResponse: + type: object + properties: + code: + type: integer + format: int32 + type: + type: string + message: + type: string + xml: + name: '##default' + requestBodies: + Pet: + description: Pet object that needs to be added to the store + content: + application/json: + schema: + $ref: '#/components/schemas/Pet' + application/xml: + schema: + $ref: '#/components/schemas/Pet' + UserArray: + description: List of user object + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + securitySchemes: + petstore_auth: + type: oauth2 + flows: + implicit: + authorizationUrl: https://petstore3.swagger.io/oauth/authorize + scopes: + write:pets: modify pets in your account + read:pets: read your pets + api_key: + type: apiKey + name: api_key + in: header diff --git a/pkg/i2gw/providers/openapi3/fixtures/input/2-hostnames.yaml b/pkg/i2gw/providers/openapi3/fixtures/input/2-hostnames.yaml new file mode 100644 index 00000000..bdddcf65 --- /dev/null +++ b/pkg/i2gw/providers/openapi3/fixtures/input/2-hostnames.yaml @@ -0,0 +1,91 @@ +openapi: 3.0.2 +info: + title: Sample API + version: 1.0.0 +servers: +- url: /api/v1 +- url: "{scheme}://api.example.com/{version}" + variables: + scheme: + enum: + - http + - https + default: https + version: + enum: + - v2 + - v3 + default: v3 +paths: + /resource: + post: + operationId: createResource + responses: + "200": + description: Successful operation + "405": + description: Invalid input + /resource/{id}: + get: + operationId: readResource + parameters: + - name: id + in: path + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: Successful operation + "400": + description: Invalid ID supplied + "404": + description: Resource not found + patch: + operationId: updateResource + parameters: + - name: id + in: path + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: Successful operation + "400": + description: Invalid ID supplied + "404": + description: Resource not found + delete: + operationId: deleteResource + parameters: + - name: id + in: path + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: Successful operation + "400": + description: Invalid ID supplied + "404": + description: Resource not found + /status: + get: + operationId: status + responses: + "200": + description: Successful operation + servers: + - url: http://api.example.com/{version} + variables: + version: + enum: + - v1 + - v2 + - v3 + default: v3 diff --git a/pkg/i2gw/providers/openapi3/fixtures/input/3-parameters.yaml b/pkg/i2gw/providers/openapi3/fixtures/input/3-parameters.yaml new file mode 100644 index 00000000..2b756663 --- /dev/null +++ b/pkg/i2gw/providers/openapi3/fixtures/input/3-parameters.yaml @@ -0,0 +1,60 @@ +openapi: 3.0.2 +info: + title: Sample API + version: 1.0.0 +paths: + /resources: + parameters: + - name: digest + in: header + required: true + schema: + type: string + post: + operationId: createResource + responses: + "200": + description: Successful operation + "400": + description: Invalid name supplied + get: + operationId: listResources + parameters: # overrides upper level parameters + - name: q + in: query + required: true + schema: + type: string + allowEmptyValue: true + - name: page + in: query + required: false # ignored + schema: + type: string + responses: + "200": + description: Successful operation + /resource/{id}: + parameters: + - name: id + in: path # ignored + required: true + schema: + type: integer + format: int64 + get: + operationId: readResource + parameters: + - name: id + in: path # ignored + required: true + schema: + type: integer + format: int64 + responses: + "200": + description: Successful operation + "400": + description: Invalid ID supplied + "404": + description: Resource not found diff --git a/pkg/i2gw/providers/openapi3/fixtures/input/4-too-many-rules.json b/pkg/i2gw/providers/openapi3/fixtures/input/4-too-many-rules.json new file mode 100644 index 00000000..ae381af2 --- /dev/null +++ b/pkg/i2gw/providers/openapi3/fixtures/input/4-too-many-rules.json @@ -0,0 +1,138 @@ +{ + "openapi": "3.0.2", + "info": { + "title": "Sample API", + "version": "1.0.0" + }, + "paths": { + "/path-001": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-002": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-003": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-004": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-005": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-006": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-007": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-008": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-009": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-010": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-011": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-012": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-013": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-014": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-015": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-016": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-017": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-018": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-019": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-020": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-021": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-022": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-023": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-024": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-025": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-026": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-027": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-028": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-029": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-030": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-031": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-032": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-033": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-034": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-035": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-036": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-037": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-038": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-039": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-040": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-041": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-042": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-043": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-044": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-045": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-046": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-047": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-048": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-049": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-050": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-051": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-052": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-053": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-054": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-055": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-056": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-057": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-058": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-059": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-060": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-061": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-062": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-063": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-064": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-065": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-066": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-067": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-068": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-069": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-070": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-071": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-072": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-073": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-074": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-075": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-076": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-077": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-078": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-079": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-080": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-081": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-082": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-083": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-084": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-085": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-086": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-087": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-088": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-089": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-090": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-091": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-092": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-093": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-094": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-095": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-096": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-097": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-098": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-099": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-100": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-101": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-102": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-103": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-104": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-105": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-106": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-107": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-108": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-109": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-110": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-111": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-112": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-113": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-114": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-115": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-116": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-117": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-118": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-119": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-120": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-121": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-122": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-123": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-124": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-125": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-126": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-127": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-128": { "get": { "responses": { "200": { "description": "Successful operation" } } } }, + "/path-129": { "get": { "responses": { "200": { "description": "Successful operation" } } } } + } +} diff --git a/pkg/i2gw/providers/openapi3/fixtures/input/5-invalid-spec.yaml b/pkg/i2gw/providers/openapi3/fixtures/input/5-invalid-spec.yaml new file mode 100644 index 00000000..fa58d422 --- /dev/null +++ b/pkg/i2gw/providers/openapi3/fixtures/input/5-invalid-spec.yaml @@ -0,0 +1,19 @@ +# Not a valid OpenAPI 3.0 spec +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: minimal-ingress + annotations: + nginx.ingress.kubernetes.io/rewrite-target: / +spec: + ingressClassName: nginx-example + rules: + - http: + paths: + - path: /testpath + pathType: Prefix + backend: + service: + name: test + port: + number: 80 diff --git a/pkg/i2gw/providers/openapi3/fixtures/input/6-reference-grants.yaml b/pkg/i2gw/providers/openapi3/fixtures/input/6-reference-grants.yaml new file mode 100644 index 00000000..f31c92ca --- /dev/null +++ b/pkg/i2gw/providers/openapi3/fixtures/input/6-reference-grants.yaml @@ -0,0 +1,13 @@ +openapi: 3.0.2 +info: + title: Sample API + version: 1.0.0 +servers: +- url: "https://api.example.com" +paths: + /resources: + post: + operationId: createResource + responses: + "200": + description: Successful operation diff --git a/pkg/i2gw/providers/openapi3/fixtures/output/1-petstore3.yaml b/pkg/i2gw/providers/openapi3/fixtures/output/1-petstore3.yaml new file mode 100644 index 00000000..ceb49127 --- /dev/null +++ b/pkg/i2gw/providers/openapi3/fixtures/output/1-petstore3.yaml @@ -0,0 +1,111 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: swagger-petstore-openapi-3-0-gateway +spec: + gatewayClassName: external + listeners: + - name: http + hostname: "*" + port: 80 + protocol: HTTP +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + creationTimestamp: null + name: swagger-petstore-openapi-3-0-route +spec: + parentRefs: + - name: swagger-petstore-openapi-3-0-gateway + rules: + - matches: + - method: POST + path: + type: Exact + value: /api/v3/pet + - method: PUT + path: + type: Exact + value: /api/v3/pet + - method: GET + path: + type: Exact + value: /api/v3/pet/findByStatus + - method: GET + path: + type: Exact + value: /api/v3/pet/findByTags + - method: DELETE + path: + type: Exact + value: /api/v3/pet/{petId} + - method: GET + path: + type: Exact + value: /api/v3/pet/{petId} + - method: POST + path: + type: Exact + value: /api/v3/pet/{petId} + - method: POST + path: + type: Exact + value: /api/v3/pet/{petId}/uploadImage + backendRefs: + - name: backend-1 + port: 3000 + - matches: + - method: GET + path: + type: Exact + value: /api/v3/store/inventory + - method: POST + path: + type: Exact + value: /api/v3/store/order + - method: DELETE + path: + type: Exact + value: /api/v3/store/order/{orderId} + - method: GET + path: + type: Exact + value: /api/v3/store/order/{orderId} + - method: POST + path: + type: Exact + value: /api/v3/user + - method: POST + path: + type: Exact + value: /api/v3/user/createWithList + - method: GET + path: + type: Exact + value: /api/v3/user/login + - method: GET + path: + type: Exact + value: /api/v3/user/logout + backendRefs: + - name: backend-1 + port: 3000 + - matches: + - method: DELETE + path: + type: Exact + value: /api/v3/user/{username} + - method: GET + path: + type: Exact + value: /api/v3/user/{username} + - method: PUT + path: + type: Exact + value: /api/v3/user/{username} + backendRefs: + - name: backend-1 + port: 3000 +status: + parents: null diff --git a/pkg/i2gw/providers/openapi3/fixtures/output/2-hostnames.yaml b/pkg/i2gw/providers/openapi3/fixtures/output/2-hostnames.yaml new file mode 100644 index 00000000..79b5fc73 --- /dev/null +++ b/pkg/i2gw/providers/openapi3/fixtures/output/2-hostnames.yaml @@ -0,0 +1,134 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: sample-api-gateway +spec: + gatewayClassName: external + listeners: + - name: http + hostname: "*" + port: 80 + protocol: HTTP + - name: api-example-com-http + hostname: api.example.com + port: 80 + protocol: HTTP + - name: api-example-com-https + hostname: api.example.com + port: 443 + protocol: HTTPS + tls: + certificateRefs: + - name: gateway-tls-cert +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + creationTimestamp: null + name: sample-api-route-1 +spec: + parentRefs: + - name: sample-api-gateway + sectionName: http + rules: + - matches: + - method: POST + path: + type: Exact + value: /api/v1/resource + - method: DELETE + path: + type: Exact + value: /api/v1/resource/{id} + - method: GET + path: + type: Exact + value: /api/v1/resource/{id} + - method: PATCH + path: + type: Exact + value: /api/v1/resource/{id} + backendRefs: + - name: backend-1 + port: 3000 +status: + parents: null +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + creationTimestamp: null + name: sample-api-route-2 +spec: + parentRefs: + - name: sample-api-gateway + sectionName: api-example-com-http + hostnames: + - api.example.com + rules: + - matches: + - method: GET + path: + type: Exact + value: /v1/status + - method: GET + path: + type: Exact + value: /v2/status + - method: GET + path: + type: Exact + value: /v3/status + backendRefs: + - name: backend-1 + port: 3000 +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + creationTimestamp: null + name: sample-api-route-3 +spec: + hostnames: + - api.example.com + parentRefs: + - name: sample-api-gateway + rules: + - matches: + - method: POST + path: + type: Exact + value: /v2/resource + - method: DELETE + path: + type: Exact + value: /v2/resource/{id} + - method: GET + path: + type: Exact + value: /v2/resource/{id} + - method: PATCH + path: + type: Exact + value: /v2/resource/{id} + - method: POST + path: + type: Exact + value: /v3/resource + - method: DELETE + path: + type: Exact + value: /v3/resource/{id} + - method: GET + path: + type: Exact + value: /v3/resource/{id} + - method: PATCH + path: + type: Exact + value: /v3/resource/{id} + backendRefs: + - name: backend-1 + port: 3000 +status: + parents: null diff --git a/pkg/i2gw/providers/openapi3/fixtures/output/3-parameters.yaml b/pkg/i2gw/providers/openapi3/fixtures/output/3-parameters.yaml new file mode 100644 index 00000000..516e70b8 --- /dev/null +++ b/pkg/i2gw/providers/openapi3/fixtures/output/3-parameters.yaml @@ -0,0 +1,45 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: sample-api-gateway +spec: + gatewayClassName: external + listeners: + - name: http + hostname: "*" + port: 80 + protocol: HTTP +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + creationTimestamp: null + name: sample-api-route +spec: + parentRefs: + - name: sample-api-gateway + rules: + - matches: + - method: GET + path: + type: Exact + value: /resource/{id} + - method: GET + path: + type: Exact + value: /resources + queryParams: + - name: q + type: Exact + - method: POST + path: + type: Exact + value: /resources + headers: + - name: digest + type: Exact + backendRefs: + - name: backend-1 + port: 3000 +status: + parents: null diff --git a/pkg/i2gw/providers/openapi3/fixtures/output/4-too-many-rules.json b/pkg/i2gw/providers/openapi3/fixtures/output/4-too-many-rules.json new file mode 100644 index 00000000..761f6a6f --- /dev/null +++ b/pkg/i2gw/providers/openapi3/fixtures/output/4-too-many-rules.json @@ -0,0 +1,272 @@ +{ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "Gateway", + "metadata": { + "name": "sample-api-gateway" + }, + "spec": { + "gatewayClassName": "external", + "listeners": [ + { + "name": "http", + "hostname": "*", + "port": 80, + "protocol": "HTTP" + } + ] + } +} +{ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "HTTPRoute", + "metadata": { + "creationTimestamp": null, + "name": "sample-api-route-1" + }, + "spec": { + "parentRefs": [ + { + "name": "sample-api-gateway" + } + ], + "rules": [ + { + "matches": [ + { "method": "GET", "path": { "type": "Exact", "value": "/path-001" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-002" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-003" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-004" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-005" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-006" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-007" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-008" } } + ], + "backendRefs": [{ "name": "backend-1", "port": 3000 } ] + }, + { + "matches": [ + { "method": "GET", "path": { "type": "Exact", "value": "/path-009" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-010" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-011" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-012" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-013" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-014" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-015" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-016" } } + ], + "backendRefs": [{ "name": "backend-1", "port": 3000 } ] + }, + { + "matches": [ + { "method": "GET", "path": { "type": "Exact", "value": "/path-017" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-018" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-019" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-020" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-021" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-022" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-023" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-024" } } + ], + "backendRefs": [{ "name": "backend-1", "port": 3000 } ] + }, + { + "matches": [ + { "method": "GET", "path": { "type": "Exact", "value": "/path-025" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-026" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-027" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-028" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-029" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-030" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-031" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-032" } } + ], + "backendRefs": [{ "name": "backend-1", "port": 3000 } ] + }, + { + "matches": [ + { "method": "GET", "path": { "type": "Exact", "value": "/path-033" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-034" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-035" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-036" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-037" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-038" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-039" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-040" } } + ], + "backendRefs": [{ "name": "backend-1", "port": 3000 } ] + }, + { + "matches": [ + { "method": "GET", "path": { "type": "Exact", "value": "/path-041" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-042" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-043" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-044" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-045" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-046" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-047" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-048" } } + ], + "backendRefs": [{ "name": "backend-1", "port": 3000 } ] + }, + { + "matches": [ + { "method": "GET", "path": { "type": "Exact", "value": "/path-049" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-050" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-051" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-052" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-053" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-054" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-055" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-056" } } + ], + "backendRefs": [{ "name": "backend-1", "port": 3000 } ] + }, + { + "matches": [ + { "method": "GET", "path": { "type": "Exact", "value": "/path-057" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-058" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-059" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-060" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-061" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-062" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-063" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-064" } } + ], + "backendRefs": [{ "name": "backend-1", "port": 3000 } ] + }, + { + "matches": [ + { "method": "GET", "path": { "type": "Exact", "value": "/path-065" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-066" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-067" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-068" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-069" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-070" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-071" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-072" } } + ], + "backendRefs": [{ "name": "backend-1", "port": 3000 } ] + }, + { + "matches": [ + { "method": "GET", "path": { "type": "Exact", "value": "/path-073" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-074" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-075" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-076" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-077" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-078" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-079" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-080" } } + ], + "backendRefs": [{ "name": "backend-1", "port": 3000 } ] + }, + { + "matches": [ + { "method": "GET", "path": { "type": "Exact", "value": "/path-081" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-082" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-083" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-084" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-085" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-086" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-087" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-088" } } + ], + "backendRefs": [{ "name": "backend-1", "port": 3000 } ] + }, + { + "matches": [ + { "method": "GET", "path": { "type": "Exact", "value": "/path-089" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-090" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-091" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-092" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-093" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-094" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-095" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-096" } } + ], + "backendRefs": [{ "name": "backend-1", "port": 3000 } ] + }, + { + "matches": [ + { "method": "GET", "path": { "type": "Exact", "value": "/path-097" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-098" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-099" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-100" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-101" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-102" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-103" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-104" } } + ], + "backendRefs": [{ "name": "backend-1", "port": 3000 } ] + }, + { + "matches": [ + { "method": "GET", "path": { "type": "Exact", "value": "/path-105" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-106" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-107" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-108" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-109" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-110" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-111" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-112" } } + ], + "backendRefs": [{ "name": "backend-1", "port": 3000 } ] + }, + { + "matches": [ + { "method": "GET", "path": { "type": "Exact", "value": "/path-113" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-114" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-115" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-116" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-117" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-118" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-119" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-120" } } + ], + "backendRefs": [{ "name": "backend-1", "port": 3000 } ] + }, + { + "matches": [ + { "method": "GET", "path": { "type": "Exact", "value": "/path-121" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-122" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-123" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-124" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-125" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-126" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-127" } }, + { "method": "GET", "path": { "type": "Exact", "value": "/path-128" } } + ], + "backendRefs": [{ "name": "backend-1", "port": 3000 } ] + } + ] + }, + "status": { + "parents": null + } +} +{ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "HTTPRoute", + "metadata": { + "creationTimestamp": null, + "name": "sample-api-route-2" + }, + "spec": { + "parentRefs": [ + { + "name": "sample-api-gateway" + } + ], + "rules": [ + { + "matches": [ + { "method": "GET", "path": { "type": "Exact", "value": "/path-129" } } + ], + "backendRefs": [{ "name": "backend-1", "port": 3000 } ] + } + ] + }, + "status": { + "parents": null + } +} diff --git a/pkg/i2gw/providers/openapi3/fixtures/output/5-invalid-spec.yaml b/pkg/i2gw/providers/openapi3/fixtures/output/5-invalid-spec.yaml new file mode 100644 index 00000000..e69de29b diff --git a/pkg/i2gw/providers/openapi3/fixtures/output/6-reference-grants.yaml b/pkg/i2gw/providers/openapi3/fixtures/output/6-reference-grants.yaml new file mode 100644 index 00000000..9a93ebb1 --- /dev/null +++ b/pkg/i2gw/providers/openapi3/fixtures/output/6-reference-grants.yaml @@ -0,0 +1,67 @@ +apiVersion: gateway.networking.k8s.io/v1 +kind: Gateway +metadata: + name: sample-api-gateway + namespace: networking +spec: + gatewayClassName: external + listeners: + - name: api-example-com-https + hostname: api.example.com + port: 443 + protocol: HTTPS + tls: + certificateRefs: + - name: gateway-tls-cert + namespace: secrets +--- +apiVersion: gateway.networking.k8s.io/v1beta1 +kind: ReferenceGrant +metadata: + name: from-networking-to-secret-gateway-tls-cert + namespace: secrets +spec: + from: + - group: gateway.networking.k8s.io + kind: Gateway + namespace: networking + to: + - kind: Secret + name: gateway-tls-cert +--- +apiVersion: gateway.networking.k8s.io/v1 +kind: HTTPRoute +metadata: + creationTimestamp: null + name: sample-api-route + namespace: networking +spec: + parentRefs: + - name: sample-api-gateway + hostnames: + - api.example.com + rules: + - matches: + - method: POST + path: + type: Exact + value: /resources + backendRefs: + - name: backend-1 + namespace: apps +status: + parents: null +--- +apiVersion: gateway.networking.k8s.io/v1beta1 +kind: ReferenceGrant +metadata: + name: from-networking-to-service-backend-1 + namespace: apps +spec: + from: + - group: gateway.networking.k8s.io + kind: HTTPRoute + namespace: networking + to: + - kind: Service + name: backend-1 diff --git a/pkg/i2gw/providers/openapi3/openapi.go b/pkg/i2gw/providers/openapi3/openapi.go new file mode 100644 index 00000000..bb8da559 --- /dev/null +++ b/pkg/i2gw/providers/openapi3/openapi.go @@ -0,0 +1,109 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package openapi3 + +import ( + "context" + "fmt" + + "github.com/getkin/kin-openapi/openapi3" + "k8s.io/apimachinery/pkg/util/validation/field" + + "github.com/kubernetes-sigs/ingress2gateway/pkg/i2gw" +) + +const ( + // The ProviderName returned to the provider's registry. + ProviderName = "openapi3" + + BackendFlag = "backend" + GatewayClassFlag = "gateway-class-name" + TLSSecretFlag = "gateway-tls-secret" //nolint:gosec +) + +func init() { + i2gw.ProviderConstructorByName[ProviderName] = NewProvider + + i2gw.RegisterProviderSpecificFlag(ProviderName, i2gw.ProviderSpecificFlag{ + Name: BackendFlag, + Description: "The name of the backend service to use in the HTTPRoutes", + }) + + i2gw.RegisterProviderSpecificFlag(ProviderName, i2gw.ProviderSpecificFlag{ + Name: GatewayClassFlag, + Description: "The name of the gateway class to use in the Gateways", + }) + + i2gw.RegisterProviderSpecificFlag(ProviderName, i2gw.ProviderSpecificFlag{ + Name: TLSSecretFlag, + Description: "The name of the secret for the TLS certificate references in the Gateways", + }) +} + +type Provider struct { + storage Storage + converter Converter +} + +var _ i2gw.Provider = &Provider{} + +// NewProvider returns an implementation of i2gw.Provider that converts OpenAPI specs to Gateway API resources. +func NewProvider(conf *i2gw.ProviderConf) i2gw.Provider { + return &Provider{ + storage: NewResourceStorage(), + converter: NewConverter(conf), + } +} + +// ReadResourcesFromCluster reads OpenAPI specs stored in the Kubernetes cluster. UNIMPLEMENTED. +func (p *Provider) ReadResourcesFromCluster(_ context.Context) error { + return nil +} + +// ReadResourcesFromFile reads OpenAPI specs from a JSON or YAML file. +func (p *Provider) ReadResourcesFromFile(ctx context.Context, filename string) error { + spec, err := readSpecFromFile(ctx, filename) + if err != nil { + return fmt.Errorf("failed to read resources from file: %w", err) + } + + p.storage.Clear() + if spec != nil { + p.storage.AddResource(spec) + } + + return nil +} + +// ToGatewayAPI converts stored OpenAPI specs to Gateway API resources. +func (p *Provider) ToGatewayAPI() (i2gw.GatewayResources, field.ErrorList) { + return p.converter.Convert(p.storage) +} + +func readSpecFromFile(ctx context.Context, filename string) (*openapi3.T, error) { + loader := openapi3.NewLoader() + spec, err := loader.LoadFromFile(filename) + if err != nil { + return nil, fmt.Errorf("failed to load OpenAPI spec: %w", err) + } + + if err := spec.Validate(ctx); err != nil { + return nil, fmt.Errorf("invalid OpenAPI 3.x spec: %w", err) + } + + return spec, nil +} diff --git a/pkg/i2gw/providers/openapi3/storage.go b/pkg/i2gw/providers/openapi3/storage.go new file mode 100644 index 00000000..526f4c7f --- /dev/null +++ b/pkg/i2gw/providers/openapi3/storage.go @@ -0,0 +1,66 @@ +/* +Copyright 2024 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package openapi3 + +import ( + "sync" + + "github.com/getkin/kin-openapi/openapi3" +) + +type Storage interface { + AddResource(resource *openapi3.T) + GetResources() []*openapi3.T + Clear() +} + +// NewResourceStorage returns a thread-safe storage for OpenAPI specs. +func NewResourceStorage() Storage { + return &storage{} +} + +type storage struct { + mu sync.RWMutex // thread-safe, so we can read and write to the storage concurrently + + resources []*openapi3.T +} + +var _ Storage = &storage{} + +// AddResource adds a new OpenAPI spec to the storage. +// AddResource is thread-safe and therefore can be called for multiple resources concurrently. +func (s *storage) AddResource(resource *openapi3.T) { + s.mu.Lock() + defer s.mu.Unlock() + + s.resources = append(s.resources, resource) +} + +// GetResources returns all OpenAPI specs stored in the storage. +func (s *storage) GetResources() []*openapi3.T { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.resources +} + +func (s *storage) Clear() { + s.mu.Lock() + defer s.mu.Unlock() + + s.resources = []*openapi3.T{} +}