diff --git a/tools/openapi2crd/.mockery.yaml b/tools/openapi2crd/.mockery.yaml new file mode 100644 index 0000000000..d3a3bcfe84 --- /dev/null +++ b/tools/openapi2crd/.mockery.yaml @@ -0,0 +1,31 @@ +# Copyright 2025 MongoDB Inc +# +# 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. + +all: true +dir: '{{.InterfaceDir}}' +filename: '{{base (trimSuffix ".go" .InterfaceFile)}}_mock.go' +force-file-write: true +formatter: goimports +log-level: info +structname: '{{.InterfaceName}}{{.Mock}}' +pkgname: '{{.SrcPackageName}}' +recursive: false +template: testify +packages: + tools/openapi2crd/pkg/config: + config: + all: true + tools/openapi2crd/pkg/plugins: + config: + all: true \ No newline at end of file diff --git a/tools/openapi2crd/cmd/run.go b/tools/openapi2crd/cmd/run.go new file mode 100644 index 0000000000..f33f865f66 --- /dev/null +++ b/tools/openapi2crd/cmd/run.go @@ -0,0 +1,137 @@ +// Copyright 2025 MongoDB Inc +// +// 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 cmd + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/spf13/afero" + "github.com/spf13/cobra" + "github.com/spf13/viper" + + configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" + "tools/openapi2crd/pkg/config" + "tools/openapi2crd/pkg/exporter" + "tools/openapi2crd/pkg/generator" + "tools/openapi2crd/pkg/plugins" +) + +const ( + outputOption = "output" + configOption = "config" + forceOption = "force" + + readOnly = os.O_RDONLY +) + +func initConfig() { + viper.AutomaticEnv() +} + +func RunCmd(ctx context.Context) *cobra.Command { + cmd := &cobra.Command{ + Use: "openapi2crd SPEC_FILE", + Short: "Generate CustomResourceDefinition from OpenAPI 3.0 document", + SilenceErrors: true, + SilenceUsage: true, + PreRun: func(cmd *cobra.Command, args []string) { + _ = viper.BindPFlags(cmd.Flags()) + }, + RunE: func(cmd *cobra.Command, args []string) error { + configPath := viper.GetString(configOption) + outputPath := viper.GetString(outputOption) + forceOverwrite := viper.GetBool(forceOption) + + fs := afero.NewOsFs() + + return runOpenapi2crd(ctx, fs, configPath, outputPath, forceOverwrite) + }, + } + + cmd.Flags().StringP(outputOption, "o", "", "Path to output file (required)") + _ = cmd.MarkFlagRequired(outputOption) + cmd.Flags().StringP(configOption, "c", "", "Path to the config file (required)") + cmd.Flags().BoolP(forceOption, "f", false, "Force overwrite the output file if it exists") + cobra.OnInitialize(initConfig) + + viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) + + return cmd +} + +func runOpenapi2crd(ctx context.Context, fs afero.Fs, input, output string, overwrite bool) error { + file, err := fs.OpenFile(input, readOnly, 0o644) + if err != nil { + return fmt.Errorf("error opening the file %s: %w", input, err) + } + + configData, err := afero.ReadAll(file) + if err != nil { + return fmt.Errorf("error reading the file %s: %w", input, err) + } + + cfg, err := config.Parse(configData) + if err != nil { + return fmt.Errorf("error parsing config: %w", err) + } + + fsExporter, err := exporter.New(fs, output, overwrite) + if err != nil { + return fmt.Errorf("error creating the exporter: %w", err) + } + + err = fsExporter.Start() + if err != nil { + return fmt.Errorf("error starting the exporter: %w", err) + } + + definitionsMap := map[string]configv1alpha1.OpenAPIDefinition{} + for _, def := range cfg.Spec.OpenAPIDefinitions { + definitionsMap[def.Name] = def + } + + catalog := plugins.NewCatalog() + pluginSets, err := catalog.BuildSets(cfg.Spec.PluginSets) + if err != nil { + return fmt.Errorf("error creating plugin set: %w", err) + } + + openapiLoader := config.NewKinOpeAPI(fs) + atlasLoader := config.NewAtlas(openapiLoader) + + for _, crdConfig := range cfg.Spec.CRDConfig { + pluginSet, err := plugins.GetPluginSet(pluginSets, crdConfig.PluginSet) + if err != nil { + return fmt.Errorf("error getting plugin set %q: %w", crdConfig.PluginSet, err) + } + + g := generator.NewGenerator(definitionsMap, pluginSet, openapiLoader, atlasLoader) + crd, err := g.Generate(ctx, &crdConfig) + if err != nil { + return err + } + + err = fsExporter.Export(crd) + if err != nil { + return err + } + } + + return fsExporter.Close() +} diff --git a/tools/openapi2crd/cmd/run_test.go b/tools/openapi2crd/cmd/run_test.go new file mode 100644 index 0000000000..acfb05cb8f --- /dev/null +++ b/tools/openapi2crd/cmd/run_test.go @@ -0,0 +1,130 @@ +// Copyright 2025 MongoDB Inc +// +// 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 cmd + +import ( + "context" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestRunOpenapi2crd(t *testing.T) { + tests := map[string]struct { + input string + output string + overwrite bool + expectedErr error + }{ + "generates CRD successfully": { + input: "./testdata/config.yaml", + output: "./testdata/output.yaml", + overwrite: true, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + fs := afero.NewMemMapFs() + _, err := fs.Create(tt.input) + require.NoError(t, err) + err = afero.WriteFile(fs, tt.input, []byte(configFile()), 0o644) + require.NoError(t, err) + err = afero.WriteFile(fs, "./testdata/openapi.yaml", []byte(openapiFile()), 0o644) + require.NoError(t, err) + + err = runOpenapi2crd(context.Background(), fs, tt.input, tt.output, tt.overwrite) + assert.Equal(t, tt.expectedErr, err) + }) + } +} + +func configFile() string { + return `kind: Config +apiVersion: atlas2crd.mongodb.com/v1alpha1 +spec: + pluginSets: + - name: example + default: true + plugins: + - base + - major_version + - parameters + - entry + - status + openapi: + - name: v1 + path: ./testdata/openapi.yaml + crd: + - gvk: + version: v1 + kind: Example + group: example.generated.mongodb.com + categories: + - example + shortNames: + - ex + mappings: + - majorVersion: v1 + openAPIRef: + name: v1 + entry: + schema: "ExampleRequest" + status: + schema: "ExampleResponse"` +} + +func openapiFile() string { + return `openapi: 3.0.0 +info: + title: Example API + version: 1.0.0 +components: + schemas: + ExampleRequest: + type: object + properties: + name: + type: string + description: + type: string + ExampleResponse: + type: object + properties: + id: + type: string + name: + type: string + description: + type: string +paths: + /example: + post: + summary: Create an example + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/ExampleRequest' + responses: + '200': + description: Successful response + content: + application/json: + schema: + $ref: '#/components/schemas/ExampleResponse'` +} diff --git a/tools/openapi2crd/config.yaml b/tools/openapi2crd/config.yaml index 7701a9a09d..1422adb80e 100644 --- a/tools/openapi2crd/config.yaml +++ b/tools/openapi2crd/config.yaml @@ -21,6 +21,7 @@ spec: - name: atlas default: true plugins: + - base - major_version - parameters - entry @@ -28,7 +29,11 @@ spec: - sensitive_properties - skipped_properties - read_only_properties - - read_write_only_properties + - read_write_properties + - references + - references_metadata + - mutual_exclusive_major_versions + - atlas_sdk_version openapi: - name: v20250312 diff --git a/tools/openapi2crd/go.mod b/tools/openapi2crd/go.mod index b43923ac54..f3df2fad57 100644 --- a/tools/openapi2crd/go.mod +++ b/tools/openapi2crd/go.mod @@ -7,10 +7,13 @@ toolchain go1.24.7 require ( github.com/getkin/kin-openapi v0.131.0 github.com/goccy/go-yaml v1.18.0 + github.com/google/go-cmp v0.6.0 github.com/pkg/errors v0.9.1 + github.com/spf13/afero v1.6.0 github.com/spf13/cobra v1.8.1 github.com/spf13/viper v1.9.0 github.com/stoewer/go-strcase v1.3.0 + github.com/stretchr/testify v1.10.0 go.mongodb.org/atlas-sdk/v20250312005 v20250312005.0.0 k8s.io/api v0.32.3 k8s.io/apiextensions-apiserver v0.32.3 @@ -60,14 +63,15 @@ require ( github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect github.com/pelletier/go-toml v1.9.4 // indirect github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/prometheus/client_golang v1.19.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/common v0.55.0 // indirect github.com/prometheus/procfs v0.15.1 // indirect - github.com/spf13/afero v1.6.0 // indirect github.com/spf13/cast v1.4.1 // indirect github.com/spf13/jwalterweatherman v1.1.0 // indirect github.com/spf13/pflag v1.0.5 // indirect + github.com/stretchr/objx v0.5.2 // indirect github.com/subosito/gotenv v1.2.0 // indirect github.com/x448/float16 v0.8.4 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.53.0 // indirect diff --git a/tools/openapi2crd/go.sum b/tools/openapi2crd/go.sum index e2d83b2afd..0c898853eb 100644 --- a/tools/openapi2crd/go.sum +++ b/tools/openapi2crd/go.sum @@ -353,6 +353,8 @@ github.com/stoewer/go-strcase v1.3.0/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8w github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= diff --git a/tools/openapi2crd/hack/tools/boilerplate.go.txt b/tools/openapi2crd/hack/tools/boilerplate.go.txt index b4ede5d4b6..8b13789179 100644 --- a/tools/openapi2crd/hack/tools/boilerplate.go.txt +++ b/tools/openapi2crd/hack/tools/boilerplate.go.txt @@ -1,13 +1 @@ -/* -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. -*/ diff --git a/tools/openapi2crd/hack/tools/tools.go b/tools/openapi2crd/hack/tools/tools.go index ed3a5d7b5e..817539d89f 100644 --- a/tools/openapi2crd/hack/tools/tools.go +++ b/tools/openapi2crd/hack/tools/tools.go @@ -25,10 +25,8 @@ package tools import ( // linter(s) _ "github.com/golangci/golangci-lint/cmd/golangci-lint" - - // kubernetes code generators - _ "k8s.io/code-generator/cmd/deepcopy-gen" - // test runner _ "gotest.tools/gotestsum" + // kubernetes code generators + _ "k8s.io/code-generator/cmd/deepcopy-gen" ) diff --git a/tools/openapi2crd/main.go b/tools/openapi2crd/main.go index c311e0703e..1d1b7d4b3c 100644 --- a/tools/openapi2crd/main.go +++ b/tools/openapi2crd/main.go @@ -19,82 +19,12 @@ import ( "context" "fmt" "os" - "strings" - "github.com/spf13/cobra" - "github.com/spf13/viper" - "tools/openapi2crd/pkg/config" - "tools/openapi2crd/pkg/exporter" - "tools/openapi2crd/pkg/generator" + "tools/openapi2crd/cmd" ) -const ( - outputOption = "output" - configOption = "config" -) - -// RootCmd defines the root cli command -func RootCmd() *cobra.Command { - cmd := &cobra.Command{ - Use: "openapi2crd SPEC_FILE", - Short: "Generate CustomResourceDefinition from OpenAPI 3.0 document", - SilenceErrors: true, - SilenceUsage: true, - PreRun: func(cmd *cobra.Command, args []string) { - _ = viper.BindPFlags(cmd.Flags()) - }, - RunE: func(cmd *cobra.Command, args []string) error { - outputOptionValue := viper.GetString(outputOption) - exporter, err := exporter.New(outputOptionValue) - if err != nil { - return err - } - - configPath := viper.GetString(configOption) - raw, err := os.ReadFile(configPath) - if err != nil { - return fmt.Errorf("error reading config: %w", err) - } - - cfg, err := config.Parse(raw) - if err != nil { - return fmt.Errorf("error parsing config: %w", err) - } - - ctx := context.Background() - for _, crdConfig := range cfg.Spec.CRDConfig { - g := generator.NewGenerator(crdConfig, cfg.Spec.OpenAPIDefinitions) - crd, err := g.Generate(ctx) - if err != nil { - return err - } - - err = exporter.Export(crd) - if err != nil { - return err - } - } - - return nil - }, - } - - cmd.Flags().StringP(outputOption, "o", "", "Path to output file (required)") - _ = cmd.MarkFlagRequired(outputOption) - cmd.Flags().StringP(configOption, "c", "", "Path to the config file (required)") - cobra.OnInitialize(initConfig) - - viper.SetEnvKeyReplacer(strings.NewReplacer("-", "_")) - return cmd -} - -func initConfig() { - viper.AutomaticEnv() -} - func main() { - // Run the cli - if err := RootCmd().Execute(); err != nil { + if err := cmd.RunCmd(context.Background()).Execute(); err != nil { fmt.Println(err) os.Exit(1) } diff --git a/tools/openapi2crd/pkg/apis/config/v1alpha1/config.go b/tools/openapi2crd/pkg/apis/config/v1alpha1/config.go index 94f16545cf..d891597bb7 100644 --- a/tools/openapi2crd/pkg/apis/config/v1alpha1/config.go +++ b/tools/openapi2crd/pkg/apis/config/v1alpha1/config.go @@ -13,20 +13,6 @@ // limitations under the License. // -/* -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 v1alpha1 import ( @@ -40,10 +26,18 @@ type Config struct { } type Spec struct { + PluginSets []PluginSet `json:"pluginSets,omitempty"` CRDConfig []CRDConfig `json:"crd,omitempty"` OpenAPIDefinitions []OpenAPIDefinition `json:"openapi,omitempty"` } +type PluginSet struct { + Name string `json:"name"` + Default bool `json:"default,omitempty"` + InheritFrom string `json:"inheritFrom,omitempty"` + Plugins []string +} + type OpenAPIDefinition struct { Name string `json:"name"` Path string `json:"path"` @@ -55,6 +49,7 @@ type CRDConfig struct { Categories []string `json:"categories,omitempty"` Mappings []CRDMapping `json:"mappings,omitempty"` ShortNames []string `json:"shortNames,omitempty"` + PluginSet string `json:"pluginSet,omitempty"` } type CRDMapping struct { diff --git a/tools/openapi2crd/pkg/apis/config/v1alpha1/doc.go b/tools/openapi2crd/pkg/apis/config/v1alpha1/doc.go index b47bc93bd7..8147e52130 100644 --- a/tools/openapi2crd/pkg/apis/config/v1alpha1/doc.go +++ b/tools/openapi2crd/pkg/apis/config/v1alpha1/doc.go @@ -13,19 +13,5 @@ // limitations under the License. // -/* -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. -*/ - // +k8s:deepcopy-gen=package package v1alpha1 diff --git a/tools/openapi2crd/pkg/apis/config/v1alpha1/zz_generated.deepcopy.go b/tools/openapi2crd/pkg/apis/config/v1alpha1/zz_generated.deepcopy.go index b7e5a5ed7c..ece9b4302c 100644 --- a/tools/openapi2crd/pkg/apis/config/v1alpha1/zz_generated.deepcopy.go +++ b/tools/openapi2crd/pkg/apis/config/v1alpha1/zz_generated.deepcopy.go @@ -1,20 +1,6 @@ //go:build !ignore_autogenerated // +build !ignore_autogenerated -/* -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. -*/ - // Code generated by deepcopy-gen. DO NOT EDIT. package v1alpha1 diff --git a/tools/openapi2crd/pkg/atlas/atlas.go b/tools/openapi2crd/pkg/config/atlas.go similarity index 65% rename from tools/openapi2crd/pkg/atlas/atlas.go rename to tools/openapi2crd/pkg/config/atlas.go index d2cd56eee3..64dd6e6ed3 100644 --- a/tools/openapi2crd/pkg/atlas/atlas.go +++ b/tools/openapi2crd/pkg/config/atlas.go @@ -13,38 +13,54 @@ // limitations under the License. // -package atlas +package config import ( + "context" + "errors" "fmt" "os/exec" "path/filepath" "strings" + "github.com/getkin/kin-openapi/openapi3" _ "go.mongodb.org/atlas-sdk/v20250312005/admin" ) -func LoadOpenAPIPath(modulePath string) (string, error) { - path, err := GetGoModulePath(modulePath) +type Atlas struct { + fileLoader Loader +} + +func (a *Atlas) Load(ctx context.Context, pkg string) (*openapi3.T, error) { + path, err := getGoModulePath(ctx, pkg) if err != nil { - return "", fmt.Errorf("failed to load module path: %v", err) + return nil, fmt.Errorf("failed to load module path: %w", err) } _ = path - return filepath.Clean(filepath.Join(path, "..", "openapi", "atlas-api-transformed.yaml")), nil + filename := filepath.Clean(filepath.Join(path, "..", "openapi", "atlas-api-transformed.yaml")) + + return a.fileLoader.Load(ctx, filename) +} + +func NewAtlas(loader Loader) *Atlas { + return &Atlas{ + fileLoader: loader, + } } -func GetGoModulePath(modulePath string) (string, error) { +func getGoModulePath(ctx context.Context, modulePath string) (string, error) { goCmd, err := exec.LookPath("go") if err != nil { return "", fmt.Errorf("go command not found in PATH: %w", err) } - cmd := exec.Command(goCmd, "list", "-f", "{{.Dir}}", modulePath) + cmd := exec.CommandContext(ctx, goCmd, "list", "-f", "{{.Dir}}", modulePath) output, err := cmd.Output() if err != nil { // Check if the error is due to the module not being found - if exitErr, ok := err.(*exec.ExitError); ok { + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { stderr := string(exitErr.Stderr) if strings.Contains(stderr, "not a known module") || strings.Contains(stderr, "cannot find module") { return "", fmt.Errorf("module '%s' not found or not a dependency of the current project", modulePath) diff --git a/tools/openapi2crd/pkg/config/atlas_test.go b/tools/openapi2crd/pkg/config/atlas_test.go new file mode 100644 index 0000000000..3cc3bea4cc --- /dev/null +++ b/tools/openapi2crd/pkg/config/atlas_test.go @@ -0,0 +1,57 @@ +// Copyright 2025 MongoDB Inc +// +// 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 config + +import ( + "context" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" +) + +func TestAtlas_Load(t *testing.T) { + tests := map[string]struct { + pkg string + expectedSchema *openapi3.T + expectedErrMsg string + }{ + "valid package": { + pkg: "go.mongodb.org/atlas-sdk/v20250312005/admin", + expectedSchema: &openapi3.T{}, + }, + "invalid package": { + pkg: "invalid/package/name", + expectedErrMsg: "failed to load module path: failed to run 'go list' for module 'invalid/package/name'", + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + openapiLoader := NewLoaderMock(t) + if tt.expectedErrMsg == "" { + openapiLoader.EXPECT().Load(context.Background(), mock.AnythingOfType("string")).Return(&openapi3.T{}, nil) + } + + a := NewAtlas(openapiLoader) + schema, err := a.Load(context.Background(), tt.pkg) + if err != nil { + assert.ErrorContains(t, err, tt.expectedErrMsg) + } + assert.Equal(t, tt.expectedSchema, schema) + }) + } +} diff --git a/tools/openapi2crd/pkg/config/openapi.go b/tools/openapi2crd/pkg/config/openapi.go index 0b8be1abf5..05226d1b4e 100644 --- a/tools/openapi2crd/pkg/config/openapi.go +++ b/tools/openapi2crd/pkg/config/openapi.go @@ -13,59 +13,82 @@ // limitations under the License. // -/* -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 config import ( + "context" "fmt" "net/url" - "os" "path/filepath" "github.com/getkin/kin-openapi/openapi3" "github.com/goccy/go-yaml" + "github.com/spf13/afero" ) -func LoadOpenAPI(filePath string) (*openapi3.T, error) { +type Loader interface { + Load(ctx context.Context, path string) (*openapi3.T, error) +} + +type KinOpeAPI struct { + fs afero.Fs +} + +func NewKinOpeAPI(fs afero.Fs) *KinOpeAPI { + return &KinOpeAPI{ + fs: fs, + } +} + +func (a *KinOpeAPI) Load(_ context.Context, path string) (*openapi3.T, error) { loader := &openapi3.Loader{ IsExternalRefsAllowed: true, } - uri, err := url.Parse(filePath) - if err == nil && uri.Scheme != "" && uri.Host != "" { + if uri, ok := isURI(path); ok { return loader.LoadFromURI(uri) } - filePath = filepath.Clean(filePath) - b, err := os.ReadFile(filePath) + data, err := a.transform(path) + if err != nil { + return nil, fmt.Errorf("failed to transform the file %s: %w", path, err) + } + + return loader.LoadFromData(data) +} + +func (a *KinOpeAPI) transform(path string) ([]byte, error) { + filePath := filepath.Clean(path) + + data, err := afero.ReadFile(a.fs, filePath) if err != nil { - return nil, fmt.Errorf("failed to read file %s: %w", filePath, err) + return nil, fmt.Errorf("failed to read the file %s: %w", filePath, err) } + result := make(map[string]interface{}) - err = yaml.Unmarshal(b, &result) + err = yaml.Unmarshal(data, &result) if err != nil { return nil, fmt.Errorf("failed to unmarshal file %s: %w", filePath, err) } + removeXGenChangelog(result) - b, err = yaml.Marshal(result) + + data, err = yaml.Marshal(result) if err != nil { return nil, fmt.Errorf("failed to marshal yaml: %w", err) } - return loader.LoadFromData(b) + return data, nil +} + +func isURI(path string) (*url.URL, bool) { + uri, err := url.Parse(path) + + if err == nil && uri.Scheme != "" && uri.Host != "" { + return uri, true + } + + return nil, false } func removeXGenChangelog(m map[string]interface{}) { diff --git a/tools/openapi2crd/pkg/config/openapi_mock.go b/tools/openapi2crd/pkg/config/openapi_mock.go new file mode 100644 index 0000000000..c2f3f7fd39 --- /dev/null +++ b/tools/openapi2crd/pkg/config/openapi_mock.go @@ -0,0 +1,107 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package config + +import ( + "context" + + "github.com/getkin/kin-openapi/openapi3" + mock "github.com/stretchr/testify/mock" +) + +// NewLoaderMock creates a new instance of LoaderMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewLoaderMock(t interface { + mock.TestingT + Cleanup(func()) +}) *LoaderMock { + mock := &LoaderMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// LoaderMock is an autogenerated mock type for the Loader type +type LoaderMock struct { + mock.Mock +} + +type LoaderMock_Expecter struct { + mock *mock.Mock +} + +func (_m *LoaderMock) EXPECT() *LoaderMock_Expecter { + return &LoaderMock_Expecter{mock: &_m.Mock} +} + +// Load provides a mock function for the type LoaderMock +func (_mock *LoaderMock) Load(ctx context.Context, path string) (*openapi3.T, error) { + ret := _mock.Called(ctx, path) + + if len(ret) == 0 { + panic("no return value specified for Load") + } + + var r0 *openapi3.T + var r1 error + if returnFunc, ok := ret.Get(0).(func(context.Context, string) (*openapi3.T, error)); ok { + return returnFunc(ctx, path) + } + if returnFunc, ok := ret.Get(0).(func(context.Context, string) *openapi3.T); ok { + r0 = returnFunc(ctx, path) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*openapi3.T) + } + } + if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok { + r1 = returnFunc(ctx, path) + } else { + r1 = ret.Error(1) + } + return r0, r1 +} + +// LoaderMock_Load_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Load' +type LoaderMock_Load_Call struct { + *mock.Call +} + +// Load is a helper method to define mock.On call +// - ctx context.Context +// - path string +func (_e *LoaderMock_Expecter) Load(ctx interface{}, path interface{}) *LoaderMock_Load_Call { + return &LoaderMock_Load_Call{Call: _e.mock.On("Load", ctx, path)} +} + +func (_c *LoaderMock_Load_Call) Run(run func(ctx context.Context, path string)) *LoaderMock_Load_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 context.Context + if args[0] != nil { + arg0 = args[0].(context.Context) + } + var arg1 string + if args[1] != nil { + arg1 = args[1].(string) + } + run( + arg0, + arg1, + ) + }) + return _c +} + +func (_c *LoaderMock_Load_Call) Return(t *openapi3.T, err error) *LoaderMock_Load_Call { + _c.Call.Return(t, err) + return _c +} + +func (_c *LoaderMock_Load_Call) RunAndReturn(run func(ctx context.Context, path string) (*openapi3.T, error)) *LoaderMock_Load_Call { + _c.Call.Return(run) + return _c +} diff --git a/tools/openapi2crd/pkg/config/openapi_test.go b/tools/openapi2crd/pkg/config/openapi_test.go new file mode 100644 index 0000000000..cb91067a93 --- /dev/null +++ b/tools/openapi2crd/pkg/config/openapi_test.go @@ -0,0 +1,82 @@ +// Copyright 2025 MongoDB Inc +// +// 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 config + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestKinOpeAPILoad(t *testing.T) { + tests := map[string]struct { + file string + filePath string + expectedOpenAPI *openapi3.T + expectError error + }{ + "valid openapi file": { + file: `openapi: 3.0.0 +info: + title: Swagger Petstore + version: 1.0.0 +paths: + /pets: + get: + operationId: listPets + x-xgen-changelog: + 2025-05-08: Corrects an issue where the endpoint would include Atlas internal entries.`, + filePath: "testdata/petstore.yaml", + expectedOpenAPI: &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Title: "Swagger Petstore", + Version: "1.0.0", + }, + Paths: openapi3.NewPaths( + openapi3.WithPath( + "/pets", + &openapi3.PathItem{ + Get: &openapi3.Operation{ + OperationID: "listPets", + }, + }, + ), + ), + }, + expectError: nil, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + fs := afero.NewMemMapFs() + _, err := fs.Create(tt.filePath) + require.NoError(t, err) + err = afero.WriteFile(fs, tt.filePath, []byte(tt.file), 0644) + require.NoError(t, err) + + tt.expectedOpenAPI.Paths.Extensions = map[string]any{} + + loader := NewKinOpeAPI(fs) + openapi, err := loader.Load(nil, tt.filePath) + assert.Equal(t, tt.expectError, err) + assert.Equal(t, tt.expectedOpenAPI, openapi) + }) + } +} diff --git a/tools/openapi2crd/pkg/config/parse.go b/tools/openapi2crd/pkg/config/parse.go index 53611276f7..b33dfcf5ff 100644 --- a/tools/openapi2crd/pkg/config/parse.go +++ b/tools/openapi2crd/pkg/config/parse.go @@ -19,6 +19,7 @@ import ( "fmt" "sigs.k8s.io/yaml" + "tools/openapi2crd/pkg/apis/config/v1alpha1" ) diff --git a/tools/openapi2crd/pkg/config/parse_test.go b/tools/openapi2crd/pkg/config/parse_test.go new file mode 100644 index 0000000000..0aea160bfb --- /dev/null +++ b/tools/openapi2crd/pkg/config/parse_test.go @@ -0,0 +1,80 @@ +// Copyright 2025 MongoDB Inc +// +// 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 config + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "tools/openapi2crd/pkg/apis/config/v1alpha1" +) + +func TestParse(t *testing.T) { + tests := map[string]struct { + raw []byte + expectedConfig *v1alpha1.Config + expectedError error + }{ + "valid config": { + raw: []byte(` +kind: Config +apiVersion: atlas2crd.mongodb.com/v1alpha1 +spec: + crd: [] + openapi: [] +`), + expectedConfig: &v1alpha1.Config{ + TypeMeta: metav1.TypeMeta{ + Kind: "Config", + APIVersion: "atlas2crd.mongodb.com/v1alpha1", + }, + Spec: v1alpha1.Spec{ + CRDConfig: []v1alpha1.CRDConfig{}, + OpenAPIDefinitions: []v1alpha1.OpenAPIDefinition{}, + }, + }, + }, + "invalid config": { + raw: []byte("invalid yaml"), + expectedConfig: nil, + expectedError: fmt.Errorf( + "error unmarshalling config type: %w", + fmt.Errorf("error unmarshaling JSON: %w", fmt.Errorf("while decoding JSON: json: cannot unmarshal string into Go value of type v1alpha1.Config")), + ), + }, + "invalid kind": { + raw: []byte(` +kind: Other +apiVersion: atlas2crd.mongodb.com/v1alpha1 +spec: + crd: [] + openapi: [] +`), + expectedConfig: nil, + expectedError: fmt.Errorf("invalid config type: %s", "Other"), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + cfg, err := Parse(tt.raw) + require.Equal(t, tt.expectedError, err) + require.Equal(t, tt.expectedConfig, cfg) + }) + } +} diff --git a/tools/openapi2crd/pkg/plugins/json_path.go b/tools/openapi2crd/pkg/converter/converter.go similarity index 53% rename from tools/openapi2crd/pkg/plugins/json_path.go rename to tools/openapi2crd/pkg/converter/converter.go index 8a6e1ec493..65b9389ede 100644 --- a/tools/openapi2crd/pkg/plugins/json_path.go +++ b/tools/openapi2crd/pkg/converter/converter.go @@ -13,11 +13,23 @@ // limitations under the License. // -package plugins +package converter -import "strings" +import ( + "github.com/getkin/kin-openapi/openapi3" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" -func jsonPath(path []string) string { - result := strings.Join(path, ".") - return strings.ReplaceAll(result, ".[*]", "[*]") + configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" +) + +type PropertyConvertInput struct { + Schema *openapi3.SchemaRef + ExtensionsSchemaRef *openapi3.SchemaRef + PropertyConfig *configv1alpha1.PropertyMapping + Depth int + Path []string +} + +type Converter interface { + Convert(input PropertyConvertInput) *apiextensions.JSONSchemaProps } diff --git a/tools/openapi2crd/pkg/exporter/exporter.go b/tools/openapi2crd/pkg/exporter/exporter.go index 59375171b1..07d74ed882 100644 --- a/tools/openapi2crd/pkg/exporter/exporter.go +++ b/tools/openapi2crd/pkg/exporter/exporter.go @@ -13,101 +13,132 @@ // limitations under the License. // -/* -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 exporter import ( - "bufio" + "fmt" "os" "path/filepath" "github.com/pkg/errors" - apiextensions "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + "github.com/spf13/afero" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/v1" "k8s.io/apimachinery/pkg/runtime" "k8s.io/client-go/kubernetes/scheme" "sigs.k8s.io/yaml" ) -// Exporter exports the yaml config to file. -type Exporter struct { - writer *bufio.Writer +const ( + createOrUpdate = os.O_WRONLY | os.O_CREATE | os.O_TRUNC + + defaultDirectoryPermission os.FileMode = 0o755 + defaultFilePermission os.FileMode = 0o644 +) + +type Exporter interface { + Export(final *apiextensions.CustomResourceDefinition) error +} + +// Filesystem exports the yaml config to file. +type Filesystem struct { + fs afero.Fs + filename string + overwrite bool + file afero.File +} + +func New(fs afero.Fs, outputFilepath string, overwrite bool) (*Filesystem, error) { + return &Filesystem{ + fs: fs, + filename: outputFilepath, + overwrite: overwrite, + }, nil } -func New(outputFilepath string) (*Exporter, error) { - f, err := os.Create(filepath.Clean(outputFilepath)) +func (f *Filesystem) Start() error { + exists, err := afero.Exists(f.fs, f.filename) if err != nil { - return nil, errors.Wrapf(err, "Failed to open the output file %s", outputFilepath) + return fmt.Errorf("failed to check if file exists: %w", err) } - writer := bufio.NewWriter(f) - _, err = writer.WriteString("# The file is generated by atlas2crd\n") + if exists && !f.overwrite { + return fmt.Errorf("file %s already exists, use --force to overwrite", f.filename) + } + + if err = f.fs.MkdirAll(filepath.Dir(f.filename), defaultDirectoryPermission); err != nil { + return fmt.Errorf("failed to create directory for file %s: %w", f.filename, err) + } + + f.file, err = f.fs.OpenFile(f.filename, createOrUpdate, defaultFilePermission) if err != nil { - return nil, errors.Wrapf(err, "Failed to write to output file") + return fmt.Errorf("failed to open file %s: %w", f.filename, err) } - return &Exporter{ - writer: writer, - }, nil + _, err = f.file.WriteString("# The file is generated by openapi2crd\n") + if err != nil { + return fmt.Errorf("failed to write the header to file %s: %w", f.filename, err) + } + + return nil +} + +func (f *Filesystem) Close() error { + if f.file != nil { + return f.file.Close() + } + + return nil } // Export exports the yaml config to file. -func (e *Exporter) Export(final *apiextensions.CustomResourceDefinition) error { - err := e.marshalCrd(final) +func (f *Filesystem) Export(final *apiextensions.CustomResourceDefinition) error { + yamlBytes, err := marshalCrd(final) if err != nil { - return errors.Wrapf(err, "Failed to marshall CRD when writing output") + return errors.New("Failed to marshall CRD when writing output") } - return e.writer.Flush() + _, err = f.file.WriteString("---\n") + if err != nil { + return fmt.Errorf("failed to write the separator to file %s: %w", f.filename, err) + } + _, err = f.file.Write(yamlBytes) + if err != nil { + return fmt.Errorf("failed to write the content to file %s: %w", f.filename, err) + } + + return nil } -func (e Exporter) marshalCrd(crd *apiextensions.CustomResourceDefinition) error { +func marshalCrd(crd *apiextensions.CustomResourceDefinition) ([]byte, error) { obj, err := convert(crd) if err != nil { - return err + return nil, err } + obj.Kind = "CustomResourceDefinition" obj.APIVersion = "apiextensions.k8s.io/v1" yamlBytes, err := yaml.Marshal(obj) if err != nil { - return err - } - _, err = e.writer.WriteString("---\n") - if err != nil { - return err - } - _, err = e.writer.Write(yamlBytes) - if err != nil { - return err + return nil, err } - return nil + return yamlBytes, nil } func convert(crd *apiextensions.CustomResourceDefinition) (*apiextensionsv1.CustomResourceDefinition, error) { sch := runtime.NewScheme() _ = scheme.AddToScheme(sch) - _ = apiextensions.AddToScheme(sch) + _ = apiextensionsv1.AddToScheme(sch) _ = apiextensionsv1.AddToScheme(sch) _ = apiextensionsv1.RegisterConversions(sch) + out := &apiextensionsv1.CustomResourceDefinition{} err := sch.Convert(crd, out, nil) if err != nil { return nil, err } + return out, err } diff --git a/tools/openapi2crd/pkg/exporter/exporter_test.go b/tools/openapi2crd/pkg/exporter/exporter_test.go new file mode 100644 index 0000000000..d3eaf573a5 --- /dev/null +++ b/tools/openapi2crd/pkg/exporter/exporter_test.go @@ -0,0 +1,298 @@ +// Copyright 2025 MongoDB Inc +// +// 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 exporter + +import ( + "fmt" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/require" + apiextensionsv1 "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func TestStart(t *testing.T) { + tests := map[string]struct { + filename string + overwrite bool + expectedError error + }{ + /* "creates file if it does not exist": { + filename: "testdata/newfile.yaml", + overwrite: false, + },*/ + "fails if file exists and overwrite is false": { + filename: "testdata/groups.atlas.generated.mongodb.com.yaml", + overwrite: false, + expectedError: fmt.Errorf("file %s already exists, use --force to overwrite", "testdata/groups.atlas.generated.mongodb.com.yaml"), + }, + "overwrites file if it exists and overwrite is true": { + filename: "testdata/groups.atlas.generated.mongodb.com.yaml", + overwrite: true, + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + fs := afero.NewMemMapFs() + _, err := fs.Create(tt.filename) + require.NoError(t, err) + + exporter, err := New(fs, tt.filename, tt.overwrite) + require.NoError(t, err) + + err = exporter.Start() + require.Equal(t, tt.expectedError, err) + + if err == nil { + _, err = fs.Stat(tt.filename) + require.NoError(t, err) + } + }) + } +} + +func TestExport(t *testing.T) { + tests := map[string]struct { + filename string + overwrite bool + crd *apiextensionsv1.CustomResourceDefinition + yaml string + expectedError error + }{ + "export to file": { + filename: "testdata/groups.atlas.generated.mongodb.com.yaml", + overwrite: true, + crd: sampleCRD(), + yaml: sampleCRDYaml(false), + }, + } + + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + fs := afero.NewMemMapFs() + _, err := fs.Create(tt.filename) + require.NoError(t, err) + + exporter, err := New(fs, tt.filename, tt.overwrite) + require.NoError(t, err) + + err = exporter.Start() + require.NoError(t, err) + + err = exporter.Export(tt.crd) + require.Equal(t, tt.expectedError, err) + data, err := afero.ReadFile(fs, tt.filename) + require.NoError(t, err) + require.Equal(t, tt.yaml, string(data)) + + err = exporter.Close() + require.NoError(t, err) + }) + } +} + +func sampleCRD() *apiextensionsv1.CustomResourceDefinition { + crd := apiextensionsv1.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "groups.atlas.generated.mongodb.com", + Annotations: map[string]string{ + "api-mappings": "properties:\n spec:\n properties:\n v20250312:\n x-atlas-sdk-version: go.mongodb.org/atlas-sdk/v20250312005/admin\n", + }, + }, + Spec: apiextensionsv1.CustomResourceDefinitionSpec{ + Group: "atlas.generated.mongodb.com", + Scope: apiextensionsv1.NamespaceScoped, + Names: apiextensionsv1.CustomResourceDefinitionNames{ + Plural: "groups", + Singular: "group", + ShortNames: []string{"ag"}, + Kind: "Group", + ListKind: "GroupList", + Categories: []string{"atlas"}, + }, + Versions: []apiextensionsv1.CustomResourceDefinitionVersion{ + { + Name: "v1", + Served: true, + Storage: true, + }, + }, + Validation: &apiextensionsv1.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensionsv1.JSONSchemaProps{ + Type: "object", + Description: "A group, managed by the MongoDB Kubernetes Atlas Operator.", + Properties: map[string]apiextensionsv1.JSONSchemaProps{ + "spec": { + Type: "object", + Description: "Specification of the group supporting the following versions:\n\n- v20250312\n\nAt most one versioned spec can be specified.", + Properties: map[string]apiextensionsv1.JSONSchemaProps{ + "v20250312": { + Type: "object", + Description: "The spec of the group resource for version v20250312.", + Properties: map[string]apiextensionsv1.JSONSchemaProps{ + "entry": { + Type: "object", + Description: "The entry fields of the group resource spec. These fields can be set for creating and updating groups.", + Properties: map[string]apiextensionsv1.JSONSchemaProps{ + "name": { + Type: "string", + Description: "Human-readable label that identifies the project included in the MongoDB Cloud organization.", + }, + "orgId": { + Type: "string", + Description: "Unique 24-hexadecimal digit string that identifies the MongoDB Cloud organization to which the project belongs.", + }, + }, + Required: []string{"name", "orgId"}, + }, + }, + }, + }, + }, + "status": { + Type: "object", + Description: "Most recently observed read-only status of the group for the specified resource version.", + Properties: map[string]apiextensionsv1.JSONSchemaProps{ + "v20250312": { + Type: "object", + Description: "The last observed Atlas state of the group resource for version v20250312.", + Properties: map[string]apiextensionsv1.JSONSchemaProps{ + "id": { + Type: "string", + Description: "Unique 24-hexadecimal digit string that identifies the MongoDB Cloud project.", + }, + "clusterCount": { + Type: "integer", + Description: "Quantity of MongoDB Cloud clusters deployed in this project.", + }, + }, + Required: []string{"clusterCount"}, + }, + }, + }, + }, + }, + }, + }, + Status: apiextensionsv1.CustomResourceDefinitionStatus{ + StoredVersions: []string{"v1"}, + }, + } + + return &crd +} + +func sampleCRDYaml(withHeader bool) string { + crd := `# The file is generated by openapi2crd +--- +apiVersion: apiextensions.k8s.io/v1 +kind: CustomResourceDefinition +metadata: + annotations: + api-mappings: | + properties: + spec: + properties: + v20250312: + x-atlas-sdk-version: go.mongodb.org/atlas-sdk/v20250312005/admin + creationTimestamp: null + name: groups.atlas.generated.mongodb.com +spec: + group: atlas.generated.mongodb.com + names: + categories: + - atlas + kind: Group + listKind: GroupList + plural: groups + shortNames: + - ag + singular: group + scope: Namespaced + versions: + - name: v1 + schema: + openAPIV3Schema: + description: A group, managed by the MongoDB Kubernetes Atlas Operator. + properties: + spec: + description: |- + Specification of the group supporting the following versions: + + - v20250312 + + At most one versioned spec can be specified. + properties: + v20250312: + description: The spec of the group resource for version v20250312. + properties: + entry: + description: The entry fields of the group resource spec. These + fields can be set for creating and updating groups. + properties: + name: + description: Human-readable label that identifies the project + included in the MongoDB Cloud organization. + type: string + orgId: + description: Unique 24-hexadecimal digit string that identifies + the MongoDB Cloud organization to which the project belongs. + type: string + required: + - name + - orgId + type: object + type: object + type: object + status: + description: Most recently observed read-only status of the group for + the specified resource version. + properties: + v20250312: + description: The last observed Atlas state of the group resource for + version v20250312. + properties: + clusterCount: + description: Quantity of MongoDB Cloud clusters deployed in this + project. + type: integer + id: + description: Unique 24-hexadecimal digit string that identifies + the MongoDB Cloud project. + type: string + required: + - clusterCount + type: object + type: object + type: object + served: true + storage: true +status: + acceptedNames: + kind: "" + plural: "" + conditions: null + storedVersions: + - v1 +` + if withHeader { + crd = "# The file is generated by openapi2crd\n" + crd + } + + return crd +} diff --git a/tools/openapi2crd/pkg/generator/convert.go b/tools/openapi2crd/pkg/generator/convert.go deleted file mode 100644 index e91ca41fd0..0000000000 --- a/tools/openapi2crd/pkg/generator/convert.go +++ /dev/null @@ -1,250 +0,0 @@ -// Copyright 2025 MongoDB Inc -// -// 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. -// - -/* -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 generator - -import ( - "strings" - - "github.com/getkin/kin-openapi/openapi3" - "github.com/stoewer/go-strcase" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" - "k8s.io/utils/ptr" - configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" -) - -// SchemaPropsToJSONProps converts openapi3.Schema to a JSONProps -func (g *Generator) ConvertProperty(schema, extensionsSchema *openapi3.SchemaRef, propertyConfig *configv1alpha1.PropertyMapping, depth int, path ...string) *apiextensions.JSONSchemaProps { - if depth == 10 { - return nil - } - - if schema == nil { - return nil - } - - if len(path) == 0 { - path = []string{"$"} - } - - propertySchema := schema.Value - if propertySchema == nil { - return nil - } - - typ := "" - if propertySchema.Type != nil && len(propertySchema.Type.Slice()) > 0 { - typ = (*propertySchema.Type)[0] - } - example := apiextensions.JSON(propertySchema.Example) - - props := &apiextensions.JSONSchemaProps{ - //ID: schemaProps.ID, - //Schema: apiextensions.JSONSchemaURL(string(schemaRef.Ref.)), - //Ref: ref, - Description: propertySchema.Description, - Type: typ, - //Format: schemaProps.Format, - Title: propertySchema.Title, - //Maximum: schemaProps.Max, - //ExclusiveMaximum: schemaProps.ExclusiveMax, - //Minimum: schemaProps.Min, - //ExclusiveMinimum: schemaProps.ExclusiveMin, - //MaxLength: castUInt64P(schemaProps.MaxLength), - //MinLength: castUInt64(schemaProps.MinLength), - // patterns seem to be incompatible in Atlas OpenAPI - //Pattern: schemaProps.Pattern, - //MaxItems: castUInt64P(schemaProps.MaxItems), - //MinItems: castUInt64(schemaProps.MinItems), - UniqueItems: false, // The field uniqueItems cannot be set to true. - MultipleOf: propertySchema.MultipleOf, - //Enum: enumJSON(schemaProps.Enum), - //MaxProperties: castUInt64P(schemaProps.MaxProps), - //MinProperties: castUInt64(schemaProps.MinProps), - Required: propertySchema.Required, - Items: g.convertPropertyOrArray(propertySchema.Items, extensionsSchema, propertyConfig, depth, append(path, "[*]")), - AllOf: g.convertPropertySlice(propertySchema.AllOf, propertyConfig, extensionsSchema, depth, path), - OneOf: g.convertPropertySlice(propertySchema.OneOf, propertyConfig, extensionsSchema, depth, path), - AnyOf: g.convertPropertySlice(propertySchema.AnyOf, propertyConfig, extensionsSchema, depth, path), - Not: g.ConvertProperty(propertySchema.Not, extensionsSchema, propertyConfig, depth+1, path...), - Properties: g.ConvertPropertyMap(propertySchema.Properties, extensionsSchema, propertyConfig, depth, path...), - AdditionalProperties: g.convertPropertyOrBool(propertySchema.AdditionalProperties.Schema, extensionsSchema, propertyConfig, depth, path), - Example: &example, - } - - for _, p := range g.plugins { - props = p.ProcessProperty(g, propertyConfig, props, propertySchema, extensionsSchema, path...) - if props == nil { - return nil - } - } - - if props.Type == "" { - props.Type = "object" - } - - if props.Type == "object" && props.Items == nil && len(props.Properties) == 0 && props.AdditionalProperties == nil { - props.XPreserveUnknownFields = ptr.To(true) - } - - // Apply custom transformations - props = g.transformations(props, schema, propertyConfig, extensionsSchema, depth, path) - - return props -} - -func (g *Generator) transformations(props *apiextensions.JSONSchemaProps, schemaRef *openapi3.SchemaRef, mapping *configv1alpha1.PropertyMapping, extensionsSchema *openapi3.SchemaRef, depth int, path []string) *apiextensions.JSONSchemaProps { - result := props - result = handleAdditionalProperties(result, schemaRef.Value.AdditionalProperties.Has) - result = removeUnknownFormats(result) - result = g.oneOfRefsTransform(result, schemaRef.Value.OneOf, mapping, extensionsSchema, depth, path) - return result -} - -func handleAdditionalProperties(props *apiextensions.JSONSchemaProps, additionalPropertiesAllowed *bool) *apiextensions.JSONSchemaProps { - if additionalPropertiesAllowed != nil && *additionalPropertiesAllowed { - props.XPreserveUnknownFields = additionalPropertiesAllowed - } - return props -} - -func removeUnknownFormats(props *apiextensions.JSONSchemaProps) *apiextensions.JSONSchemaProps { - switch props.Format { - case "int32", "int64", "float", "double", "byte", "date", "date-time", "password": - // Valid formats https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#format - case "": - props.Format = "" - default: - props.Format = "" - } - return props -} - -// oneOfRefsTransform transforms oneOf with a list of $ref to a list of nullable properties -func (g *Generator) oneOfRefsTransform(props *apiextensions.JSONSchemaProps, oneOf openapi3.SchemaRefs, mapping *configv1alpha1.PropertyMapping, extensionsSchema *openapi3.SchemaRef, depth int, path []string) *apiextensions.JSONSchemaProps { - if props.OneOf != nil && len(props.Properties) == 0 && props.AdditionalProperties == nil { - result := props.DeepCopy() - result.Type = "object" - result.OneOf = nil - - options := []apiextensions.JSON{} - for _, v := range oneOf { - if v.Ref == "" { - // this transform does not apply here - // return the original props - return props - } - name := v.Ref - name = name[strings.LastIndex(name, "/")+1:] - name = strcase.LowerCamelCase(name) - options = append(options, name) - result := g.ConvertProperty(v, extensionsSchema, mapping, depth+1, append(path, name)...) - if result == nil { - continue - } - result.Properties[name] = *result - } - - result.Properties["type"] = apiextensions.JSONSchemaProps{ - Type: "string", - Enum: options, - Description: "Type is the discriminator for the different possible values", - } - - return result - } - return props -} - -func (g *Generator) convertPropertySlice(schemas openapi3.SchemaRefs, mapping *configv1alpha1.PropertyMapping, extensionsSchema *openapi3.SchemaRef, depth int, path []string) []apiextensions.JSONSchemaProps { - var s []apiextensions.JSONSchemaProps - for _, schema := range schemas { - result := g.ConvertProperty(schema, extensionsSchema, mapping, depth+1, path...) - if result == nil { - continue - } - s = append(s, *result) - } - return s -} - -// enumJSON converts []interface{} to []JSON -func enumJSON(enum []interface{}) []apiextensions.JSON { - var s []apiextensions.JSON - for _, elt := range enum { - s = append(s, apiextensions.JSON(elt)) - } - return s -} - -func (g *Generator) convertPropertyOrArray(schema, extensionsSchema *openapi3.SchemaRef, mapping *configv1alpha1.PropertyMapping, depth int, path []string) *apiextensions.JSONSchemaPropsOrArray { - if schema == nil { - return nil - } - extensionsSchema.Value.Items = openapi3.NewSchemaRef("", openapi3.NewSchema()) - return &apiextensions.JSONSchemaPropsOrArray{ - Schema: g.ConvertProperty(schema, extensionsSchema.Value.Items, mapping, depth+1, path...), - } -} - -func (g *Generator) convertPropertyOrBool(schema, extensionsSchema *openapi3.SchemaRef, mapping *configv1alpha1.PropertyMapping, depth int, path []string) *apiextensions.JSONSchemaPropsOrBool { - if schema == nil { - return nil - } - - return &apiextensions.JSONSchemaPropsOrBool{ - Schema: g.ConvertProperty(schema, extensionsSchema, mapping, depth+1, path...), - Allows: true, - } -} - -func (g *Generator) ConvertPropertyMap(schemaMap openapi3.Schemas, extensionsSchema *openapi3.SchemaRef, mapping *configv1alpha1.PropertyMapping, depth int, path ...string) map[string]apiextensions.JSONSchemaProps { - m := make(map[string]apiextensions.JSONSchemaProps) - for key, schema := range schemaMap { - childExtensionsSchema := openapi3.NewSchemaRef("", openapi3.NewSchema()) - result := g.ConvertProperty(schema, childExtensionsSchema, mapping, depth+1, append(path, key)...) - if result == nil { - continue - } - - propName := key - if result.ID != "" { // workaround for the fact that CRD props do not let us specify its own property name - propName = result.ID - result.ID = "" - } - - if extensionsSchema.Value.Properties == nil { - extensionsSchema.Value.Properties = make(openapi3.Schemas) - } - extensionsSchema.Value.Properties[propName] = childExtensionsSchema - - m[propName] = *result - } - return m -} diff --git a/tools/openapi2crd/pkg/generator/converter.go b/tools/openapi2crd/pkg/generator/converter.go new file mode 100644 index 0000000000..04c489b61e --- /dev/null +++ b/tools/openapi2crd/pkg/generator/converter.go @@ -0,0 +1,325 @@ +// Copyright 2025 MongoDB Inc +// +// 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 generator + +import ( + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stoewer/go-strcase" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + "k8s.io/utils/ptr" + + "tools/openapi2crd/pkg/converter" + "tools/openapi2crd/pkg/plugins" +) + +func (g *Generator) Convert(input converter.PropertyConvertInput) *apiextensions.JSONSchemaProps { + if input.Depth == 10 { + return nil + } + + if input.Schema == nil { + return nil + } + + if len(input.Path) == 0 { + input.Path = []string{"$"} + } + + propertySchema := input.Schema.Value + if propertySchema == nil { + return nil + } + + typ := "" + if propertySchema.Type != nil && len(propertySchema.Type.Slice()) > 0 { + typ = (*propertySchema.Type)[0] + } + example := apiextensions.JSON(propertySchema.Example) + extensionSchemaRef := input.ExtensionsSchemaRef + props := &apiextensions.JSONSchemaProps{ + //ID: schemaProps.ID, + //Schema: apiextensions.JSONSchemaURL(string(schemaRef.Ref.)), + //Ref: ref, + Description: propertySchema.Description, + Type: typ, + //Format: schemaProps.Format, + Title: propertySchema.Title, + //Maximum: schemaProps.Max, + //ExclusiveMaximum: schemaProps.ExclusiveMax, + //Minimum: schemaProps.Min, + //ExclusiveMinimum: schemaProps.ExclusiveMin, + //MaxLength: castUInt64P(schemaProps.MaxLength), + //MinLength: castUInt64(schemaProps.MinLength), + // patterns seem to be incompatible in Atlas OpenAPI + //Pattern: schemaProps.Pattern, + //MaxItems: castUInt64P(schemaProps.MaxItems), + //MinItems: castUInt64(schemaProps.MinItems), + UniqueItems: false, // The field uniqueItems cannot be set to true. + MultipleOf: propertySchema.MultipleOf, + //Enum: enumJSON(schemaProps.Enum), + //MaxProperties: castUInt64P(schemaProps.MaxProps), + //MinProperties: castUInt64(schemaProps.MinProps), + Required: propertySchema.Required, + Items: g.convertPropertyOrArray( + converter.PropertyConvertInput{ + Schema: propertySchema.Items, + ExtensionsSchemaRef: extensionSchemaRef, + PropertyConfig: input.PropertyConfig, + Depth: input.Depth, + Path: append(input.Path, "[*]"), + }, + ), + AllOf: g.convertPropertySlice(propertySchema.AllOf, input), + OneOf: g.convertPropertySlice(propertySchema.OneOf, input), + AnyOf: g.convertPropertySlice(propertySchema.AnyOf, input), + Not: g.Convert( + converter.PropertyConvertInput{ + Schema: propertySchema.Not, + ExtensionsSchemaRef: extensionSchemaRef, + PropertyConfig: input.PropertyConfig, + Depth: input.Depth + 1, + Path: input.Path, + }, + ), + Properties: g.convertPropertyMap(propertySchema.Properties, input), + AdditionalProperties: g.convertPropertyOrBool( + converter.PropertyConvertInput{ + Schema: propertySchema.AdditionalProperties.Schema, + ExtensionsSchemaRef: extensionSchemaRef, + PropertyConfig: input.PropertyConfig, + Depth: input.Depth, + Path: input.Path, + }, + ), + Example: &example, + } + + for _, p := range g.pluginSet.Property { + req := &plugins.PropertyProcessorRequest{ + Property: props, + PropertyConfig: input.PropertyConfig, + OpenAPISchema: propertySchema, + ExtensionsSchema: extensionSchemaRef, + Path: input.Path, + } + err := p.Process(req) + // Currently, property plugins are not expected to return an error. + // If an error case is introduced in the future, we should handle it appropriately. + if err != nil { + return nil + } + + if req.Property == nil { + return nil + } + } + + if props.Type == "" { + props.Type = "object" + } + + if props.Type == "object" && props.Items == nil && len(props.Properties) == 0 && props.AdditionalProperties == nil { + props.XPreserveUnknownFields = ptr.To(true) + } + + // Apply custom transformations + props = g.transformations( + props, + converter.PropertyConvertInput{ + Schema: input.Schema, + ExtensionsSchemaRef: extensionSchemaRef, + PropertyConfig: input.PropertyConfig, + Depth: input.Depth, + Path: input.Path, + }, + ) + + return props +} + +func (g *Generator) convertPropertyOrBool(input converter.PropertyConvertInput) *apiextensions.JSONSchemaPropsOrBool { + if input.Schema == nil { + return nil + } + + return &apiextensions.JSONSchemaPropsOrBool{ + Schema: g.Convert( + converter.PropertyConvertInput{ + Schema: input.Schema, + ExtensionsSchemaRef: input.ExtensionsSchemaRef, + PropertyConfig: input.PropertyConfig, + Depth: input.Depth + 1, + Path: input.Path, + }, + ), + Allows: true, + } +} + +func (g *Generator) convertPropertyOrArray(input converter.PropertyConvertInput) *apiextensions.JSONSchemaPropsOrArray { + if input.Schema == nil { + return nil + } + + input.ExtensionsSchemaRef.Value.Items = openapi3.NewSchemaRef("", openapi3.NewSchema()) + + return &apiextensions.JSONSchemaPropsOrArray{ + Schema: g.Convert( + converter.PropertyConvertInput{ + Schema: input.Schema, + ExtensionsSchemaRef: input.ExtensionsSchemaRef.Value.Items, + PropertyConfig: input.PropertyConfig, + Depth: input.Depth + 1, + Path: input.Path, + }, + ), + } +} + +func (g *Generator) convertPropertySlice(schemas openapi3.SchemaRefs, input converter.PropertyConvertInput) []apiextensions.JSONSchemaProps { + if len(schemas) == 0 { + return nil + } + + s := make([]apiextensions.JSONSchemaProps, 0, len(schemas)) + + for _, schema := range schemas { + input.Depth++ + result := g.Convert( + converter.PropertyConvertInput{ + Schema: schema, + ExtensionsSchemaRef: input.ExtensionsSchemaRef, + PropertyConfig: input.PropertyConfig, + Depth: input.Depth + 1, + Path: input.Path, + }, + ) + if result == nil { + continue + } + s = append(s, *result) + } + + return s +} + +func (g *Generator) convertPropertyMap(schemaMap openapi3.Schemas, input converter.PropertyConvertInput) map[string]apiextensions.JSONSchemaProps { + m := make(map[string]apiextensions.JSONSchemaProps) + for key, schema := range schemaMap { + childExtensionsSchema := openapi3.NewSchemaRef("", openapi3.NewSchema()) + result := g.Convert( + converter.PropertyConvertInput{ + Schema: schema, + ExtensionsSchemaRef: childExtensionsSchema, + PropertyConfig: input.PropertyConfig, + Depth: input.Depth + 1, + Path: append(input.Path, key), + }, + ) + if result == nil { + continue + } + + propName := key + if result.ID != "" { // workaround for the fact that CRD props do not let us specify its own property name + propName = result.ID + result.ID = "" + } + + if input.ExtensionsSchemaRef.Value.Properties == nil { + input.ExtensionsSchemaRef.Value.Properties = make(openapi3.Schemas) + } + input.ExtensionsSchemaRef.Value.Properties[propName] = childExtensionsSchema + + m[propName] = *result + } + + return m +} + +func (g *Generator) transformations(props *apiextensions.JSONSchemaProps, input converter.PropertyConvertInput) *apiextensions.JSONSchemaProps { + result := props + result = handleAdditionalProperties(result, input.Schema.Value.AdditionalProperties.Has) + result = removeUnknownFormats(result) + result = g.oneOfRefsTransform(result, input.Schema.Value.OneOf, input) + + return result +} + +func (g *Generator) oneOfRefsTransform(props *apiextensions.JSONSchemaProps, oneOf openapi3.SchemaRefs, input converter.PropertyConvertInput) *apiextensions.JSONSchemaProps { + if props.OneOf != nil && len(props.Properties) == 0 && props.AdditionalProperties == nil { + result := props.DeepCopy() + result.Type = "object" + result.OneOf = nil + + options := make([]apiextensions.JSON, 0, len(oneOf)) + for _, v := range oneOf { + if v.Ref == "" { + // this transform does not apply here + // return the original props + return props + } + name := v.Ref + name = name[strings.LastIndex(name, "/")+1:] + name = strcase.LowerCamelCase(name) + options = append(options, name) + result = g.Convert( + converter.PropertyConvertInput{ + Schema: v, + ExtensionsSchemaRef: input.ExtensionsSchemaRef, + PropertyConfig: input.PropertyConfig, + Depth: input.Depth + 1, + Path: append(input.Path, name), + }, + ) + if result == nil { + continue + } + result.Properties[name] = *result + } + + result.Properties["type"] = apiextensions.JSONSchemaProps{ + Type: "string", + Enum: options, + Description: "Type is the discriminator for the different possible values", + } + + return result + } + + return props +} + +func handleAdditionalProperties(props *apiextensions.JSONSchemaProps, additionalPropertiesAllowed *bool) *apiextensions.JSONSchemaProps { + if additionalPropertiesAllowed != nil && *additionalPropertiesAllowed { + props.XPreserveUnknownFields = additionalPropertiesAllowed + } + return props +} + +func removeUnknownFormats(props *apiextensions.JSONSchemaProps) *apiextensions.JSONSchemaProps { + switch props.Format { + case "int32", "int64", "float", "double", "byte", "date", "date-time", "password": + // Valid formats https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#format + case "": + props.Format = "" + default: + props.Format = "" + } + return props +} diff --git a/tools/openapi2crd/pkg/generator/converter_test.go b/tools/openapi2crd/pkg/generator/converter_test.go new file mode 100644 index 0000000000..c02ddac3d6 --- /dev/null +++ b/tools/openapi2crd/pkg/generator/converter_test.go @@ -0,0 +1,249 @@ +// Copyright 2025 MongoDB Inc +// +// 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 generator + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + + configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" + "tools/openapi2crd/pkg/converter" + "tools/openapi2crd/pkg/plugins" +) + +func TestGeneratorConvert(t *testing.T) { + example := apiextensions.JSON(nil) + trueVar := true + + tests := map[string]struct { + input converter.PropertyConvertInput + expectedProps *apiextensions.JSONSchemaProps + }{ + "standard schema": { + input: converter.PropertyConvertInput{ + PropertyConfig: &configv1alpha1.PropertyMapping{ + Schema: "Pet", + }, + Schema: regularSchemaRef(), + ExtensionsSchemaRef: openapi3.NewSchemaRef("", openapi3.NewSchema()), + Path: []string{}, + }, + expectedProps: &apiextensions.JSONSchemaProps{ + Type: "object", + AllOf: []apiextensions.JSONSchemaProps{ + { + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "id": { + Type: "integer", + Properties: map[string]apiextensions.JSONSchemaProps{}, + Example: &example, + }, + }, + Example: &example, + }, + { + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "active": { + Type: "boolean", + Properties: map[string]apiextensions.JSONSchemaProps{}, + Example: &example, + }, + }, + Example: &example, + }, + }, + Properties: map[string]apiextensions.JSONSchemaProps{ + "tags": { + Type: "array", + Items: &apiextensions.JSONSchemaPropsOrArray{ + Schema: &apiextensions.JSONSchemaProps{ + Type: "string", + Properties: map[string]apiextensions.JSONSchemaProps{}, + Example: &example, + }, + }, + Properties: map[string]apiextensions.JSONSchemaProps{}, + Example: &example, + }, + "name": { + Type: "string", + Properties: map[string]apiextensions.JSONSchemaProps{}, + Example: &example, + }, + "attributes": { + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{}, + AdditionalProperties: &apiextensions.JSONSchemaPropsOrBool{ + Allows: true, + Schema: &apiextensions.JSONSchemaProps{ + Type: "string", + Properties: map[string]apiextensions.JSONSchemaProps{}, + Example: &example, + }, + }, + Example: &example, + }, + }, + AdditionalProperties: &apiextensions.JSONSchemaPropsOrBool{ + Allows: true, + Schema: &apiextensions.JSONSchemaProps{ + Type: "string", + Properties: map[string]apiextensions.JSONSchemaProps{}, + Example: &example, + }, + }, + Example: &example, + }, + }, + "oneOf schema": { + input: converter.PropertyConvertInput{ + PropertyConfig: &configv1alpha1.PropertyMapping{ + Schema: "Pet", + }, + Schema: oneOfSchemaRef(), + ExtensionsSchemaRef: openapi3.NewSchemaRef("", openapi3.NewSchema()), + Path: []string{}, + }, + expectedProps: &apiextensions.JSONSchemaProps{ + Type: "object", + OneOf: []apiextensions.JSONSchemaProps{ + { + Type: "string", + Properties: map[string]apiextensions.JSONSchemaProps{}, + Example: &example, + }, + { + Type: "integer", + Properties: map[string]apiextensions.JSONSchemaProps{}, + Example: &example, + }, + }, + Properties: map[string]apiextensions.JSONSchemaProps{}, + XPreserveUnknownFields: &trueVar, + Example: &example, + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + propertyPlugin := plugins.NewPropertyPluginMock(t) + propertyPlugin.EXPECT().Process(mock.AnythingOfType("*plugins.PropertyProcessorRequest")). + Return(nil) + + g := &Generator{ + pluginSet: &plugins.Set{ + Property: []plugins.PropertyPlugin{propertyPlugin}, + }, + } + p := g.Convert(tt.input) + assert.Equal(t, tt.expectedProps, p) + }) + } +} + +func regularSchemaRef() *openapi3.SchemaRef { + return &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "name": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + "tags": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Items: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + "attributes": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + AdditionalProperties: openapi3.AdditionalProperties{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + }, + }, + AllOf: openapi3.SchemaRefs{ + { + Value: &openapi3.Schema{ + Properties: map[string]*openapi3.SchemaRef{ + "id": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + }, + }, + }, + }, + }, + { + Value: &openapi3.Schema{ + Properties: map[string]*openapi3.SchemaRef{ + "active": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"boolean"}, + }, + }, + }, + }, + }, + }, + AdditionalProperties: openapi3.AdditionalProperties{ + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + } +} + +func oneOfSchemaRef() *openapi3.SchemaRef { + return &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + OneOf: openapi3.SchemaRefs{ + { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + Ref: "#/components/schemas/CustomObject", + }, + { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"integer"}, + }, + }, + }, + }, + } +} diff --git a/tools/openapi2crd/pkg/generator/generator.go b/tools/openapi2crd/pkg/generator/generator.go index d1ea1d23bd..0823166ec8 100644 --- a/tools/openapi2crd/pkg/generator/generator.go +++ b/tools/openapi2crd/pkg/generator/generator.go @@ -13,20 +13,6 @@ // limitations under the License. // -/* -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 generator import ( @@ -37,90 +23,96 @@ import ( "github.com/getkin/kin-openapi/openapi3" "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" "sigs.k8s.io/yaml" + "tools/openapi2crd/pkg/apis/config/v1alpha1" - "tools/openapi2crd/pkg/atlas" "tools/openapi2crd/pkg/config" "tools/openapi2crd/pkg/plugins" ) type Generator struct { - config v1alpha1.CRDConfig - definitions map[string]v1alpha1.OpenAPIDefinition - plugins []plugins.Plugin + definitions map[string]v1alpha1.OpenAPIDefinition + pluginSet *plugins.Set + openapiLoader config.Loader + atlasLoader config.Loader } -func NewGenerator(crdConfig v1alpha1.CRDConfig, definitions []v1alpha1.OpenAPIDefinition) *Generator { - definitionsMap := map[string]v1alpha1.OpenAPIDefinition{} - for _, def := range definitions { - definitionsMap[def.Name] = def - } +func NewGenerator( + openAPIDefinitions map[string]v1alpha1.OpenAPIDefinition, + pluginSet *plugins.Set, + openapiLoader config.Loader, + atlasLoader config.Loader, +) *Generator { return &Generator{ - config: crdConfig, - definitions: definitionsMap, + definitions: openAPIDefinitions, + pluginSet: pluginSet, + openapiLoader: openapiLoader, + atlasLoader: atlasLoader, } } -func (g *Generator) majorVersions() []string { - var result []string - for _, m := range g.config.Mappings { - result = append(result, "- "+m.MajorVersion) - } - return result -} - -func (g *Generator) Generate(ctx context.Context) (*apiextensions.CustomResourceDefinition, error) { +func (g *Generator) Generate(ctx context.Context, crdConfig *v1alpha1.CRDConfig) (*apiextensions.CustomResourceDefinition, error) { crd := &apiextensions.CustomResourceDefinition{} - g.plugins = []plugins.Plugin{ - plugins.NewCrdPlugin(crd), - plugins.NewMajorVersionPlugin(crd), - plugins.NewParametersPlugin(crd), - plugins.NewEntryPlugin(crd), - plugins.NewStatusPlugin(crd), - plugins.NewSensitivePropertiesPlugin(), - plugins.NewSkippedPropertiesPlugin(), - plugins.NewReadOnlyPropertiesPlugin(), - plugins.NewReadWriteOnlyPropertiesPlugin(), - plugins.NewReferencesPlugin(crd), - plugins.NewMutualExclusiveMajorVersions(crd), - plugins.NewAtlasSdkVersionPlugin(crd, g.definitions), - } - extensionsSchema := openapi3.NewSchema() extensionsSchema.Properties = map[string]*openapi3.SchemaRef{ "spec": {Value: &openapi3.Schema{Properties: map[string]*openapi3.SchemaRef{}}}, } - for _, p := range g.plugins { - if err := p.ProcessCRD(g, &g.config); err != nil { + var err error + for _, p := range g.pluginSet.CRD { + err = p.Process(&plugins.CRDProcessorRequest{CRD: crd, CRDConfig: crdConfig}) + if err != nil { return nil, fmt.Errorf("error processing CRD in plugin %q: %w", p.Name(), err) } } - for _, mapping := range g.config.Mappings { + for _, mapping := range crdConfig.Mappings { def, ok := g.definitions[mapping.OpenAPIRef.Name] if !ok { return nil, fmt.Errorf("no OpenAPI definition named %q found", mapping.OpenAPIRef.Name) } var openApiSpec *openapi3.T + var err error - path := def.Path - if path == "" { - var err error - path, err = atlas.LoadOpenAPIPath(def.Package) + switch def.Path { + case "": + openApiSpec, err = g.atlasLoader.Load(ctx, def.Package) if err != nil { - return nil, fmt.Errorf("error loading OpenAPI package %q: %w", def.Package, err) + return nil, fmt.Errorf("error loading Atlas OpenAPI package %q: %w", def.Package, err) + } + default: + openApiSpec, err = g.openapiLoader.Load(ctx, def.Path) + if err != nil { + return nil, fmt.Errorf("error loading spec: %w", err) } } - openApiSpec, err := config.LoadOpenAPI(path) - if err != nil { - return nil, fmt.Errorf("error loading spec: %w", err) + for _, p := range g.pluginSet.Mapping { + err = p.Process( + &plugins.MappingProcessorRequest{ + CRD: crd, + MappingConfig: &mapping, + OpenAPISpec: openApiSpec, + ExtensionsSchema: extensionsSchema, + Converter: g, + }, + ) + if err != nil { + return nil, fmt.Errorf("error processing mapping plugin %s: %w", p.Name(), err) + } } - for _, p := range g.plugins { - if err := p.ProcessMapping(g, &mapping, openApiSpec, extensionsSchema); err != nil { - return nil, fmt.Errorf("error processing plugin %s: %w", p.Name(), err) + + for _, p := range g.pluginSet.Extension { + err = p.Process( + &plugins.ExtensionProcessorRequest{ + ExtensionsSchema: extensionsSchema, + ApiDefinitions: g.definitions, + MappingConfig: &mapping, + }, + ) + if err != nil { + return nil, fmt.Errorf("error processing extenssion plugin %s: %w", p.Name(), err) } } } @@ -137,13 +129,22 @@ func (g *Generator) Generate(ctx context.Context) (*apiextensions.CustomResource crd.Annotations["api-mappings"] = string(d) } - if err := ValidateCRD(ctx, crd); err != nil { + if err = ValidateCRD(ctx, crd); err != nil { log.Printf("Error validating CRD: %v", err) } return crd, nil } +func (g *Generator) majorVersions(config v1alpha1.CRDConfig) []string { + result := make([]string, 0, len(config.Mappings)) + for _, m := range config.Mappings { + result = append(result, "- "+m.MajorVersion) + } + + return result +} + func clearPropertiesWithoutExtensions(schema *openapi3.Schema) bool { if schema == nil { return false diff --git a/tools/openapi2crd/pkg/generator/generator_test.go b/tools/openapi2crd/pkg/generator/generator_test.go new file mode 100644 index 0000000000..6df0cfa1fe --- /dev/null +++ b/tools/openapi2crd/pkg/generator/generator_test.go @@ -0,0 +1,326 @@ +// Copyright 2025 MongoDB Inc +// +// 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 generator + +import ( + "context" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "tools/openapi2crd/pkg/apis/config/v1alpha1" + "tools/openapi2crd/pkg/config" + "tools/openapi2crd/pkg/plugins" +) + +func TestGeneratorMajorVersions(t *testing.T) { + tests := map[string]struct { + config v1alpha1.CRDConfig + expectedResult []string + }{ + "mapping with single version": { + config: v1alpha1.CRDConfig{ + Mappings: []v1alpha1.CRDMapping{ + { + MajorVersion: "v1", + }, + }, + }, + expectedResult: []string{"- v1"}, + }, + "mapping with multiple versions": { + config: v1alpha1.CRDConfig{ + Mappings: []v1alpha1.CRDMapping{ + { + MajorVersion: "v1", + }, + { + MajorVersion: "v2", + }, + }, + }, + expectedResult: []string{"- v1", "- v2"}, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + g := &Generator{} + result := g.majorVersions(tt.config) + assert.Equal(t, tt.expectedResult, result) + }) + } +} + +func TestClearPropertiesWithoutExtensions(t *testing.T) { + tests := map[string]struct { + schema *openapi3.Schema + expectedResult bool + }{ + "schema with properties and extensions": { + schema: &openapi3.Schema{ + Properties: map[string]*openapi3.SchemaRef{ + "prop1": {Value: &openapi3.Schema{Extensions: map[string]interface{}{"x-kubernetes-preserve-unknown-fields": true}}}, + "prop2": {Value: &openapi3.Schema{}}, + }, + }, + expectedResult: true, + }, + "schema with properties without extensions": { + schema: &openapi3.Schema{ + Properties: map[string]*openapi3.SchemaRef{ + "prop1": {Value: &openapi3.Schema{}}, + "prop2": {Value: &openapi3.Schema{}}, + }, + }, + expectedResult: false, + }, + "schema with nested properties and extensions": { + schema: &openapi3.Schema{ + Properties: map[string]*openapi3.SchemaRef{ + "prop1": {Value: &openapi3.Schema{ + Properties: map[string]*openapi3.SchemaRef{ + "nestedProp": {Value: &openapi3.Schema{Extensions: map[string]interface{}{"x-kubernetes-preserve-unknown-fields": true}}}, + }, + }}, + "prop2": {Value: &openapi3.Schema{}}, + }, + }, + expectedResult: true, + }, + "schema with additionalProperties and extensions": { + schema: &openapi3.Schema{ + AdditionalProperties: openapi3.AdditionalProperties{ + Schema: &openapi3.SchemaRef{Value: &openapi3.Schema{Extensions: map[string]interface{}{"x-kubernetes-preserve-unknown-fields": true}}}, + }, + }, + expectedResult: true, + }, + "schema with items and extensions": { + schema: &openapi3.Schema{ + Items: &openapi3.SchemaRef{Value: &openapi3.Schema{Extensions: map[string]interface{}{"x-kubernetes-preserve-unknown-fields": true}}}, + }, + expectedResult: true, + }, + "schema with allOf and extensions": { + schema: &openapi3.Schema{ + AllOf: []*openapi3.SchemaRef{ + {Value: &openapi3.Schema{Extensions: map[string]interface{}{"x-kubernetes-preserve-unknown-fields": true}}}, + }, + }, + expectedResult: true, + }, + "schema with anyOf and extensions": { + schema: &openapi3.Schema{ + AnyOf: []*openapi3.SchemaRef{ + {Value: &openapi3.Schema{Extensions: map[string]interface{}{"x-kubernetes-preserve-unknown-fields": true}}}, + }, + }, + expectedResult: true, + }, + "schema with oneOf and extensions": { + schema: &openapi3.Schema{ + OneOf: []*openapi3.SchemaRef{ + {Value: &openapi3.Schema{Extensions: map[string]interface{}{"x-kubernetes-preserve-unknown-fields": true}}}, + }, + }, + expectedResult: true, + }, + "nil schema": { + schema: nil, + expectedResult: false, + }, + "empty schema": { + schema: &openapi3.Schema{}, + expectedResult: false, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + result := clearPropertiesWithoutExtensions(tt.schema) + assert.Equal(t, tt.expectedResult, result) + }) + } +} + +func TestGeneratorGenerate(t *testing.T) { + tests := map[string]struct { + //openapi *openapi3.T + apiDefinitions map[string]v1alpha1.OpenAPIDefinition + config *v1alpha1.CRDConfig + expectedResult *apiextensions.CustomResourceDefinition + expectError bool + }{ + "generate with valid openapi and config": { + apiDefinitions: map[string]v1alpha1.OpenAPIDefinition{ + "Pet": { + Name: "Pet", + Path: "testdata/openapi.yaml", + }, + }, + config: &v1alpha1.CRDConfig{ + Mappings: []v1alpha1.CRDMapping{ + { + OpenAPIRef: v1alpha1.LocalObjectReference{ + Name: "Pet", + }, + }, + }, + }, + expectedResult: &apiextensions.CustomResourceDefinition{ + ObjectMeta: v1.ObjectMeta{ + Name: "examples.test.com", + }, + Spec: apiextensions.CustomResourceDefinitionSpec{ + Group: "test.com", + Names: apiextensions.CustomResourceDefinitionNames{ + Plural: "examples", + Singular: "example", + Kind: "Example", + ListKind: "ExampleList", + ShortNames: []string{"ex"}, + Categories: []string{"test"}, + }, + Versions: []apiextensions.CustomResourceDefinitionVersion{ + { + Name: "v1", + Served: true, + Storage: true, + }, + }, + Scope: apiextensions.NamespaceScoped, + Validation: &apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "name": { + Type: "string", + Description: "Name of the resource", + }, + }, + }, + "status": { + Type: "object", + Description: "Most recently observed status of the example.", + }, + }, + Required: []string{"spec"}, + }, + }, + PreserveUnknownFields: nil, + }, + Status: apiextensions.CustomResourceDefinitionStatus{ + StoredVersions: []string{"v1"}, + }, + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + openapiLoader := config.NewLoaderMock(t) + openapiLoader.EXPECT().Load(context.Background(), "testdata/openapi.yaml").Return(&openapi3.T{}, nil) + + atlasLoader := config.NewLoaderMock(t) + + crdPlugin := plugins.NewCRDPluginMock(t) + crdPlugin.EXPECT().Process(mock.AnythingOfType("*plugins.CRDProcessorRequest")). + RunAndReturn(func(request *plugins.CRDProcessorRequest) error { + baseCRD(request.CRD) + return nil + }) + mappingPlugin := plugins.NewMappingPluginMock(t) + mappingPlugin.EXPECT().Process(mock.AnythingOfType("*plugins.MappingProcessorRequest")). + RunAndReturn(func(request *plugins.MappingProcessorRequest) error { + request.CRD.Spec.Validation.OpenAPIV3Schema.Properties["spec"].Properties["name"] = apiextensions.JSONSchemaProps{ + Type: "string", + Description: "Name of the resource", + } + + return nil + }) + extensionPlugin := plugins.NewExtensionPluginMock(t) + extensionPlugin.EXPECT().Process(mock.AnythingOfType("*plugins.ExtensionProcessorRequest")).Return(nil) + + g := &Generator{ + definitions: tt.apiDefinitions, + pluginSet: &plugins.Set{ + CRD: []plugins.CRDPlugin{crdPlugin}, + Mapping: []plugins.MappingPlugin{mappingPlugin}, + Extension: []plugins.ExtensionPlugin{extensionPlugin}, + }, + openapiLoader: openapiLoader, + atlasLoader: atlasLoader, + } + result, err := g.Generate(context.Background(), tt.config) + if tt.expectError { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.Equal(t, tt.expectedResult, result) + } + }) + } +} + +func baseCRD(crd *apiextensions.CustomResourceDefinition) { + crd.ObjectMeta = v1.ObjectMeta{ + Name: "examples.test.com", + } + crd.Spec = apiextensions.CustomResourceDefinitionSpec{ + Group: "test.com", + Names: apiextensions.CustomResourceDefinitionNames{ + Plural: "examples", + Singular: "example", + Kind: "Example", + ListKind: "ExampleList", + ShortNames: []string{"ex"}, + Categories: []string{"test"}, + }, + Versions: []apiextensions.CustomResourceDefinitionVersion{ + { + Name: "v1", + Served: true, + Storage: true, + }, + }, + Validation: &apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "spec": { + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + "status": { + Type: "object", + Description: "Most recently observed status of the example.", + }, + }, + Required: []string{"spec"}, + }, + }, + Scope: apiextensions.NamespaceScoped, + } + crd.Status = apiextensions.CustomResourceDefinitionStatus{ + StoredVersions: []string{"v1"}, + } +} diff --git a/tools/openapi2crd/pkg/generator/validate.go b/tools/openapi2crd/pkg/generator/validate.go index 9988a9b967..95af0b010f 100644 --- a/tools/openapi2crd/pkg/generator/validate.go +++ b/tools/openapi2crd/pkg/generator/validate.go @@ -13,25 +13,12 @@ // limitations under the License. // -/* -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 generator import ( "context" "fmt" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions/validation" ) @@ -41,5 +28,6 @@ func ValidateCRD(ctx context.Context, crd *apiextensions.CustomResourceDefinitio if len(errorList) > 0 { return fmt.Errorf("error validating CRD %v: %w", crd.ObjectMeta.Name, errorList.ToAggregate()) } + return nil } diff --git a/tools/openapi2crd/pkg/generator/validate_test.go b/tools/openapi2crd/pkg/generator/validate_test.go new file mode 100644 index 0000000000..2b2cca0b09 --- /dev/null +++ b/tools/openapi2crd/pkg/generator/validate_test.go @@ -0,0 +1,116 @@ +// Copyright 2025 MongoDB Inc +// +// 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 generator + +import ( + "context" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + v1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/util/validation/field" +) + +func TestValidateCRD(t *testing.T) { + tests := map[string]struct { + crd *apiextensions.CustomResourceDefinition + expectedErr error + }{ + "valid CRD": { + crd: &apiextensions.CustomResourceDefinition{ + ObjectMeta: v1.ObjectMeta{ + Name: "examples.test.com", + }, + Spec: apiextensions.CustomResourceDefinitionSpec{ + Group: "test.com", + Names: apiextensions.CustomResourceDefinitionNames{ + Plural: "examples", + Singular: "example", + Kind: "Example", + ListKind: "ExampleList", + }, + Versions: []apiextensions.CustomResourceDefinitionVersion{ + { + Name: "v1", + Served: true, + Storage: true, + }, + }, + Validation: &apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + }, + }, + Scope: apiextensions.NamespaceScoped, + }, + Status: apiextensions.CustomResourceDefinitionStatus{ + StoredVersions: []string{"v1"}, + }, + }, + expectedErr: nil, + }, + "invalid CRD": { + crd: &apiextensions.CustomResourceDefinition{ + ObjectMeta: v1.ObjectMeta{ + Name: "examples.test.com", + }, + Spec: apiextensions.CustomResourceDefinitionSpec{ + Group: "test.com", + Names: apiextensions.CustomResourceDefinitionNames{ + Plural: "wrongs", + Singular: "wrong", + Kind: "Wrong", + ListKind: "WrongList", + }, + Versions: []apiextensions.CustomResourceDefinitionVersion{ + { + Name: "v1", + Served: true, + Storage: true, + }, + }, + Validation: &apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + }, + }, + Scope: apiextensions.NamespaceScoped, + }, + Status: apiextensions.CustomResourceDefinitionStatus{ + StoredVersions: []string{"v1"}, + }, + }, + expectedErr: fmt.Errorf( + "error validating CRD %v: %w", + "examples.test.com", + field.ErrorList{&field.Error{ + Type: field.ErrorTypeInvalid, + Field: "metadata.name", + BadValue: "examples.test.com", + Detail: "must be spec.names.plural+\".\"+spec.group"}, + }.ToAggregate(), + ), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + err := ValidateCRD(context.Background(), tt.crd) + assert.Equal(t, tt.expectedErr, err) + }) + } +} diff --git a/tools/openapi2crd/pkg/plugins/atlas_sdk_version.go b/tools/openapi2crd/pkg/plugins/atlas_sdk_version.go index b6a7b8943b..bb9417ea6a 100644 --- a/tools/openapi2crd/pkg/plugins/atlas_sdk_version.go +++ b/tools/openapi2crd/pkg/plugins/atlas_sdk_version.go @@ -15,41 +15,26 @@ package plugins -import ( - "github.com/getkin/kin-openapi/openapi3" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" - configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" -) - -type AtlasSdkVersionPlugin struct { - NoOp - crd *apiextensions.CustomResourceDefinition - definitions map[string]configv1alpha1.OpenAPIDefinition -} - -func NewAtlasSdkVersionPlugin(crd *apiextensions.CustomResourceDefinition, definitions map[string]configv1alpha1.OpenAPIDefinition) *AtlasSdkVersionPlugin { - return &AtlasSdkVersionPlugin{ - crd: crd, - definitions: definitions, - } -} +// AtlasSdkVersionPlugin is a plugin that adds the Atlas SDK version information as an OpenAPI extension in the CRD. +// It requires the entry plugin to be run first. +type AtlasSdkVersionPlugin struct{} func (p *AtlasSdkVersionPlugin) Name() string { return "atlas_sdk_version" } -func (p *AtlasSdkVersionPlugin) ProcessMapping(g Generator, mappingConfig *configv1alpha1.CRDMapping, openApiSpec *openapi3.T, extensionsSchema *openapi3.Schema) error { - pkg := p.definitions[mappingConfig.OpenAPIRef.Name].Package +func (p *AtlasSdkVersionPlugin) Process(req *ExtensionProcessorRequest) error { + pkg := req.ApiDefinitions[req.MappingConfig.OpenAPIRef.Name].Package if pkg == "" { return nil } - extensions := extensionsSchema.Properties["spec"].Value.Properties[mappingConfig.MajorVersion].Value.Extensions + extensions := req.ExtensionsSchema.Properties["spec"].Value.Properties[req.MappingConfig.MajorVersion].Value.Extensions if extensions == nil { extensions = map[string]interface{}{} } extensions["x-atlas-sdk-version"] = pkg - extensionsSchema.Properties["spec"].Value.Properties[mappingConfig.MajorVersion].Value.Extensions = extensions + req.ExtensionsSchema.Properties["spec"].Value.Properties[req.MappingConfig.MajorVersion].Value.Extensions = extensions return nil } diff --git a/tools/openapi2crd/pkg/plugins/atlas_sdk_version_test.go b/tools/openapi2crd/pkg/plugins/atlas_sdk_version_test.go new file mode 100644 index 0000000000..971a76ffb2 --- /dev/null +++ b/tools/openapi2crd/pkg/plugins/atlas_sdk_version_test.go @@ -0,0 +1,102 @@ +// Copyright 2025 MongoDB Inc +// +// 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 plugins + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + + configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" +) + +func TestAtlasSdkVersionName(t *testing.T) { + p := &AtlasSdkVersionPlugin{} + assert.Equal(t, "atlas_sdk_version", p.Name()) +} + +func TestAtlasSdkVersionPluginProcess(t *testing.T) { + tests := map[string]struct { + request *ExtensionProcessorRequest + expectedExtensions map[string]any + expectedErr error + }{ + "add atlas sdk version extension to the CRD": { + request: extensionRequest(t, map[string]configv1alpha1.OpenAPIDefinition{ + "v20250312": { + Name: "v20250312", + Package: "go.mongodb.org/atlas-sdk/v20250312005/admin", + }, + }), + expectedExtensions: map[string]any{"x-atlas-sdk-version": "go.mongodb.org/atlas-sdk/v20250312005/admin"}, + }, + "no sdk packgae defined": { + request: extensionRequest(t, nil), + expectedExtensions: nil, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + p := &AtlasSdkVersionPlugin{} + err := p.Process(tt.request) + assert.Equal(t, tt.expectedErr, err) + extensions := tt.request.ExtensionsSchema.Properties["spec"].Value.Properties["v20250312"].Value.Extensions + assert.Equal(t, tt.expectedExtensions, extensions) + }) + } +} + +func extensionRequest(t *testing.T, apiDefinitions map[string]configv1alpha1.OpenAPIDefinition) *ExtensionProcessorRequest { + t.Helper() + + extensionsSchema := openapi3.NewSchema() + extensionsSchema.Properties = map[string]*openapi3.SchemaRef{ + "spec": {Value: &openapi3.Schema{Properties: map[string]*openapi3.SchemaRef{ + "v20250312": {Value: &openapi3.Schema{Properties: map[string]*openapi3.SchemaRef{}}}, + }}}, + } + + return &ExtensionProcessorRequest{ + ExtensionsSchema: extensionsSchema, + ApiDefinitions: apiDefinitions, + MappingConfig: &configv1alpha1.CRDMapping{ + MajorVersion: "v20250312", + OpenAPIRef: configv1alpha1.LocalObjectReference{ + Name: "v20250312", + }, + ParametersMapping: configv1alpha1.PropertyMapping{ + Path: configv1alpha1.PropertyPath{ + Name: "/api/atlas/v2/groups", + Verb: "post", + }, + }, + EntryMapping: configv1alpha1.PropertyMapping{ + Schema: "Group", + Filters: configv1alpha1.Filters{ + ReadWriteOnly: true, + }, + }, + StatusMapping: configv1alpha1.PropertyMapping{ + Schema: "Group", + Filters: configv1alpha1.Filters{ + ReadOnly: true, + SkipProperties: []string{"$.links"}, + }, + }, + }, + } +} diff --git a/tools/openapi2crd/pkg/plugins/crd.go b/tools/openapi2crd/pkg/plugins/base.go similarity index 81% rename from tools/openapi2crd/pkg/plugins/crd.go rename to tools/openapi2crd/pkg/plugins/base.go index e6a59f09f1..b96b9cb8fe 100644 --- a/tools/openapi2crd/pkg/plugins/crd.go +++ b/tools/openapi2crd/pkg/plugins/base.go @@ -23,43 +23,36 @@ import ( v1 "k8s.io/apimachinery/pkg/apis/meta/v1" runtimeschema "k8s.io/apimachinery/pkg/runtime/schema" "k8s.io/utils/ptr" + configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" ) -type CrdPlugin struct { - NoOp - crd *apiextensions.CustomResourceDefinition -} +// Base is a plugin that add minimum required configuration to a CRD +type Base struct{} -func (c CrdPlugin) Name() string { - return "crd" -} - -func NewCrdPlugin(crd *apiextensions.CustomResourceDefinition) *CrdPlugin { - return &CrdPlugin{ - crd: crd, - } +func (p *Base) Name() string { + return "base" } -func (p *CrdPlugin) ProcessCRD(g Generator, crdConfig *configv1alpha1.CRDConfig) error { - pluralGvk, singularGvk := guessKindToResource(crdConfig.GVK) +func (p *Base) Process(req *CRDProcessorRequest) error { + pluralGvk, singularGvk := guessKindToResource(req.CRDConfig.GVK) - p.crd.ObjectMeta = v1.ObjectMeta{ + req.CRD.ObjectMeta = v1.ObjectMeta{ Name: fmt.Sprintf("%s.%s", pluralGvk.Resource, pluralGvk.Group), } - p.crd.Spec = apiextensions.CustomResourceDefinitionSpec{ + req.CRD.Spec = apiextensions.CustomResourceDefinitionSpec{ Group: pluralGvk.Group, Scope: apiextensions.NamespaceScoped, Names: apiextensions.CustomResourceDefinitionNames{ - Kind: crdConfig.GVK.Kind, - ListKind: fmt.Sprintf("%sList", crdConfig.GVK.Kind), + Kind: req.CRDConfig.GVK.Kind, + ListKind: fmt.Sprintf("%sList", req.CRDConfig.GVK.Kind), Plural: pluralGvk.Resource, Singular: singularGvk.Resource, }, Versions: []apiextensions.CustomResourceDefinitionVersion{ { - Name: crdConfig.GVK.Version, + Name: req.CRDConfig.GVK.Version, Served: true, Storage: true, }, @@ -76,7 +69,7 @@ func (p *CrdPlugin) ProcessCRD(g Generator, crdConfig *configv1alpha1.CRDConfig) %v -At most one versioned spec can be specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status`, singularGvk.Resource, strings.Join(majorVersions(crdConfig), "\n")), +At most one versioned spec can be specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status`, singularGvk.Resource, strings.Join(majorVersions(req.CRDConfig), "\n")), Properties: map[string]apiextensions.JSONSchemaProps{}, }, "status": { @@ -89,7 +82,7 @@ At most one versioned spec can be specified. More info: https://git.k8s.io/commu }, } - p.crd.Spec.Validation.OpenAPIV3Schema.Properties["status"].Properties["conditions"] = apiextensions.JSONSchemaProps{ + req.CRD.Spec.Validation.OpenAPIV3Schema.Properties["status"].Properties["conditions"] = apiextensions.JSONSchemaProps{ Type: "array", Description: "Represents the latest available observations of a resource's current state.", Items: &apiextensions.JSONSchemaPropsOrArray{ @@ -112,33 +105,25 @@ At most one versioned spec can be specified. More info: https://git.k8s.io/commu XListType: ptr.To("map"), } - p.crd.Status.StoredVersions = []string{} + req.CRD.Status.StoredVersions = []string{} // enable status subresource - p.crd.Spec.Subresources = &apiextensions.CustomResourceSubresources{ + req.CRD.Spec.Subresources = &apiextensions.CustomResourceSubresources{ Status: &apiextensions.CustomResourceSubresourceStatus{}, } - p.crd.Spec.Names.Categories = crdConfig.Categories - p.crd.Spec.Names.ShortNames = crdConfig.ShortNames + req.CRD.Spec.Names.Categories = req.CRDConfig.Categories + req.CRD.Spec.Names.ShortNames = req.CRDConfig.ShortNames - for _, version := range p.crd.Spec.Versions { + for _, version := range req.CRD.Spec.Versions { if version.Storage { - p.crd.Status.StoredVersions = append(p.crd.Status.StoredVersions, version.Name) + req.CRD.Status.StoredVersions = append(req.CRD.Status.StoredVersions, version.Name) } } return nil } -func majorVersions(crdConfig *configv1alpha1.CRDConfig) []string { - var result []string - for _, m := range crdConfig.Mappings { - result = append(result, "- "+m.MajorVersion) - } - return result -} - func guessKindToResource(gvk v1.GroupVersionKind) ( /*plural*/ runtimeschema.GroupVersionResource /*singular*/, runtimeschema.GroupVersionResource) { runtimeGVK := runtimeschema.GroupVersionKind{ Group: gvk.Group, @@ -163,3 +148,11 @@ func guessKindToResource(gvk v1.GroupVersionKind) ( /*plural*/ runtimeschema.Gro return runtimeGVK.GroupVersion().WithResource(singularName + "s"), singular } + +func majorVersions(crdConfig *configv1alpha1.CRDConfig) []string { + result := make([]string, 0, len(crdConfig.Mappings)) + for _, m := range crdConfig.Mappings { + result = append(result, "- "+m.MajorVersion) + } + return result +} diff --git a/tools/openapi2crd/pkg/plugins/base_test.go b/tools/openapi2crd/pkg/plugins/base_test.go new file mode 100644 index 0000000000..a750259bde --- /dev/null +++ b/tools/openapi2crd/pkg/plugins/base_test.go @@ -0,0 +1,281 @@ +// Copyright 2025 MongoDB Inc +// +// 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 plugins + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/utils/ptr" + + configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" +) + +func TestBaseName(t *testing.T) { + p := &Base{} + assert.Equal(t, "base", p.Name()) +} + +func TestBaseProcess(t *testing.T) { + tests := map[string]struct { + request *CRDProcessorRequest + expectedCrd *apiextensions.CustomResourceDefinition + expectedErr error + }{ + "add the base of the CRD": { + request: groupCRDRequest(t, &apiextensions.CustomResourceDefinition{}), + expectedCrd: groupBaseCRD(t), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + p := &Base{} + err := p.Process(tt.request) + assert.Equal(t, tt.expectedErr, err) + assert.Equal(t, tt.expectedCrd, tt.request.CRD) + }) + } +} + +func TestGuessKindToResource(t *testing.T) { + tests := map[string]struct { + gvk metav1.GroupVersionKind + expectedPlural schema.GroupVersionResource + expectedSingular schema.GroupVersionResource + }{ + "regular case": { + gvk: metav1.GroupVersionKind{ + Group: "atlas.generated.mongodb.com", + Version: "v1", + Kind: "Group", + }, + expectedPlural: schema.GroupVersionResource{ + Group: "atlas.generated.mongodb.com", + Version: "v1", + Resource: "groups", + }, + expectedSingular: schema.GroupVersionResource{ + Group: "atlas.generated.mongodb.com", + Version: "v1", + Resource: "group", + }, + }, + "kind ending with s": { + gvk: metav1.GroupVersionKind{ + Group: "example.com", + Version: "v1", + Kind: "Bus", + }, + expectedPlural: schema.GroupVersionResource{ + Group: "example.com", + Version: "v1", + Resource: "buses", + }, + expectedSingular: schema.GroupVersionResource{ + Group: "example.com", + Version: "v1", + Resource: "bus", + }, + }, + "kind ending with x": { + gvk: metav1.GroupVersionKind{ + Group: "example.com", + Version: "v1", + Kind: "Box", + }, + expectedPlural: schema.GroupVersionResource{ + Group: "example.com", + Version: "v1", + Resource: "boxes", + }, + expectedSingular: schema.GroupVersionResource{ + Group: "example.com", + Version: "v1", + Resource: "box", + }, + }, + "kind ending with y": { + gvk: metav1.GroupVersionKind{ + Group: "example.com", + Version: "v1", + Kind: "City", + }, + expectedPlural: schema.GroupVersionResource{ + Group: "example.com", + Version: "v1", + Resource: "cities", + }, + expectedSingular: schema.GroupVersionResource{ + Group: "example.com", + Version: "v1", + Resource: "city", + }, + }, + "empty kind": { + gvk: metav1.GroupVersionKind{}, + expectedPlural: schema.GroupVersionResource{}, + expectedSingular: schema.GroupVersionResource{}, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + plural, singular := guessKindToResource(tt.gvk) + assert.Equal(t, tt.expectedPlural, plural) + assert.Equal(t, tt.expectedSingular, singular) + }) + } +} + +func groupCRDRequest(t *testing.T, crd *apiextensions.CustomResourceDefinition) *CRDProcessorRequest { + t.Helper() + + return &CRDProcessorRequest{ + CRD: crd, + CRDConfig: &configv1alpha1.CRDConfig{ + GVK: metav1.GroupVersionKind{ + Group: "atlas.generated.mongodb.com", + Version: "v1", + Kind: "Group", + }, + Categories: []string{"atlas"}, + Mappings: []configv1alpha1.CRDMapping{ + { + MajorVersion: "v20250312", + OpenAPIRef: configv1alpha1.LocalObjectReference{ + Name: "v20250312", + }, + ParametersMapping: configv1alpha1.PropertyMapping{ + Path: configv1alpha1.PropertyPath{ + Name: "/api/atlas/v2/groups", + Verb: "post", + }, + }, + EntryMapping: configv1alpha1.PropertyMapping{ + Schema: "Group", + Filters: configv1alpha1.Filters{ + ReadWriteOnly: true, + }, + }, + StatusMapping: configv1alpha1.PropertyMapping{ + Schema: "Group", + Filters: configv1alpha1.Filters{ + ReadOnly: true, + SkipProperties: []string{"$.links"}, + }, + }, + }, + }, + ShortNames: []string{"ag"}, + }, + } +} + +func groupBaseCRD(t *testing.T) *apiextensions.CustomResourceDefinition { + t.Helper() + + return &apiextensions.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "groups.atlas.generated.mongodb.com", + }, + Spec: apiextensions.CustomResourceDefinitionSpec{ + Group: "atlas.generated.mongodb.com", + Names: apiextensions.CustomResourceDefinitionNames{ + Kind: "Group", + ListKind: "GroupList", + Plural: "groups", + Singular: "group", + ShortNames: []string{"ag"}, + Categories: []string{"atlas"}, + }, + Scope: apiextensions.NamespaceScoped, + Versions: []apiextensions.CustomResourceDefinitionVersion{ + { + Name: "v1", + Served: true, + Storage: true, + }, + }, + PreserveUnknownFields: ptr.To(false), + Validation: &apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Description: "A group, managed by the MongoDB Kubernetes Atlas Operator.", + Properties: map[string]apiextensions.JSONSchemaProps{ + "spec": { + Type: "object", + Description: "Specification of the group supporting the following versions:\n\n- v20250312\n\nAt most one versioned spec can be specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status", + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + "status": { + Type: "object", + Description: `Most recently observed read-only status of the group for the specified resource version. This data may not be up to date and is populated by the system. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status`, + Properties: map[string]apiextensions.JSONSchemaProps{ + "conditions": { + Type: "array", + Description: "Represents the latest available observations of a resource's current state.", + Items: &apiextensions.JSONSchemaPropsOrArray{ + Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "type": { + Type: "string", + Description: "Type of condition.", + }, + "status": { + Type: "string", + Description: "Status of the condition, one of True, False, Unknown.", + }, + "lastTransitionTime": { + Type: "string", + Format: "date-time", + Description: "Last time the condition transitioned from one status to another.", + }, + "reason": { + Type: "string", + Description: "The reason for the condition's last transition.", + }, + "message": { + Type: "string", + Description: "A human readable message indicating details about the transition.", + }, + "observedGeneration": { + Type: "integer", + Description: "observedGeneration represents the .metadata.generation that the condition was set based upon.", + }, + }, + Required: []string{"type", "status"}, + }, + }, + XListMapKeys: []string{"type"}, + XListType: ptr.To("map"), + }, + }, + }, + }, + }, + }, + Subresources: &apiextensions.CustomResourceSubresources{ + Status: &apiextensions.CustomResourceSubresourceStatus{}, + }, + }, + Status: apiextensions.CustomResourceDefinitionStatus{ + StoredVersions: []string{"v1"}, + }, + } +} diff --git a/tools/openapi2crd/pkg/plugins/catalog.go b/tools/openapi2crd/pkg/plugins/catalog.go new file mode 100644 index 0000000000..528595b88f --- /dev/null +++ b/tools/openapi2crd/pkg/plugins/catalog.go @@ -0,0 +1,199 @@ +// Copyright 2025 MongoDB Inc +// +// 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 plugins + +import ( + "errors" + "fmt" + + configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" +) + +type Set struct { + Name string + Default bool + CRD []CRDPlugin + Mapping []MappingPlugin + Property []PropertyPlugin + Extension []ExtensionPlugin +} + +type Catalog struct { + crd map[string]CRDPlugin + mapping map[string]MappingPlugin + property map[string]PropertyPlugin + extension map[string]ExtensionPlugin +} + +func (c *Catalog) IsCRD(name string) bool { + _, ok := c.crd[name] + + return ok +} + +func (c *Catalog) IsMapping(name string) bool { + _, ok := c.mapping[name] + + return ok +} + +func (c *Catalog) IsProperty(name string) bool { + _, ok := c.property[name] + + return ok +} + +func (c *Catalog) IsMappingExtension(name string) bool { + _, ok := c.extension[name] + + return ok +} + +func (c *Catalog) BuildSets(setsDefinition []configv1alpha1.PluginSet) ([]Set, error) { + orderedSet, err := orderPluginSet(setsDefinition) + if err != nil { + return nil, err + } + + sets := make([]Set, 0, len(orderedSet)) + hasDefault := false + + for _, pluginSet := range orderedSet { + if pluginSet.Default { + if hasDefault { + return nil, errors.New("multiple default plugin sets defined") + } + + hasDefault = true + } + + var set Set + + if pluginSet.InheritFrom != "" { + var parent *Set + for _, s := range sets { + if s.Name == pluginSet.InheritFrom { + parent = &s + break + } + } + + if parent == nil { + return nil, fmt.Errorf("parent plugin set %s not found for plugin set %s", pluginSet.InheritFrom, pluginSet.Name) + } + + set = *parent + set.Name = pluginSet.Name + set.Default = pluginSet.Default + } else { + set = Set{ + Name: pluginSet.Name, + Default: pluginSet.Default, + CRD: make([]CRDPlugin, 0, len(c.crd)), + Mapping: make([]MappingPlugin, 0, len(c.mapping)), + Property: make([]PropertyPlugin, 0, len(c.property)), + Extension: make([]ExtensionPlugin, 0, len(c.extension)), + } + } + for _, plugin := range pluginSet.Plugins { + switch { + case c.IsCRD(plugin): + set.CRD = append(set.CRD, c.crd[plugin]) + case c.IsMapping(plugin): + set.Mapping = append(set.Mapping, c.mapping[plugin]) + case c.IsProperty(plugin): + set.Property = append(set.Property, c.property[plugin]) + case c.IsMappingExtension(plugin): + set.Extension = append(set.Extension, c.extension[plugin]) + default: + return nil, fmt.Errorf("plugin %s not found in catalog", plugin) + } + } + + sets = append(sets, set) + } + + return sets, nil +} + +func NewCatalog() *Catalog { + return &Catalog{ + crd: map[string]CRDPlugin{ + "base": &Base{}, + "mutual_exclusive_major_versions": &MutualExclusiveMajorVersions{}, + }, + mapping: map[string]MappingPlugin{ + "major_version": &MajorVersion{}, + "parameters": &Parameters{}, + "entry": &Entry{}, + "status": &Status{}, + "references": &References{}, + }, + property: map[string]PropertyPlugin{ + "sensitive_properties": &SensitiveProperties{}, + "skipped_properties": &SkippedProperties{}, + "read_only_properties": &ReadOnlyProperties{}, + "read_write_properties": &ReadWriteProperties{}, + }, + extension: map[string]ExtensionPlugin{ + "atlas_sdk_version": &AtlasSdkVersionPlugin{}, + "references_metadata": &ReferencesMetadata{}, + }, + } +} + +func GetPluginSet(sets []Set, name string) (*Set, error) { + for _, set := range sets { + if name == "" && set.Default { + return &set, nil + } + + if set.Name == name { + return &set, nil + } + } + + return nil, errors.New(fmt.Sprintf("pluginSet %s not found", name)) +} + +func orderPluginSet(setsDefinition []configv1alpha1.PluginSet) ([]configv1alpha1.PluginSet, error) { + mapSets := make(map[string]configv1alpha1.PluginSet) + orderedSets := make([]configv1alpha1.PluginSet, 0, len(setsDefinition)) + visitCount := make(map[string]int) + + for len(setsDefinition) > 0 { + set := setsDefinition[0] + setsDefinition = setsDefinition[1:] + + hasDependency := set.InheritFrom != "" + _, dependencyIsMapped := mapSets[set.InheritFrom] + + if hasDependency && !dependencyIsMapped { + visitCount[set.Name]++ + if visitCount[set.Name] > len(setsDefinition)+1 { + return nil, fmt.Errorf("circular dependency detected for plugin set: %s", set.Name) + } + + setsDefinition = append(setsDefinition, set) + continue + } + + orderedSets = append(orderedSets, set) + mapSets[set.Name] = set + } + + return orderedSets, nil +} diff --git a/tools/openapi2crd/pkg/plugins/catalog_test.go b/tools/openapi2crd/pkg/plugins/catalog_test.go new file mode 100644 index 0000000000..026111a1a5 --- /dev/null +++ b/tools/openapi2crd/pkg/plugins/catalog_test.go @@ -0,0 +1,249 @@ +// Copyright 2025 MongoDB Inc +// +// 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 plugins + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + + configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" +) + +func TestBuildSets(t *testing.T) { + tests := map[string]struct { + setsDefinition []configv1alpha1.PluginSet + expectedSet []Set + expectedErr error + }{ + "build sets": { + setsDefinition: []configv1alpha1.PluginSet{ + { + Name: "set-1", + Default: true, + Plugins: []string{"base", "entry", "status"}, + }, + { + Name: "set-2", + Default: false, + InheritFrom: "set-1", + Plugins: []string{"references", "read_only_properties", "read_write_properties"}, + }, + }, + expectedSet: []Set{ + { + Name: "set-1", + Default: true, + CRD: []CRDPlugin{&Base{}}, + Mapping: []MappingPlugin{&Entry{}, &Status{}}, + Property: []PropertyPlugin{}, + Extension: []ExtensionPlugin{}, + }, + { + Name: "set-2", + Default: false, + CRD: []CRDPlugin{&Base{}}, + Mapping: []MappingPlugin{&Entry{}, &Status{}, &References{}}, + Property: []PropertyPlugin{&ReadOnlyProperties{}, &ReadWriteProperties{}}, + Extension: []ExtensionPlugin{}, + }, + }, + expectedErr: nil, + }, + "build sets in random order": { + setsDefinition: []configv1alpha1.PluginSet{ + { + Name: "set-2", + Default: false, + InheritFrom: "set-1", + Plugins: []string{"references", "read_only_properties", "read_write_properties"}, + }, + { + Name: "set-1", + Default: true, + Plugins: []string{"base", "entry", "status", "references_metadata"}, + }, + }, + expectedSet: []Set{ + { + Name: "set-1", + Default: true, + CRD: []CRDPlugin{&Base{}}, + Mapping: []MappingPlugin{&Entry{}, &Status{}}, + Property: []PropertyPlugin{}, + Extension: []ExtensionPlugin{&ReferencesMetadata{}}, + }, + { + Name: "set-2", + Default: false, + CRD: []CRDPlugin{&Base{}}, + Mapping: []MappingPlugin{&Entry{}, &Status{}, &References{}}, + Property: []PropertyPlugin{&ReadOnlyProperties{}, &ReadWriteProperties{}}, + Extension: []ExtensionPlugin{&ReferencesMetadata{}}, + }, + }, + expectedErr: nil, + }, + "error on unknown plugin": { + setsDefinition: []configv1alpha1.PluginSet{ + { + Name: "set-1", + Default: true, + Plugins: []string{"base", "entry", "unknown_plugin"}, + }, + }, + expectedSet: nil, + expectedErr: errors.New("plugin unknown_plugin not found in catalog"), + }, + "error on unknown inherited set": { + setsDefinition: []configv1alpha1.PluginSet{ + { + Name: "set-2", + Default: false, + InheritFrom: "set-1", + Plugins: []string{"references", "read_only_properties", "read_write_properties"}, + }, + }, + expectedSet: nil, + expectedErr: errors.New("circular dependency detected for plugin set: set-2"), + }, + "error on multiple default sets": { + setsDefinition: []configv1alpha1.PluginSet{ + { + Name: "set-1", + Default: true, + Plugins: []string{"base", "entry", "status"}, + }, + { + Name: "set-2", + Default: true, + Plugins: []string{"references", "read_only_properties", "read_write_properties"}, + }, + }, + expectedSet: nil, + expectedErr: errors.New("multiple default plugin sets defined"), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + c := NewCatalog() + set, err := c.BuildSets(tt.setsDefinition) + assert.Equal(t, tt.expectedErr, err) + assert.Equal(t, tt.expectedSet, set) + }) + } +} + +func TestGetPluginSet(t *testing.T) { + tests := map[string]struct { + sets []Set + name string + expectedSet *Set + expectedError error + }{ + "get existing set": { + sets: []Set{ + { + Name: "set-1", + Default: true, + CRD: []CRDPlugin{&Base{}}, + Mapping: []MappingPlugin{&Entry{}, &Status{}}, + Property: []PropertyPlugin{}, + Extension: []ExtensionPlugin{}, + }, + { + Name: "set-2", + Default: false, + CRD: []CRDPlugin{&Base{}}, + Mapping: []MappingPlugin{&Entry{}, &Status{}, &References{}}, + Property: []PropertyPlugin{&ReadOnlyProperties{}, &ReadWriteProperties{}}, + Extension: []ExtensionPlugin{}, + }, + }, + name: "set-2", + expectedSet: &Set{ + Name: "set-2", + Default: false, + CRD: []CRDPlugin{&Base{}}, + Mapping: []MappingPlugin{&Entry{}, &Status{}, &References{}}, + Property: []PropertyPlugin{&ReadOnlyProperties{}, &ReadWriteProperties{}}, + Extension: []ExtensionPlugin{}, + }, + expectedError: nil, + }, + "get default set": { + sets: []Set{ + { + Name: "set-1", + Default: true, + CRD: []CRDPlugin{&Base{}}, + Mapping: []MappingPlugin{&Entry{}, &Status{}}, + Property: []PropertyPlugin{}, + Extension: []ExtensionPlugin{}, + }, + { + Name: "set-2", + Default: false, + CRD: []CRDPlugin{&Base{}}, + Mapping: []MappingPlugin{&Entry{}, &Status{}, &References{}}, + Property: []PropertyPlugin{&ReadOnlyProperties{}, &ReadWriteProperties{}}, + Extension: []ExtensionPlugin{}, + }, + }, + name: "", + expectedSet: &Set{ + Name: "set-1", + Default: true, + CRD: []CRDPlugin{&Base{}}, + Mapping: []MappingPlugin{&Entry{}, &Status{}}, + Property: []PropertyPlugin{}, + Extension: []ExtensionPlugin{}, + }, + expectedError: nil, + }, + "error on unknown set": { + sets: []Set{ + { + Name: "set-1", + Default: true, + CRD: []CRDPlugin{&Base{}}, + Mapping: []MappingPlugin{&Entry{}, &Status{}}, + Property: []PropertyPlugin{}, + Extension: []ExtensionPlugin{}, + }, + { + Name: "set-2", + Default: false, + CRD: []CRDPlugin{&Base{}}, + Mapping: []MappingPlugin{&Entry{}, &Status{}, &References{}}, + Property: []PropertyPlugin{&ReadOnlyProperties{}, &ReadWriteProperties{}}, + Extension: []ExtensionPlugin{}, + }, + }, + name: "set-3", + expectedSet: nil, + expectedError: errors.New("pluginSet set-3 not found"), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + set, err := GetPluginSet(tt.sets, tt.name) + assert.Equal(t, tt.expectedError, err) + assert.Equal(t, tt.expectedSet, set) + }) + } +} diff --git a/tools/openapi2crd/pkg/plugins/entry.go b/tools/openapi2crd/pkg/plugins/entry.go index ae9713df7d..9d4aceb620 100644 --- a/tools/openapi2crd/pkg/plugins/entry.go +++ b/tools/openapi2crd/pkg/plugins/entry.go @@ -20,41 +20,35 @@ import ( "strings" "github.com/getkin/kin-openapi/openapi3" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" - configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" -) -type EntryPlugin struct { - NoOp - crd *apiextensions.CustomResourceDefinition -} + "tools/openapi2crd/pkg/converter" +) -var _ Plugin = &EntryPlugin{} +// Entry is a plugin that processes the entry mapping configuration and adds the entry schema to the CRD's spec validation schema. +// It requires the base and major_version plugin to be run first. +type Entry struct{} -func NewEntryPlugin(crd *apiextensions.CustomResourceDefinition) *EntryPlugin { - return &EntryPlugin{ - crd: crd, - } -} - -func (s *EntryPlugin) Name() string { +func (p *Entry) Name() string { return "entry" } -func (s *EntryPlugin) ProcessMapping(g Generator, mappingConfig *configv1alpha1.CRDMapping, openApiSpec *openapi3.T, extensionsSchema *openapi3.Schema) error { +func (p *Entry) Process(req *MappingProcessorRequest) error { var entrySchema *openapi3.SchemaRef switch { - case mappingConfig.EntryMapping.Schema != "": + case req.MappingConfig.EntryMapping.Schema != "": var ok bool - entrySchema, ok = openApiSpec.Components.Schemas[mappingConfig.EntryMapping.Schema] + entrySchema, ok = req.OpenAPISpec.Components.Schemas[req.MappingConfig.EntryMapping.Schema] if !ok { - return fmt.Errorf("entry schema %q not found in openapi spec", mappingConfig.EntryMapping.Schema) + return fmt.Errorf("entry schema %q not found in openapi spec", req.MappingConfig.EntryMapping.Schema) } - case mappingConfig.EntryMapping.Path.Name != "": - entrySchema = openApiSpec.Paths.Find(mappingConfig.EntryMapping.Path.Name).Operations()[strings.ToUpper(mappingConfig.EntryMapping.Path.Verb)].RequestBody.Value.Content[mappingConfig.EntryMapping.Path.RequestBody.MimeType].Schema + case req.MappingConfig.EntryMapping.Path.Name != "": + entrySchema = req.OpenAPISpec.Paths. + Find(req.MappingConfig.EntryMapping.Path.Name). + Operations()[strings.ToUpper(req.MappingConfig.EntryMapping.Path.Verb)]. + RequestBody.Value.Content[req.MappingConfig.EntryMapping.Path.RequestBody.MimeType].Schema } - extensionsSchema.Properties["spec"].Value.Properties[mappingConfig.MajorVersion] = &openapi3.SchemaRef{ + req.ExtensionsSchema.Properties["spec"].Value.Properties[req.MappingConfig.MajorVersion] = &openapi3.SchemaRef{ Value: &openapi3.Schema{ Properties: map[string]*openapi3.SchemaRef{ "entry": {Value: &openapi3.Schema{}}, @@ -63,10 +57,22 @@ func (s *EntryPlugin) ProcessMapping(g Generator, mappingConfig *configv1alpha1. } if entrySchema != nil { - entryProps := g.ConvertProperty(entrySchema, extensionsSchema.Properties["spec"].Value.Properties[mappingConfig.MajorVersion].Value.Properties["entry"], &mappingConfig.EntryMapping, 0) + entryProps := req.Converter.Convert( + converter.PropertyConvertInput{ + Schema: entrySchema, + ExtensionsSchemaRef: req.ExtensionsSchema.Properties["spec"].Value.Properties[req.MappingConfig.MajorVersion].Value.Properties["entry"], + PropertyConfig: &req.MappingConfig.EntryMapping, + Depth: 0, + Path: nil, + }, + ) - entryProps.Description = fmt.Sprintf("The entry fields of the %v resource spec. These fields can be set for creating and updating %v.", s.crd.Spec.Names.Singular, s.crd.Spec.Names.Plural) - s.crd.Spec.Validation.OpenAPIV3Schema.Properties["spec"].Properties[mappingConfig.MajorVersion].Properties["entry"] = *entryProps + entryProps.Description = fmt.Sprintf( + "The entry fields of the %v resource spec. These fields can be set for creating and updating %v.", + req.CRD.Spec.Names.Singular, + req.CRD.Spec.Names.Plural, + ) + req.CRD.Spec.Validation.OpenAPIV3Schema.Properties["spec"].Properties[req.MappingConfig.MajorVersion].Properties["entry"] = *entryProps } return nil diff --git a/tools/openapi2crd/pkg/plugins/entry_test.go b/tools/openapi2crd/pkg/plugins/entry_test.go new file mode 100644 index 0000000000..974db7f4d9 --- /dev/null +++ b/tools/openapi2crd/pkg/plugins/entry_test.go @@ -0,0 +1,252 @@ +// Copyright 2025 MongoDB Inc +// +// 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 plugins + +import ( + "fmt" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + + configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" + "tools/openapi2crd/pkg/converter" +) + +func TestEntryName(t *testing.T) { + p := &Entry{} + assert.Equal(t, "entry", p.Name()) +} + +func TestEntryProcess(t *testing.T) { + tests := map[string]struct { + request *MappingProcessorRequest + expectedEntry apiextensions.JSONSchemaProps + expectedErr error + }{ + "add entry schema to the CRD mapped to schema": { + request: groupMappingRequest(t, groupBaseCRDWithMajorVersion(t), entryInitialExtensionsSchema(t), entryConverterMock(t)), + expectedEntry: apiextensions.JSONSchemaProps{ + Type: "object", + Description: "The entry fields of the group resource spec. These fields can be set for creating and updating groups.", + Properties: map[string]apiextensions.JSONSchemaProps{ + "name": { + Type: "string", + Description: "Human-readable label that identifies this group.", + }, + "orgId": { + Type: "string", + Description: "Unique 24-hexadecimal digit string that identifies the organization to which this group belongs.", + }, + "teamIds": { + Type: "array", + Description: "List of unique 24-hexadecimal digit strings that identify the teams to which this group belongs.", + Items: &apiextensions.JSONSchemaPropsOrArray{ + Schema: &apiextensions.JSONSchemaProps{ + Type: "string", + }, + }, + }, + "labels": { + Type: "array", + Description: "List of key-value pairs that can be attached to a group.", + Items: &apiextensions.JSONSchemaPropsOrArray{ + Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "key": { + Type: "string", + Description: "Label key.", + }, + "value": { + Type: "string", + Description: "Label value.", + }, + }, + }, + }, + }, + }, + }, + }, + "add entry schema to the CRD mapped to path": { + request: groupMappingRequestWithPath(t, groupBaseCRDWithMajorVersion(t), entryInitialExtensionsSchema(t), entryConverterMock(t)), + expectedEntry: apiextensions.JSONSchemaProps{ + Type: "object", + Description: "The entry fields of the group resource spec. These fields can be set for creating and updating groups.", + Properties: map[string]apiextensions.JSONSchemaProps{ + "name": { + Type: "string", + Description: "Human-readable label that identifies this group.", + }, + "orgId": { + Type: "string", + Description: "Unique 24-hexadecimal digit string that identifies the organization to which this group belongs.", + }, + "teamIds": { + Type: "array", + Description: "List of unique 24-hexadecimal digit strings that identify the teams to which this group belongs.", + Items: &apiextensions.JSONSchemaPropsOrArray{ + Schema: &apiextensions.JSONSchemaProps{ + Type: "string", + }, + }, + }, + "labels": { + Type: "array", + Description: "List of key-value pairs that can be attached to a group.", + Items: &apiextensions.JSONSchemaPropsOrArray{ + Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "key": { + Type: "string", + Description: "Label key.", + }, + "value": { + Type: "string", + Description: "Label value.", + }, + }, + }, + }, + }, + }, + }, + }, + "error when schema not found": { + request: groupMappingWithNonExistingSchema(t, groupBaseCRDWithMajorVersion(t), entryInitialExtensionsSchema(t), entryConverterMock(t)), + expectedErr: fmt.Errorf("entry schema %q not found in openapi spec", "NonExistentSchema"), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + p := &Entry{} + err := p.Process(tt.request) + assert.Equal(t, tt.expectedErr, err) + entry := tt.request.CRD.Spec.Validation.OpenAPIV3Schema.Properties["spec"].Properties[tt.request.MappingConfig.MajorVersion].Properties["entry"] + assert.Equal(t, tt.expectedEntry, entry) + }) + } +} + +func groupMappingRequestWithPath( + t *testing.T, + crd *apiextensions.CustomResourceDefinition, + extensionsSchema *openapi3.Schema, + converterFunc converterFuncMock, +) *MappingProcessorRequest { + req := groupMappingRequest(t, crd, extensionsSchema, converterFunc) + req.MappingConfig.EntryMapping.Path = configv1alpha1.PropertyPath{ + Name: "/api/atlas/v2/groups", + Verb: "post", + RequestBody: configv1alpha1.RequestBody{ + MimeType: "application/vnd.atlas.2025-03-12+json", + }, + } + req.MappingConfig.EntryMapping.Schema = "" + + return req +} + +func groupMappingWithNonExistingSchema( + t *testing.T, + crd *apiextensions.CustomResourceDefinition, + extensionsSchema *openapi3.Schema, + converterFunc converterFuncMock, +) *MappingProcessorRequest { + req := groupMappingRequest(t, crd, extensionsSchema, converterFunc) + req.MappingConfig.EntryMapping.Schema = "NonExistentSchema" + + return req +} + +func groupBaseCRDWithMajorVersion(t *testing.T) *apiextensions.CustomResourceDefinition { + crd := groupBaseCRD(t) + crd.Spec.Validation.OpenAPIV3Schema.Properties["spec"].Properties["v20250312"] = apiextensions.JSONSchemaProps{ + Type: "object", + Description: "The spec of the group resource for version v20250312.", + Properties: map[string]apiextensions.JSONSchemaProps{}, + } + + return crd +} + +func entryInitialExtensionsSchema(t *testing.T) *openapi3.Schema { + t.Helper() + + return &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "spec": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{}, + }, + }, + }, + } +} + +func entryConverterMock(t *testing.T) converterFuncMock { + t.Helper() + + return func(input converter.PropertyConvertInput) *apiextensions.JSONSchemaProps { + return &apiextensions.JSONSchemaProps{ + Type: "object", + Description: "The entry fields of the group resource spec. These fields can be set for creating and updating groups.", + Properties: map[string]apiextensions.JSONSchemaProps{ + "name": { + Type: "string", + Description: "Human-readable label that identifies this group.", + }, + "orgId": { + Type: "string", + Description: "Unique 24-hexadecimal digit string that identifies the organization to which this group belongs.", + }, + "teamIds": { + Type: "array", + Description: "List of unique 24-hexadecimal digit strings that identify the teams to which this group belongs.", + Items: &apiextensions.JSONSchemaPropsOrArray{ + Schema: &apiextensions.JSONSchemaProps{ + Type: "string", + }, + }, + }, + "labels": { + Type: "array", + Description: "List of key-value pairs that can be attached to a group.", + Items: &apiextensions.JSONSchemaPropsOrArray{ + Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "key": { + Type: "string", + Description: "Label key.", + }, + "value": { + Type: "string", + Description: "Label value.", + }, + }, + }, + }, + }, + }, + } + } +} diff --git a/tools/openapi2crd/pkg/plugins/major_version_spec.go b/tools/openapi2crd/pkg/plugins/major_version.go similarity index 52% rename from tools/openapi2crd/pkg/plugins/major_version_spec.go rename to tools/openapi2crd/pkg/plugins/major_version.go index 54ad7dff0a..d5921d6806 100644 --- a/tools/openapi2crd/pkg/plugins/major_version_spec.go +++ b/tools/openapi2crd/pkg/plugins/major_version.go @@ -18,33 +18,23 @@ package plugins import ( "fmt" - "github.com/getkin/kin-openapi/openapi3" "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" - configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" ) -type MajorVersionSpecPlugin struct { - NoOp - crd *apiextensions.CustomResourceDefinition -} - -var _ Plugin = &MajorVersionSpecPlugin{} - -func NewMajorVersionPlugin(crd *apiextensions.CustomResourceDefinition) *MajorVersionSpecPlugin { - return &MajorVersionSpecPlugin{ - crd: crd, - } -} +// MajorVersion is a plugin that adds the major version schema to the CRD. +// It requires the base plugin to be run first. +type MajorVersion struct{} -func (s *MajorVersionSpecPlugin) Name() string { +func (s *MajorVersion) Name() string { return "major_version" } -func (s *MajorVersionSpecPlugin) ProcessMapping(g Generator, mappingConfig *configv1alpha1.CRDMapping, openApiSpec *openapi3.T, extensionsSchema *openapi3.Schema) error { - s.crd.Spec.Validation.OpenAPIV3Schema.Properties["spec"].Properties[mappingConfig.MajorVersion] = apiextensions.JSONSchemaProps{ +func (s *MajorVersion) Process(req *MappingProcessorRequest) error { + req.CRD.Spec.Validation.OpenAPIV3Schema.Properties["spec"].Properties[req.MappingConfig.MajorVersion] = apiextensions.JSONSchemaProps{ Type: "object", - Description: fmt.Sprintf("The spec of the %v resource for version %v.", s.crd.Spec.Names.Singular, mappingConfig.MajorVersion), + Description: fmt.Sprintf("The spec of the %v resource for version %v.", req.CRD.Spec.Names.Singular, req.MappingConfig.MajorVersion), Properties: map[string]apiextensions.JSONSchemaProps{}, } + return nil } diff --git a/tools/openapi2crd/pkg/plugins/major_version_test.go b/tools/openapi2crd/pkg/plugins/major_version_test.go new file mode 100644 index 0000000000..c835022767 --- /dev/null +++ b/tools/openapi2crd/pkg/plugins/major_version_test.go @@ -0,0 +1,86 @@ +// Copyright 2025 MongoDB Inc +// +// 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 plugins + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" +) + +func TestMajorVersionName(t *testing.T) { + p := &MajorVersion{} + assert.Equal(t, "major_version", p.Name()) +} + +func TestMajorVersionProcess(t *testing.T) { + tests := map[string]struct { + request *MappingProcessorRequest + expectedVersionSpec apiextensions.JSONSchemaProps + expectedErr error + }{ + "add major version schema to the CRD": { + request: groupMappingRequest(t, groupBaseCRD(t), majorVersionInitialExtensionsSchema(t), nil), + expectedVersionSpec: majorVersionSchema(t), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + p := &MajorVersion{} + err := p.Process(tt.request) + assert.Equal(t, tt.expectedErr, err) + versionSpec := tt.request.CRD.Spec.Validation.OpenAPIV3Schema.Properties["spec"].Properties[tt.request.MappingConfig.MajorVersion] + assert.Equal(t, tt.expectedVersionSpec, versionSpec) + }) + } +} + +func majorVersionSchema(t *testing.T) apiextensions.JSONSchemaProps { + t.Helper() + + return apiextensions.JSONSchemaProps{ + Type: "object", + Description: "The spec of the group resource for version v20250312.", + Properties: map[string]apiextensions.JSONSchemaProps{}, + } +} + +func majorVersionInitialExtensionsSchema(t *testing.T) *openapi3.Schema { + t.Helper() + + return &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "spec": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "v20250312": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "entry": {Value: &openapi3.Schema{}}, + }, + }, + }, + }, + }, + }, + }, + } +} diff --git a/tools/openapi2crd/pkg/plugins/mutal_exclusive_major_versions.go b/tools/openapi2crd/pkg/plugins/mutual_exclusive_major_versions.go similarity index 54% rename from tools/openapi2crd/pkg/plugins/mutal_exclusive_major_versions.go rename to tools/openapi2crd/pkg/plugins/mutual_exclusive_major_versions.go index 589acdb29c..f85bdc1d74 100644 --- a/tools/openapi2crd/pkg/plugins/mutal_exclusive_major_versions.go +++ b/tools/openapi2crd/pkg/plugins/mutual_exclusive_major_versions.go @@ -20,53 +20,42 @@ import ( "strings" "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" - configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" ) -// (has(self.externalProjectRef) && !has(self.projectRef)) || (!has(self.externalProjectRef) && has(self.projectRef)) -type MutualExclusiveMajorVersions struct { - NoOp - crd *apiextensions.CustomResourceDefinition -} - -var _ Plugin = &MutualExclusiveMajorVersions{} - -func NewMutualExclusiveMajorVersions(crd *apiextensions.CustomResourceDefinition) *MutualExclusiveMajorVersions { - return &MutualExclusiveMajorVersions{ - crd: crd, - } -} +// MutualExclusiveMajorVersions is a plugin that adds a CEL validation to the CRD to ensure that only one of the major +// versions is set in the spec. It requires base plugin to be run first. +type MutualExclusiveMajorVersions struct{} -func (s *MutualExclusiveMajorVersions) Name() string { +func (p *MutualExclusiveMajorVersions) Name() string { return "mutual_exclusive_major_versions" } -func (m *MutualExclusiveMajorVersions) ProcessCRD(g Generator, crdConfig *configv1alpha1.CRDConfig) error { - if len(crdConfig.Mappings) <= 1 { +func (p *MutualExclusiveMajorVersions) Process(req *CRDProcessorRequest) error { + if len(req.CRDConfig.Mappings) <= 1 { return nil } - majorVersions := make([]string, 0, len(crdConfig.Mappings)) - for _, mapping := range crdConfig.Mappings { - majorVersions = append(majorVersions, mapping.MajorVersion) + versions := make([]string, 0, len(req.CRDConfig.Mappings)) + for _, mapping := range req.CRDConfig.Mappings { + versions = append(versions, mapping.MajorVersion) } - cel := mutualExclusiveCEL(majorVersions) - specProps := m.crd.Spec.Validation.OpenAPIV3Schema.Properties["spec"] + cel := mutualExclusiveCEL(versions) + specProps := req.CRD.Spec.Validation.OpenAPIV3Schema.Properties["spec"] specProps.XValidations = apiextensions.ValidationRules{ { Rule: cel, - Message: fmt.Sprintf(`Only one of the following entries can be set: %q`, strings.Join(majorVersions, ", ")), + Message: fmt.Sprintf(`Only one of the following entries can be set: %q`, strings.Join(versions, ", ")), }, } - m.crd.Spec.Validation.OpenAPIV3Schema.Properties["spec"] = specProps + req.CRD.Spec.Validation.OpenAPIV3Schema.Properties["spec"] = specProps return nil } func mutualExclusiveCEL(fields []string) string { clauses := make([]string, 0, len(fields)) - for i, _ := range fields { + for i := range fields { parts := make([]string, len(fields)) for j, name := range fields { if i == j { diff --git a/tools/openapi2crd/pkg/plugins/mutual_exclusive_major_versions_test.go b/tools/openapi2crd/pkg/plugins/mutual_exclusive_major_versions_test.go new file mode 100644 index 0000000000..0a84e7c533 --- /dev/null +++ b/tools/openapi2crd/pkg/plugins/mutual_exclusive_major_versions_test.go @@ -0,0 +1,103 @@ +// Copyright 2025 MongoDB Inc +// +// 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 plugins + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/stretchr/testify/assert" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + + configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" +) + +func TestMutualExclusiveMajorVersionsName(t *testing.T) { + p := &MutualExclusiveMajorVersions{} + assert.Equal(t, "mutual_exclusive_major_versions", p.Name()) +} + +func TestMutualExclusiveMajorVersionsProcess(t *testing.T) { + tests := map[string]struct { + request *CRDProcessorRequest + expectedValidation apiextensions.ValidationRules + expectedErr error + }{ + "add mutual exclusive major versions validation": { + request: groupMultipleVersionsCRDConfig(t, groupBaseCRD(t)), + expectedValidation: mutualExclusiveMajorVersionsValidation(t), + }, + "add no validation when there's only one version": { + request: groupCRDRequest(t, groupBaseCRD(t)), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + p := &MutualExclusiveMajorVersions{} + err := p.Process(tt.request) + assert.Equal(t, tt.expectedErr, err) + assert.Equal(t, tt.expectedValidation, tt.request.CRD.Spec.Validation.OpenAPIV3Schema.Properties["spec"].XValidations) + t.Log(cmp.Diff(tt.expectedValidation, tt.request.CRD.Spec.Validation.OpenAPIV3Schema.Properties["spec"].XValidations)) + }) + } +} + +func groupMultipleVersionsCRDConfig(t *testing.T, crd *apiextensions.CustomResourceDefinition) *CRDProcessorRequest { + t.Helper() + + config := groupCRDRequest(t, crd) + config.CRDConfig.Mappings = append( + config.CRDConfig.Mappings, + configv1alpha1.CRDMapping{ + MajorVersion: "v20250219", + OpenAPIRef: configv1alpha1.LocalObjectReference{ + Name: "v20250219", + }, + ParametersMapping: configv1alpha1.PropertyMapping{ + Path: configv1alpha1.PropertyPath{ + Name: "/api/atlas/v2/groups", + Verb: "post", + }, + }, + EntryMapping: configv1alpha1.PropertyMapping{ + Schema: "Group", + Filters: configv1alpha1.Filters{ + ReadWriteOnly: true, + }, + }, + StatusMapping: configv1alpha1.PropertyMapping{ + Schema: "Group", + Filters: configv1alpha1.Filters{ + ReadOnly: true, + SkipProperties: []string{"$.links"}, + }, + }, + }, + ) + + return config +} + +func mutualExclusiveMajorVersionsValidation(t *testing.T) apiextensions.ValidationRules { + t.Helper() + + return apiextensions.ValidationRules{ + { + Rule: "!has(self.v20250312) && has(self.v20250219) || has(self.v20250312) && !has(self.v20250219)", + Message: `Only one of the following entries can be set: "v20250312, v20250219"`, + }, + } +} diff --git a/tools/openapi2crd/pkg/plugins/noop.go b/tools/openapi2crd/pkg/plugins/noop.go deleted file mode 100644 index 815d55616a..0000000000 --- a/tools/openapi2crd/pkg/plugins/noop.go +++ /dev/null @@ -1,40 +0,0 @@ -// Copyright 2025 MongoDB Inc -// -// 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 plugins - -import ( - "github.com/getkin/kin-openapi/openapi3" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" - configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" -) - -// NoOp is a struct that implements the Plugin interface that does nothing. -// It can be embedded in plugins that do not need to implement all methods of Plugin interface. -type NoOp struct { - Plugin -} - -func (n *NoOp) ProcessMapping(g Generator, mappingConfig *configv1alpha1.CRDMapping, openApiSpec *openapi3.T, extensionsSchema *openapi3.Schema) error { - return nil -} - -func (n *NoOp) ProcessProperty(g Generator, propertyConfig *configv1alpha1.PropertyMapping, props *apiextensions.JSONSchemaProps, propertySchema *openapi3.Schema, extensionsSchema *openapi3.SchemaRef, path ...string) *apiextensions.JSONSchemaProps { - return props -} - -func (n *NoOp) ProcessCRD(g Generator, crdConfig *configv1alpha1.CRDConfig) error { - return nil -} diff --git a/tools/openapi2crd/pkg/plugins/parameters.go b/tools/openapi2crd/pkg/plugins/parameters.go index 41fac91ec4..583c1dd2a3 100644 --- a/tools/openapi2crd/pkg/plugins/parameters.go +++ b/tools/openapi2crd/pkg/plugins/parameters.go @@ -20,38 +20,30 @@ import ( "github.com/getkin/kin-openapi/openapi3" "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" - configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" -) -type ParametersPlugin struct { - NoOp - crd *apiextensions.CustomResourceDefinition -} + "tools/openapi2crd/pkg/converter" +) -var _ Plugin = &ParametersPlugin{} +// Parameters adds parameters from the OpenAPI spec to the CRD schema. +// It requires base and major version plugins to be run before. +type Parameters struct{} -func NewParametersPlugin(crd *apiextensions.CustomResourceDefinition) *ParametersPlugin { - return &ParametersPlugin{ - crd: crd, - } -} - -func (s *ParametersPlugin) Name() string { +func (p *Parameters) Name() string { return "parameters" } -func (s *ParametersPlugin) ProcessMapping(g Generator, mappingConfig *configv1alpha1.CRDMapping, openApiSpec *openapi3.T, extensionsSchema *openapi3.Schema) error { - majorVersionSpec := s.crd.Spec.Validation.OpenAPIV3Schema.Properties["spec"].Properties[mappingConfig.MajorVersion] +func (p *Parameters) Process(req *MappingProcessorRequest) error { + majorVersionSpec := req.CRD.Spec.Validation.OpenAPIV3Schema.Properties["spec"].Properties[req.MappingConfig.MajorVersion] - if mappingConfig.ParametersMapping.Path.Name != "" { + if req.MappingConfig.ParametersMapping.Path.Name != "" { var operation *openapi3.Operation - pathItem := openApiSpec.Paths.Find(mappingConfig.ParametersMapping.Path.Name) + pathItem := req.OpenAPISpec.Paths.Find(req.MappingConfig.ParametersMapping.Path.Name) if pathItem == nil { - return fmt.Errorf("OpenAPI path %q does not exist", mappingConfig.ParametersMapping) + return fmt.Errorf("OpenAPI path %v does not exist", req.MappingConfig.ParametersMapping) } - switch mappingConfig.ParametersMapping.Path.Verb { + switch req.MappingConfig.ParametersMapping.Path.Verb { case "post": operation = pathItem.Post case "put": @@ -59,32 +51,40 @@ func (s *ParametersPlugin) ProcessMapping(g Generator, mappingConfig *configv1al case "patch": operation = pathItem.Patch default: - return fmt.Errorf("verb %q unsupported", mappingConfig.ParametersMapping.Path.Verb) + return fmt.Errorf("verb %q unsupported", req.MappingConfig.ParametersMapping.Path.Verb) } - for _, p := range operation.Parameters { - switch p.Value.Name { + for _, param := range operation.Parameters { + switch param.Value.Name { case "includeCount": case "itemsPerPage": case "pageNum": case "envelope": case "pretty": default: - props := g.ConvertProperty(p.Value.Schema, openapi3.NewSchemaRef("", openapi3.NewSchema()), &mappingConfig.ParametersMapping, 0, "$", p.Value.Name) - props.Description = p.Value.Description + props := req.Converter.Convert( + converter.PropertyConvertInput{ + Schema: param.Value.Schema, + ExtensionsSchemaRef: openapi3.NewSchemaRef("", openapi3.NewSchema()), + PropertyConfig: &req.MappingConfig.ParametersMapping, + Depth: 0, + Path: []string{"$", param.Value.Name}, + }, + ) + props.Description = param.Value.Description props.XValidations = apiextensions.ValidationRules{ { Rule: "self == oldSelf", - Message: fmt.Sprintf("%s cannot be modified after creation", p.Value.Name), + Message: fmt.Sprintf("%s cannot be modified after creation", param.Value.Name), }, } - majorVersionSpec.Properties[p.Value.Name] = *props - majorVersionSpec.Required = append(majorVersionSpec.Required, p.Value.Name) + majorVersionSpec.Properties[param.Value.Name] = *props + majorVersionSpec.Required = append(majorVersionSpec.Required, param.Value.Name) } } } - s.crd.Spec.Validation.OpenAPIV3Schema.Properties["spec"].Properties[mappingConfig.MajorVersion] = majorVersionSpec + req.CRD.Spec.Validation.OpenAPIV3Schema.Properties["spec"].Properties[req.MappingConfig.MajorVersion] = majorVersionSpec return nil } diff --git a/tools/openapi2crd/pkg/plugins/parameters_test.go b/tools/openapi2crd/pkg/plugins/parameters_test.go new file mode 100644 index 0000000000..455df16cfa --- /dev/null +++ b/tools/openapi2crd/pkg/plugins/parameters_test.go @@ -0,0 +1,376 @@ +// Copyright 2025 MongoDB Inc +// +// 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 plugins + +import ( + "errors" + "fmt" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + "k8s.io/utils/ptr" + + configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" + "tools/openapi2crd/pkg/converter" +) + +func TestParameterName(t *testing.T) { + p := &Parameters{} + assert.Equal(t, "parameters", p.Name()) +} + +func TestParameterProcess(t *testing.T) { + tests := map[string]struct { + request *MappingProcessorRequest + expectedVersionSpec apiextensions.JSONSchemaProps + expectedErr error + }{ + "add parameter schema to the CRD": { + request: groupMappingRequest(t, groupBaseCRDWithMajorVersion(t), entryInitialExtensionsSchema(t), parameterConverterMock(t)), + expectedVersionSpec: apiextensions.JSONSchemaProps{ + Description: "The spec of the group resource for version v20250312.", + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "projectOwnerId": { + Description: "Unique 24-hexadecimal digit string that identifies the MongoDB Cloud user to whom to grant the Project Owner role on the specified project. If you set this parameter, it overrides the default value of the oldest Organization Owner.", + Type: "object", + XValidations: apiextensions.ValidationRules{ + { + Rule: "self == oldSelf", + Message: "projectOwnerId cannot be modified after creation", + }, + }, + }, + }, + Required: []string{"projectOwnerId"}, + }, + }, + "missing path in OpenAPI spec": { + request: &MappingProcessorRequest{ + CRD: groupBaseCRDWithMajorVersion(t), + MappingConfig: &configv1alpha1.CRDMapping{ + MajorVersion: "v20250312", + ParametersMapping: configv1alpha1.PropertyMapping{ + Path: configv1alpha1.PropertyPath{ + Name: "/api/atlas/v2/nonexistent", + Verb: "post", + }, + }, + }, + OpenAPISpec: &openapi3.T{ + Paths: openapi3.NewPaths(), + }, + }, + expectedErr: fmt.Errorf("OpenAPI path %v does not exist", configv1alpha1.PropertyMapping{ + Path: configv1alpha1.PropertyPath{ + Name: "/api/atlas/v2/nonexistent", + Verb: "post", + }, + }), + expectedVersionSpec: apiextensions.JSONSchemaProps{ + Description: "The spec of the group resource for version v20250312.", + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + }, + "unsupported operation": { + request: &MappingProcessorRequest{ + CRD: groupBaseCRDWithMajorVersion(t), + MappingConfig: &configv1alpha1.CRDMapping{ + MajorVersion: "v20250312", + ParametersMapping: configv1alpha1.PropertyMapping{ + Path: configv1alpha1.PropertyPath{ + Name: "/api/atlas/v2/groups", + Verb: "delete", + }, + }, + }, + OpenAPISpec: &openapi3.T{ + Paths: openapi3.NewPaths( + openapi3.WithPath( + "/api/atlas/v2/groups", + &openapi3.PathItem{}, + ), + ), + }, + }, + expectedErr: errors.New("verb \"delete\" unsupported"), + expectedVersionSpec: apiextensions.JSONSchemaProps{ + Description: "The spec of the group resource for version v20250312.", + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + }, + "skipped parameters are not added to the CRD": { + request: groupMappingRequestWithSkippedParameters(t, groupBaseCRDWithMajorVersion(t), entryInitialExtensionsSchema(t), parameterConverterMock(t)), + expectedVersionSpec: apiextensions.JSONSchemaProps{ + Description: "The spec of the group resource for version v20250312.", + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "projectOwnerId": { + Description: "Unique 24-hexadecimal digit string that identifies the MongoDB Cloud user to whom to grant the Project Owner role on the specified project. If you set this parameter, it overrides the default value of the oldest Organization Owner.", + Type: "object", + XValidations: apiextensions.ValidationRules{ + { + Rule: "self == oldSelf", + Message: "projectOwnerId cannot be modified after creation", + }, + }, + }, + }, + Required: []string{"projectOwnerId"}, + }, + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + p := &Parameters{} + err := p.Process(tt.request) + assert.Equal(t, tt.expectedErr, err) + versionSpec := tt.request.CRD.Spec.Validation.OpenAPIV3Schema.Properties["spec"].Properties[tt.request.MappingConfig.MajorVersion] + assert.Equal(t, tt.expectedVersionSpec, versionSpec) + }) + } +} + +func groupMappingRequest( + t *testing.T, + crd *apiextensions.CustomResourceDefinition, + extensionsSchema *openapi3.Schema, + converterFunc converterFuncMock, +) *MappingProcessorRequest { + t.Helper() + + return &MappingProcessorRequest{ + CRD: crd, + MappingConfig: &configv1alpha1.CRDMapping{ + MajorVersion: "v20250312", + OpenAPIRef: configv1alpha1.LocalObjectReference{ + Name: "v20250312", + }, + ParametersMapping: configv1alpha1.PropertyMapping{ + Path: configv1alpha1.PropertyPath{ + Name: "/api/atlas/v2/groups", + Verb: "post", + }, + }, + EntryMapping: configv1alpha1.PropertyMapping{ + Schema: "Group", + Filters: configv1alpha1.Filters{ + ReadWriteOnly: true, + }, + }, + StatusMapping: configv1alpha1.PropertyMapping{ + Schema: "Group", + Filters: configv1alpha1.Filters{ + ReadOnly: true, + SkipProperties: []string{"$.links"}, + }, + }, + }, + OpenAPISpec: &openapi3.T{ + Paths: openapi3.NewPaths( + openapi3.WithPath( + "/api/atlas/v2/groups", + &openapi3.PathItem{ + Post: &openapi3.Operation{ + Parameters: openapi3.Parameters{ + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "projectOwnerId", + In: "query", + Description: "Unique 24-hexadecimal digit string that identifies the MongoDB Cloud user to whom to grant the Project Owner role on the specified project. If you set this parameter, it overrides the default value of the oldest Organization Owner.", + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"boolean"}, + Default: false, + }, + }, + }, + }, + }, + RequestBody: &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Description: "Request body to create a new project.", + Content: openapi3.Content{ + "application/vnd.atlas.2025-03-12+json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: "#/components/schemas/Group", + }, + }, + }, + Required: true, + }, + }, + Responses: openapi3.NewResponses( + openapi3.WithStatus(200, &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: ptr.To("OK"), + Content: openapi3.Content{ + "application/vnd.atlas.2025-03-12+json": &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: "#/components/schemas/Group", + }, + }, + }, + }, + }), + ), + }, + }, + ), + ), + Components: &openapi3.Components{ + Schemas: map[string]*openapi3.SchemaRef{ + "Group": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "id": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Unique 24-hexadecimal digit string that identifies this project.", + }, + }, + "name": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Description: "Human-readable label that identifies the project.", + }, + }, + }, + }, + }, + }, + }, + }, + ExtensionsSchema: extensionsSchema, + Converter: &dummyConverter{ + ConverterFunc: func(input converter.PropertyConvertInput) *apiextensions.JSONSchemaProps { + if converterFunc == nil { + return &apiextensions.JSONSchemaProps{} + } + + return converterFunc(input) + }, + }, + } +} + +func groupMappingRequestWithSkippedParameters( + t *testing.T, + crd *apiextensions.CustomResourceDefinition, + extensionsSchema *openapi3.Schema, + converterFunc converterFuncMock, +) *MappingProcessorRequest { + t.Helper() + + req := groupMappingRequest(t, crd, extensionsSchema, converterFunc) + req.OpenAPISpec.Paths.Find("/api/atlas/v2/groups").Post.Parameters = append( + req.OpenAPISpec.Paths.Find("/api/atlas/v2/groups").Post.Parameters, + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "includeCount", + In: "query", + Description: "A parameter that should be skipped.", + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"boolean"}, + }, + }, + }, + }, + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "itemsPerPage", + In: "query", + Description: "A parameter that should be skipped.", + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"boolean"}, + }, + }, + }, + }, + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "pageNum", + In: "query", + Description: "A parameter that should be skipped.", + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"boolean"}, + }, + }, + }, + }, + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "envelope", + In: "query", + Description: "A parameter that should be skipped.", + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"boolean"}, + }, + }, + }, + }, + &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: "pretty", + In: "query", + Description: "A parameter that should be skipped.", + Schema: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"boolean"}, + }, + }, + }, + }, + ) + + return req +} + +func parameterConverterMock(t *testing.T) converterFuncMock { + t.Helper() + + return func(input converter.PropertyConvertInput) *apiextensions.JSONSchemaProps { + return &apiextensions.JSONSchemaProps{ + Description: "Unique 24-hexadecimal digit string that identifies the MongoDB Cloud user to whom to grant the Project Owner role on the specified project. If you set this parameter, it overrides the default value of the oldest Organization Owner.", + Type: "object", + XValidations: apiextensions.ValidationRules{ + { + Rule: "self == oldSelf", + Message: "projectOwnerId cannot be modified after creation", + }, + }, + } + } +} + +type converterFuncMock func(input converter.PropertyConvertInput) *apiextensions.JSONSchemaProps + +type dummyConverter struct { + ConverterFunc func(input converter.PropertyConvertInput) *apiextensions.JSONSchemaProps +} + +func (d *dummyConverter) Convert(input converter.PropertyConvertInput) *apiextensions.JSONSchemaProps { + return d.ConverterFunc(input) +} diff --git a/tools/openapi2crd/pkg/plugins/plugin.go b/tools/openapi2crd/pkg/plugins/plugin.go index ecfd37dd12..1cbb34b3f9 100644 --- a/tools/openapi2crd/pkg/plugins/plugin.go +++ b/tools/openapi2crd/pkg/plugins/plugin.go @@ -18,16 +18,58 @@ package plugins import ( "github.com/getkin/kin-openapi/openapi3" "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" + "tools/openapi2crd/pkg/converter" ) -type Plugin interface { - Name() string - ProcessCRD(g Generator, crdConfig *configv1alpha1.CRDConfig) error - ProcessMapping(g Generator, mappingConfig *configv1alpha1.CRDMapping, openApiSpec *openapi3.T, extensionsSchema *openapi3.Schema) error - ProcessProperty(g Generator, propertyConfig *configv1alpha1.PropertyMapping, props *apiextensions.JSONSchemaProps, propertySchema *openapi3.Schema, extensionsSchema *openapi3.SchemaRef, path ...string) *apiextensions.JSONSchemaProps +type CRDProcessorRequest struct { + CRD *apiextensions.CustomResourceDefinition + CRDConfig *configv1alpha1.CRDConfig +} + +type MappingProcessorRequest struct { + CRD *apiextensions.CustomResourceDefinition + MappingConfig *configv1alpha1.CRDMapping + OpenAPISpec *openapi3.T + ExtensionsSchema *openapi3.Schema + Converter converter.Converter +} + +type PropertyProcessorRequest struct { + Property *apiextensions.JSONSchemaProps + PropertyConfig *configv1alpha1.PropertyMapping + OpenAPISchema *openapi3.Schema + ExtensionsSchema *openapi3.SchemaRef + Path []string } -type Generator interface { - ConvertProperty(schema, extensionsSchema *openapi3.SchemaRef, propertyConfig *configv1alpha1.PropertyMapping, depth int, path ...string) *apiextensions.JSONSchemaProps +type ExtensionProcessorRequest struct { + ExtensionsSchema *openapi3.Schema + ApiDefinitions map[string]configv1alpha1.OpenAPIDefinition + MappingConfig *configv1alpha1.CRDMapping } + +type Plugin[R any] interface { + Name() string + Process(request R) error +} + +type CRDPlugin = Plugin[*CRDProcessorRequest] +type MappingPlugin = Plugin[*MappingProcessorRequest] +type PropertyPlugin = Plugin[*PropertyProcessorRequest] +type ExtensionPlugin = Plugin[*ExtensionProcessorRequest] + +var _ CRDPlugin = &Base{} +var _ CRDPlugin = &MutualExclusiveMajorVersions{} +var _ MappingPlugin = &Entry{} +var _ MappingPlugin = &Status{} +var _ MappingPlugin = &Parameters{} +var _ MappingPlugin = &References{} +var _ MappingPlugin = &MajorVersion{} +var _ PropertyPlugin = &ReadOnlyProperties{} +var _ PropertyPlugin = &ReadWriteProperties{} +var _ PropertyPlugin = &SensitiveProperties{} +var _ PropertyPlugin = &SkippedProperties{} +var _ ExtensionPlugin = &AtlasSdkVersionPlugin{} +var _ ExtensionPlugin = &ReferencesMetadata{} diff --git a/tools/openapi2crd/pkg/plugins/plugin_mock.go b/tools/openapi2crd/pkg/plugins/plugin_mock.go new file mode 100644 index 0000000000..6db44e3ba5 --- /dev/null +++ b/tools/openapi2crd/pkg/plugins/plugin_mock.go @@ -0,0 +1,619 @@ +// Code generated by mockery; DO NOT EDIT. +// github.com/vektra/mockery +// template: testify + +package plugins + +import ( + mock "github.com/stretchr/testify/mock" +) + +// NewPluginMock creates a new instance of PluginMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewPluginMock[R any](t interface { + mock.TestingT + Cleanup(func()) +}) *PluginMock[R] { + mock := &PluginMock[R]{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// PluginMock is an autogenerated mock type for the Plugin type +type PluginMock[R any] struct { + mock.Mock +} + +type PluginMock_Expecter[R any] struct { + mock *mock.Mock +} + +func (_m *PluginMock[R]) EXPECT() *PluginMock_Expecter[R] { + return &PluginMock_Expecter[R]{mock: &_m.Mock} +} + +// Name provides a mock function for the type PluginMock +func (_mock *PluginMock[R]) Name() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Name") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// PluginMock_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name' +type PluginMock_Name_Call[R any] struct { + *mock.Call +} + +// Name is a helper method to define mock.On call +func (_e *PluginMock_Expecter[R]) Name() *PluginMock_Name_Call[R] { + return &PluginMock_Name_Call[R]{Call: _e.mock.On("Name")} +} + +func (_c *PluginMock_Name_Call[R]) Run(run func()) *PluginMock_Name_Call[R] { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *PluginMock_Name_Call[R]) Return(s string) *PluginMock_Name_Call[R] { + _c.Call.Return(s) + return _c +} + +func (_c *PluginMock_Name_Call[R]) RunAndReturn(run func() string) *PluginMock_Name_Call[R] { + _c.Call.Return(run) + return _c +} + +// Process provides a mock function for the type PluginMock +func (_mock *PluginMock[R]) Process(request R) error { + ret := _mock.Called(request) + + if len(ret) == 0 { + panic("no return value specified for Process") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(R) error); ok { + r0 = returnFunc(request) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// PluginMock_Process_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Process' +type PluginMock_Process_Call[R any] struct { + *mock.Call +} + +// Process is a helper method to define mock.On call +// - request R +func (_e *PluginMock_Expecter[R]) Process(request interface{}) *PluginMock_Process_Call[R] { + return &PluginMock_Process_Call[R]{Call: _e.mock.On("Process", request)} +} + +func (_c *PluginMock_Process_Call[R]) Run(run func(request R)) *PluginMock_Process_Call[R] { + _c.Call.Run(func(args mock.Arguments) { + var arg0 R + if args[0] != nil { + arg0 = args[0].(R) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *PluginMock_Process_Call[R]) Return(err error) *PluginMock_Process_Call[R] { + _c.Call.Return(err) + return _c +} + +func (_c *PluginMock_Process_Call[R]) RunAndReturn(run func(request R) error) *PluginMock_Process_Call[R] { + _c.Call.Return(run) + return _c +} + +// NewCRDPluginMock creates a new instance of CRDPluginMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewCRDPluginMock(t interface { + mock.TestingT + Cleanup(func()) +}) *CRDPluginMock { + mock := &CRDPluginMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// CRDPluginMock is an autogenerated mock type for the CRDPlugin type +type CRDPluginMock struct { + mock.Mock +} + +type CRDPluginMock_Expecter struct { + mock *mock.Mock +} + +func (_m *CRDPluginMock) EXPECT() *CRDPluginMock_Expecter { + return &CRDPluginMock_Expecter{mock: &_m.Mock} +} + +// Name provides a mock function for the type CRDPluginMock +func (_mock *CRDPluginMock) Name() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Name") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// CRDPluginMock_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name' +type CRDPluginMock_Name_Call struct { + *mock.Call +} + +// Name is a helper method to define mock.On call +func (_e *CRDPluginMock_Expecter) Name() *CRDPluginMock_Name_Call { + return &CRDPluginMock_Name_Call{Call: _e.mock.On("Name")} +} + +func (_c *CRDPluginMock_Name_Call) Run(run func()) *CRDPluginMock_Name_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *CRDPluginMock_Name_Call) Return(s string) *CRDPluginMock_Name_Call { + _c.Call.Return(s) + return _c +} + +func (_c *CRDPluginMock_Name_Call) RunAndReturn(run func() string) *CRDPluginMock_Name_Call { + _c.Call.Return(run) + return _c +} + +// Process provides a mock function for the type CRDPluginMock +func (_mock *CRDPluginMock) Process(request *CRDProcessorRequest) error { + ret := _mock.Called(request) + + if len(ret) == 0 { + panic("no return value specified for Process") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(*CRDProcessorRequest) error); ok { + r0 = returnFunc(request) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// CRDPluginMock_Process_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Process' +type CRDPluginMock_Process_Call struct { + *mock.Call +} + +// Process is a helper method to define mock.On call +// - request *CRDProcessorRequest +func (_e *CRDPluginMock_Expecter) Process(request interface{}) *CRDPluginMock_Process_Call { + return &CRDPluginMock_Process_Call{Call: _e.mock.On("Process", request)} +} + +func (_c *CRDPluginMock_Process_Call) Run(run func(request *CRDProcessorRequest)) *CRDPluginMock_Process_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 *CRDProcessorRequest + if args[0] != nil { + arg0 = args[0].(*CRDProcessorRequest) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *CRDPluginMock_Process_Call) Return(err error) *CRDPluginMock_Process_Call { + _c.Call.Return(err) + return _c +} + +func (_c *CRDPluginMock_Process_Call) RunAndReturn(run func(request *CRDProcessorRequest) error) *CRDPluginMock_Process_Call { + _c.Call.Return(run) + return _c +} + +// NewMappingPluginMock creates a new instance of MappingPluginMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMappingPluginMock(t interface { + mock.TestingT + Cleanup(func()) +}) *MappingPluginMock { + mock := &MappingPluginMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// MappingPluginMock is an autogenerated mock type for the MappingPlugin type +type MappingPluginMock struct { + mock.Mock +} + +type MappingPluginMock_Expecter struct { + mock *mock.Mock +} + +func (_m *MappingPluginMock) EXPECT() *MappingPluginMock_Expecter { + return &MappingPluginMock_Expecter{mock: &_m.Mock} +} + +// Name provides a mock function for the type MappingPluginMock +func (_mock *MappingPluginMock) Name() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Name") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// MappingPluginMock_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name' +type MappingPluginMock_Name_Call struct { + *mock.Call +} + +// Name is a helper method to define mock.On call +func (_e *MappingPluginMock_Expecter) Name() *MappingPluginMock_Name_Call { + return &MappingPluginMock_Name_Call{Call: _e.mock.On("Name")} +} + +func (_c *MappingPluginMock_Name_Call) Run(run func()) *MappingPluginMock_Name_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MappingPluginMock_Name_Call) Return(s string) *MappingPluginMock_Name_Call { + _c.Call.Return(s) + return _c +} + +func (_c *MappingPluginMock_Name_Call) RunAndReturn(run func() string) *MappingPluginMock_Name_Call { + _c.Call.Return(run) + return _c +} + +// Process provides a mock function for the type MappingPluginMock +func (_mock *MappingPluginMock) Process(request *MappingProcessorRequest) error { + ret := _mock.Called(request) + + if len(ret) == 0 { + panic("no return value specified for Process") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(*MappingProcessorRequest) error); ok { + r0 = returnFunc(request) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// MappingPluginMock_Process_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Process' +type MappingPluginMock_Process_Call struct { + *mock.Call +} + +// Process is a helper method to define mock.On call +// - request *MappingProcessorRequest +func (_e *MappingPluginMock_Expecter) Process(request interface{}) *MappingPluginMock_Process_Call { + return &MappingPluginMock_Process_Call{Call: _e.mock.On("Process", request)} +} + +func (_c *MappingPluginMock_Process_Call) Run(run func(request *MappingProcessorRequest)) *MappingPluginMock_Process_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 *MappingProcessorRequest + if args[0] != nil { + arg0 = args[0].(*MappingProcessorRequest) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *MappingPluginMock_Process_Call) Return(err error) *MappingPluginMock_Process_Call { + _c.Call.Return(err) + return _c +} + +func (_c *MappingPluginMock_Process_Call) RunAndReturn(run func(request *MappingProcessorRequest) error) *MappingPluginMock_Process_Call { + _c.Call.Return(run) + return _c +} + +// NewPropertyPluginMock creates a new instance of PropertyPluginMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewPropertyPluginMock(t interface { + mock.TestingT + Cleanup(func()) +}) *PropertyPluginMock { + mock := &PropertyPluginMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// PropertyPluginMock is an autogenerated mock type for the PropertyPlugin type +type PropertyPluginMock struct { + mock.Mock +} + +type PropertyPluginMock_Expecter struct { + mock *mock.Mock +} + +func (_m *PropertyPluginMock) EXPECT() *PropertyPluginMock_Expecter { + return &PropertyPluginMock_Expecter{mock: &_m.Mock} +} + +// Name provides a mock function for the type PropertyPluginMock +func (_mock *PropertyPluginMock) Name() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Name") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// PropertyPluginMock_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name' +type PropertyPluginMock_Name_Call struct { + *mock.Call +} + +// Name is a helper method to define mock.On call +func (_e *PropertyPluginMock_Expecter) Name() *PropertyPluginMock_Name_Call { + return &PropertyPluginMock_Name_Call{Call: _e.mock.On("Name")} +} + +func (_c *PropertyPluginMock_Name_Call) Run(run func()) *PropertyPluginMock_Name_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *PropertyPluginMock_Name_Call) Return(s string) *PropertyPluginMock_Name_Call { + _c.Call.Return(s) + return _c +} + +func (_c *PropertyPluginMock_Name_Call) RunAndReturn(run func() string) *PropertyPluginMock_Name_Call { + _c.Call.Return(run) + return _c +} + +// Process provides a mock function for the type PropertyPluginMock +func (_mock *PropertyPluginMock) Process(request *PropertyProcessorRequest) error { + ret := _mock.Called(request) + + if len(ret) == 0 { + panic("no return value specified for Process") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(*PropertyProcessorRequest) error); ok { + r0 = returnFunc(request) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// PropertyPluginMock_Process_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Process' +type PropertyPluginMock_Process_Call struct { + *mock.Call +} + +// Process is a helper method to define mock.On call +// - request *PropertyProcessorRequest +func (_e *PropertyPluginMock_Expecter) Process(request interface{}) *PropertyPluginMock_Process_Call { + return &PropertyPluginMock_Process_Call{Call: _e.mock.On("Process", request)} +} + +func (_c *PropertyPluginMock_Process_Call) Run(run func(request *PropertyProcessorRequest)) *PropertyPluginMock_Process_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 *PropertyProcessorRequest + if args[0] != nil { + arg0 = args[0].(*PropertyProcessorRequest) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *PropertyPluginMock_Process_Call) Return(err error) *PropertyPluginMock_Process_Call { + _c.Call.Return(err) + return _c +} + +func (_c *PropertyPluginMock_Process_Call) RunAndReturn(run func(request *PropertyProcessorRequest) error) *PropertyPluginMock_Process_Call { + _c.Call.Return(run) + return _c +} + +// NewExtensionPluginMock creates a new instance of ExtensionPluginMock. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewExtensionPluginMock(t interface { + mock.TestingT + Cleanup(func()) +}) *ExtensionPluginMock { + mock := &ExtensionPluginMock{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} + +// ExtensionPluginMock is an autogenerated mock type for the ExtensionPlugin type +type ExtensionPluginMock struct { + mock.Mock +} + +type ExtensionPluginMock_Expecter struct { + mock *mock.Mock +} + +func (_m *ExtensionPluginMock) EXPECT() *ExtensionPluginMock_Expecter { + return &ExtensionPluginMock_Expecter{mock: &_m.Mock} +} + +// Name provides a mock function for the type ExtensionPluginMock +func (_mock *ExtensionPluginMock) Name() string { + ret := _mock.Called() + + if len(ret) == 0 { + panic("no return value specified for Name") + } + + var r0 string + if returnFunc, ok := ret.Get(0).(func() string); ok { + r0 = returnFunc() + } else { + r0 = ret.Get(0).(string) + } + return r0 +} + +// ExtensionPluginMock_Name_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Name' +type ExtensionPluginMock_Name_Call struct { + *mock.Call +} + +// Name is a helper method to define mock.On call +func (_e *ExtensionPluginMock_Expecter) Name() *ExtensionPluginMock_Name_Call { + return &ExtensionPluginMock_Name_Call{Call: _e.mock.On("Name")} +} + +func (_c *ExtensionPluginMock_Name_Call) Run(run func()) *ExtensionPluginMock_Name_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *ExtensionPluginMock_Name_Call) Return(s string) *ExtensionPluginMock_Name_Call { + _c.Call.Return(s) + return _c +} + +func (_c *ExtensionPluginMock_Name_Call) RunAndReturn(run func() string) *ExtensionPluginMock_Name_Call { + _c.Call.Return(run) + return _c +} + +// Process provides a mock function for the type ExtensionPluginMock +func (_mock *ExtensionPluginMock) Process(request *ExtensionProcessorRequest) error { + ret := _mock.Called(request) + + if len(ret) == 0 { + panic("no return value specified for Process") + } + + var r0 error + if returnFunc, ok := ret.Get(0).(func(*ExtensionProcessorRequest) error); ok { + r0 = returnFunc(request) + } else { + r0 = ret.Error(0) + } + return r0 +} + +// ExtensionPluginMock_Process_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Process' +type ExtensionPluginMock_Process_Call struct { + *mock.Call +} + +// Process is a helper method to define mock.On call +// - request *ExtensionProcessorRequest +func (_e *ExtensionPluginMock_Expecter) Process(request interface{}) *ExtensionPluginMock_Process_Call { + return &ExtensionPluginMock_Process_Call{Call: _e.mock.On("Process", request)} +} + +func (_c *ExtensionPluginMock_Process_Call) Run(run func(request *ExtensionProcessorRequest)) *ExtensionPluginMock_Process_Call { + _c.Call.Run(func(args mock.Arguments) { + var arg0 *ExtensionProcessorRequest + if args[0] != nil { + arg0 = args[0].(*ExtensionProcessorRequest) + } + run( + arg0, + ) + }) + return _c +} + +func (_c *ExtensionPluginMock_Process_Call) Return(err error) *ExtensionPluginMock_Process_Call { + _c.Call.Return(err) + return _c +} + +func (_c *ExtensionPluginMock_Process_Call) RunAndReturn(run func(request *ExtensionProcessorRequest) error) *ExtensionPluginMock_Process_Call { + _c.Call.Return(run) + return _c +} diff --git a/tools/openapi2crd/pkg/plugins/read_only_properties.go b/tools/openapi2crd/pkg/plugins/read_only_properties.go new file mode 100644 index 0000000000..e9d9e18fd1 --- /dev/null +++ b/tools/openapi2crd/pkg/plugins/read_only_properties.go @@ -0,0 +1,56 @@ +// Copyright 2025 MongoDB Inc +// +// 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 plugins + +import ( + "slices" + + "k8s.io/apimachinery/pkg/util/sets" +) + +type ReadOnlyProperties struct{} + +func (p *ReadOnlyProperties) Name() string { + return "read_only_properties" +} + +func (p *ReadOnlyProperties) Process(req *PropertyProcessorRequest) error { + if req.PropertyConfig == nil || !req.PropertyConfig.Filters.ReadOnly { + return nil + } + + if req.OpenAPISchema.ReadOnly { + return nil + } + + required := sets.New(req.OpenAPISchema.Required...) + for name, p := range req.OpenAPISchema.Properties { + if !p.Value.ReadOnly { + required.Delete(name) + } + } + req.Property.Required = required.UnsortedList() + slices.Sort(req.Property.Required) + + // ignore root + if len(req.Path) == 1 { + return nil + } + + req.Property = nil + + return nil +} diff --git a/tools/openapi2crd/pkg/plugins/read_only_properties_test.go b/tools/openapi2crd/pkg/plugins/read_only_properties_test.go new file mode 100644 index 0000000000..8274314f05 --- /dev/null +++ b/tools/openapi2crd/pkg/plugins/read_only_properties_test.go @@ -0,0 +1,152 @@ +// Copyright 2025 MongoDB Inc +// +// 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 plugins + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + + "tools/openapi2crd/pkg/apis/config/v1alpha1" +) + +func TestReadOnlyPropertyName(t *testing.T) { + p := &ReadOnlyProperties{} + assert.Equal(t, "read_only_properties", p.Name()) +} + +func TestReadOnlyPropertyProcess(t *testing.T) { + tests := map[string]struct { + request *PropertyProcessorRequest + expectedProps *apiextensions.JSONSchemaProps + expectedError error + }{ + "do nothing when property config is nil": { + request: &PropertyProcessorRequest{ + Property: &apiextensions.JSONSchemaProps{ + Required: nil, + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + }, + expectedProps: &apiextensions.JSONSchemaProps{ + Required: nil, + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + expectedError: nil, + }, + "do nothing when read only filter is false": { + request: &PropertyProcessorRequest{ + PropertyConfig: &v1alpha1.PropertyMapping{ + Filters: v1alpha1.Filters{ + ReadOnly: false, + }, + }, + Property: &apiextensions.JSONSchemaProps{ + Required: nil, + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + }, + expectedProps: &apiextensions.JSONSchemaProps{ + Required: nil, + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + expectedError: nil, + }, + "do nothing when schema is read only": { + request: &PropertyProcessorRequest{ + PropertyConfig: &v1alpha1.PropertyMapping{ + Filters: v1alpha1.Filters{ + ReadOnly: true, + }, + }, + OpenAPISchema: &openapi3.Schema{ + ReadOnly: true, + }, + Property: &apiextensions.JSONSchemaProps{ + Required: nil, + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + }, + expectedProps: &apiextensions.JSONSchemaProps{ + Required: nil, + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + expectedError: nil, + }, + "remove non-read-only properties from required list": { + request: &PropertyProcessorRequest{ + PropertyConfig: &v1alpha1.PropertyMapping{ + Filters: v1alpha1.Filters{ + ReadOnly: true, + }, + }, + OpenAPISchema: &openapi3.Schema{ + ReadOnly: false, + Required: []string{"a", "b", "c"}, + Properties: map[string]*openapi3.SchemaRef{ + "a": {Value: &openapi3.Schema{ReadOnly: true}}, + "b": {Value: &openapi3.Schema{ReadOnly: false}}, + "c": {Value: &openapi3.Schema{ReadOnly: true}}, + }, + }, + Property: &apiextensions.JSONSchemaProps{ + Required: nil, + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + }, + expectedProps: nil, + expectedError: nil, + }, + "do nothing when path is root": { + request: &PropertyProcessorRequest{ + PropertyConfig: &v1alpha1.PropertyMapping{ + Filters: v1alpha1.Filters{ + ReadOnly: true, + }, + }, + OpenAPISchema: &openapi3.Schema{ + ReadOnly: false, + Required: []string{"a", "b", "c"}, + Properties: map[string]*openapi3.SchemaRef{ + "a": {Value: &openapi3.Schema{ReadOnly: true}}, + "b": {Value: &openapi3.Schema{ReadOnly: false}}, + "c": {Value: &openapi3.Schema{ReadOnly: true}}, + }, + }, + Property: &apiextensions.JSONSchemaProps{ + Required: nil, + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + Path: []string{"$"}, + }, + expectedProps: &apiextensions.JSONSchemaProps{ + Required: []string{"a", "c"}, + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + expectedError: nil, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + p := &ReadOnlyProperties{} + err := p.Process(test.request) + assert.Equal(t, test.expectedError, err) + assert.Equal(t, test.expectedProps, test.request.Property) + }) + } +} diff --git a/tools/openapi2crd/pkg/plugins/read_write_only_properties.go b/tools/openapi2crd/pkg/plugins/read_write_only_properties.go deleted file mode 100644 index f75a95104c..0000000000 --- a/tools/openapi2crd/pkg/plugins/read_write_only_properties.go +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright 2025 MongoDB Inc -// -// 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 plugins - -import ( - "slices" - - "github.com/getkin/kin-openapi/openapi3" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" - "k8s.io/apimachinery/pkg/util/sets" - configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" -) - -type ReadWriteOnlyProperties struct { - NoOp -} - -var _ Plugin = &ReadWriteOnlyProperties{} - -func NewReadWriteOnlyPropertiesPlugin() *ReadWriteOnlyProperties { - return &ReadWriteOnlyProperties{} -} - -func (s *ReadWriteOnlyProperties) Name() string { - return "read_write_only_properties" -} - -func (n *ReadWriteOnlyProperties) ProcessProperty(g Generator, propertyConfig *configv1alpha1.PropertyMapping, props *apiextensions.JSONSchemaProps, propertySchema *openapi3.Schema, extensionsSchema *openapi3.SchemaRef, path ...string) *apiextensions.JSONSchemaProps { - if propertyConfig == nil || !propertyConfig.Filters.ReadWriteOnly { - return props - } - - if propertySchema.ReadOnly { - return nil - } - - required := sets.New(propertySchema.Required...) - for name, p := range propertySchema.Properties { - if p.Value.ReadOnly { - required.Delete(name) - } - } - props.Required = required.UnsortedList() - slices.Sort(props.Required) - - // ignore root - if len(path) == 1 { - return props - } - - return props -} diff --git a/tools/openapi2crd/pkg/plugins/read_write_properties.go b/tools/openapi2crd/pkg/plugins/read_write_properties.go new file mode 100644 index 0000000000..8dd76f6674 --- /dev/null +++ b/tools/openapi2crd/pkg/plugins/read_write_properties.go @@ -0,0 +1,51 @@ +// Copyright 2025 MongoDB Inc +// +// 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 plugins + +import ( + "slices" + + "k8s.io/apimachinery/pkg/util/sets" +) + +type ReadWriteProperties struct{} + +func (p *ReadWriteProperties) Name() string { + return "read_write_property" +} + +func (p *ReadWriteProperties) Process(req *PropertyProcessorRequest) error { + if req.PropertyConfig == nil || !req.PropertyConfig.Filters.ReadWriteOnly { + return nil + } + + if req.OpenAPISchema.ReadOnly { + req.Property = nil + + return nil + } + + required := sets.New(req.OpenAPISchema.Required...) + for name, prop := range req.OpenAPISchema.Properties { + if prop.Value.ReadOnly { + required.Delete(name) + } + } + req.Property.Required = required.UnsortedList() + slices.Sort(req.Property.Required) + + return nil +} diff --git a/tools/openapi2crd/pkg/plugins/read_write_properties_test.go b/tools/openapi2crd/pkg/plugins/read_write_properties_test.go new file mode 100644 index 0000000000..0ba8d3a10e --- /dev/null +++ b/tools/openapi2crd/pkg/plugins/read_write_properties_test.go @@ -0,0 +1,131 @@ +// Copyright 2025 MongoDB Inc +// +// 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 plugins + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + + "tools/openapi2crd/pkg/apis/config/v1alpha1" +) + +func TestReadWritePropertyName(t *testing.T) { + p := &ReadWriteProperties{} + assert.Equal(t, "read_write_property", p.Name()) +} + +func TestReadWritePropertyProcess(t *testing.T) { + tests := map[string]struct { + request *PropertyProcessorRequest + expectedProps *apiextensions.JSONSchemaProps + expectedError error + }{ + "do nothing when property config is nil": { + request: &PropertyProcessorRequest{ + Property: &apiextensions.JSONSchemaProps{ + Required: nil, + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + }, + expectedProps: &apiextensions.JSONSchemaProps{ + Required: nil, + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + expectedError: nil, + }, + "do nothing when read write only filter is false": { + request: &PropertyProcessorRequest{ + PropertyConfig: &v1alpha1.PropertyMapping{ + Filters: v1alpha1.Filters{ + ReadWriteOnly: false, + }, + }, + Property: &apiextensions.JSONSchemaProps{ + Required: nil, + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + }, + expectedProps: &apiextensions.JSONSchemaProps{ + Required: nil, + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + expectedError: nil, + }, + "remove entire property when schema is read only": { + request: &PropertyProcessorRequest{ + PropertyConfig: &v1alpha1.PropertyMapping{ + Filters: v1alpha1.Filters{ + ReadWriteOnly: true, + }, + }, + OpenAPISchema: &openapi3.Schema{ + ReadOnly: true, + }, + Property: &apiextensions.JSONSchemaProps{ + Required: []string{"a", "b"}, + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + }, + expectedProps: nil, + expectedError: nil, + }, + "remove read only properties from required list and keep others": { + request: &PropertyProcessorRequest{ + PropertyConfig: &v1alpha1.PropertyMapping{ + Filters: v1alpha1.Filters{ + ReadWriteOnly: true, + }, + }, + OpenAPISchema: &openapi3.Schema{ + Required: []string{"a", "b", "c"}, + Properties: map[string]*openapi3.SchemaRef{ + "a": {Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + "b": {Value: &openapi3.Schema{Type: &openapi3.Types{"string"}, ReadOnly: true}}, + "c": {Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + }, + }, + Property: &apiextensions.JSONSchemaProps{ + Required: []string{"a", "b", "c"}, + Properties: map[string]apiextensions.JSONSchemaProps{ + "a": {Type: "string"}, + "b": {Type: "string"}, + "c": {Type: "string"}, + }, + }, + }, + expectedProps: &apiextensions.JSONSchemaProps{ + Required: []string{"a", "c"}, + Properties: map[string]apiextensions.JSONSchemaProps{ + "a": {Type: "string"}, + "b": {Type: "string"}, + "c": {Type: "string"}, + }, + }, + expectedError: nil, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + p := &ReadWriteProperties{} + err := p.Process(tc.request) + assert.Equal(t, tc.expectedError, err) + assert.Equal(t, tc.expectedProps, tc.request.Property) + }) + } +} diff --git a/tools/openapi2crd/pkg/plugins/readonly_properties.go b/tools/openapi2crd/pkg/plugins/readonly_properties.go deleted file mode 100644 index 160e0661dd..0000000000 --- a/tools/openapi2crd/pkg/plugins/readonly_properties.go +++ /dev/null @@ -1,65 +0,0 @@ -// Copyright 2025 MongoDB Inc -// -// 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 plugins - -import ( - "slices" - - "github.com/getkin/kin-openapi/openapi3" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" - "k8s.io/apimachinery/pkg/util/sets" - configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" -) - -type ReadOnlyProperties struct { - NoOp -} - -var _ Plugin = &ReadOnlyProperties{} - -func NewReadOnlyPropertiesPlugin() *ReadOnlyProperties { - return &ReadOnlyProperties{} -} - -func (s *ReadOnlyProperties) Name() string { - return "read_only_properties" -} - -func (n *ReadOnlyProperties) ProcessProperty(g Generator, propertyConfig *configv1alpha1.PropertyMapping, props *apiextensions.JSONSchemaProps, propertySchema *openapi3.Schema, extensionsSchema *openapi3.SchemaRef, path ...string) *apiextensions.JSONSchemaProps { - if propertyConfig == nil || !propertyConfig.Filters.ReadOnly { - return props - } - - if propertySchema.ReadOnly { - return props - } - - required := sets.New(propertySchema.Required...) - for name, p := range propertySchema.Properties { - if !p.Value.ReadOnly { - required.Delete(name) - } - } - props.Required = required.UnsortedList() - slices.Sort(props.Required) - - // ignore root - if len(path) == 1 { - return props - } - - return nil -} diff --git a/tools/openapi2crd/pkg/plugins/references.go b/tools/openapi2crd/pkg/plugins/references.go index a3fceab972..458d0f07a4 100644 --- a/tools/openapi2crd/pkg/plugins/references.go +++ b/tools/openapi2crd/pkg/plugins/references.go @@ -21,33 +21,22 @@ import ( "slices" "strings" - "github.com/getkin/kin-openapi/openapi3" "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" "k8s.io/apimachinery/pkg/util/sets" - configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" ) -type References struct { - NoOp - crd *apiextensions.CustomResourceDefinition -} - -var _ Plugin = &References{} - -func NewReferencesPlugin(crd *apiextensions.CustomResourceDefinition) *References { - return &References{ - crd: crd, - } -} +// References adds reference properties to the CRD OpenAPI schema based on the mapping configuration. +// It requires base and major version schemas to be already processed. +type References struct{} func (r *References) Name() string { - return "references" + return "reference" } -func (r *References) ProcessMapping(g Generator, mappingConfig *configv1alpha1.CRDMapping, openApiSpec *openapi3.T, extensionsSchema *openapi3.Schema) error { - majorVersionSpec := r.crd.Spec.Validation.OpenAPIV3Schema.Properties["spec"].Properties[mappingConfig.MajorVersion] +func (r *References) Process(req *MappingProcessorRequest) error { + majorVersionSpec := req.CRD.Spec.Validation.OpenAPIV3Schema.Properties["spec"].Properties[req.MappingConfig.MajorVersion] - for _, ref := range mappingConfig.ParametersMapping.References { + for _, ref := range req.MappingConfig.ParametersMapping.References { var refProp apiextensions.JSONSchemaProps openApiPropertyPath := strings.Split(ref.Property, ".") @@ -77,21 +66,7 @@ func (r *References) ProcessMapping(g Generator, mappingConfig *configv1alpha1.C slices.Sort(majorVersionSpec.Required) majorVersionSpec.Properties[ref.Name] = refProp - r.crd.Spec.Validation.OpenAPIV3Schema.Properties["spec"].Properties[mappingConfig.MajorVersion] = majorVersionSpec - - schema := openapi3.NewSchema() - schema.Extensions = map[string]interface{}{} - schema.Extensions["x-kubernetes-mapping"] = map[string]interface{}{ - "type": map[string]interface{}{"kind": ref.Target.Type.Kind, "group": ref.Target.Type.Group, "version": ref.Target.Type.Version, "resource": ref.Target.Type.Resource}, - "nameSelector": ".name", - "properties": ref.Target.Properties, - } - - schema.Extensions["x-openapi-mapping"] = map[string]interface{}{ - "property": ref.Property, - } - - extensionsSchema.Properties["spec"].Value.Properties[mappingConfig.MajorVersion].Value.Properties[ref.Name] = openapi3.NewSchemaRef("", schema) + req.CRD.Spec.Validation.OpenAPIV3Schema.Properties["spec"].Properties[req.MappingConfig.MajorVersion] = majorVersionSpec } return nil diff --git a/tools/openapi2crd/pkg/plugins/references_metadata.go b/tools/openapi2crd/pkg/plugins/references_metadata.go new file mode 100644 index 0000000000..99ca29aa19 --- /dev/null +++ b/tools/openapi2crd/pkg/plugins/references_metadata.go @@ -0,0 +1,52 @@ +// Copyright 2025 MongoDB Inc +// +// 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 plugins + +import ( + "errors" + + "github.com/getkin/kin-openapi/openapi3" +) + +type ReferencesMetadata struct{} + +func (r *ReferencesMetadata) Name() string { + return "reference_metadata" +} + +func (r *ReferencesMetadata) Process(req *ExtensionProcessorRequest) error { + for _, ref := range req.MappingConfig.ParametersMapping.References { + if len(ref.Target.Properties) == 0 { + return errors.New("reference target must have at least one property defined") + } + + schema := openapi3.NewSchema() + schema.Extensions = map[string]interface{}{} + schema.Extensions["x-kubernetes-mapping"] = map[string]interface{}{ + "type": map[string]interface{}{"kind": ref.Target.Type.Kind, "group": ref.Target.Type.Group, "version": ref.Target.Type.Version, "resource": ref.Target.Type.Resource}, + "nameSelector": ".name", + "properties": ref.Target.Properties, + } + + schema.Extensions["x-openapi-mapping"] = map[string]interface{}{ + "property": ref.Property, + } + + req.ExtensionsSchema.Properties["spec"].Value.Properties[req.MappingConfig.MajorVersion].Value.Properties[ref.Name] = openapi3.NewSchemaRef("", schema) + } + + return nil +} diff --git a/tools/openapi2crd/pkg/plugins/references_metadata_test.go b/tools/openapi2crd/pkg/plugins/references_metadata_test.go new file mode 100644 index 0000000000..5c514aec19 --- /dev/null +++ b/tools/openapi2crd/pkg/plugins/references_metadata_test.go @@ -0,0 +1,180 @@ +// Copyright 2025 MongoDB Inc +// +// 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 plugins + +import ( + "errors" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + + configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" +) + +func TestReferenceMetadataName(t *testing.T) { + p := &ReferencesMetadata{} + assert.Equal(t, "reference_metadata", p.Name()) +} + +func TestReferenceMetadataProcess(t *testing.T) { + tests := map[string]struct { + request *ExtensionProcessorRequest + expectedExtensions map[string]interface{} + expectedErr error + }{ + "do nothing when no references": { + request: &ExtensionProcessorRequest{ + MappingConfig: &configv1alpha1.CRDMapping{ + MajorVersion: "v20250312", + }, + ExtensionsSchema: &openapi3.Schema{ + Properties: map[string]*openapi3.SchemaRef{ + "spec": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "v20250312": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{}, + }, + }, + }, + }, + }, + }, + }, + }, + expectedExtensions: nil, + expectedErr: nil, + }, + "add reference metadata": { + request: &ExtensionProcessorRequest{ + MappingConfig: &configv1alpha1.CRDMapping{ + MajorVersion: "v20250312", + ParametersMapping: configv1alpha1.PropertyMapping{ + References: []configv1alpha1.Reference{ + { + Name: "myRef", + Property: "spec.myRef", + Target: configv1alpha1.Target{ + Type: configv1alpha1.Type{ + Kind: "MyKind", + Group: "mygroup.example.com", + Version: "v1", + Resource: "myresources", + }, + Properties: []string{"status.id"}, + }, + }, + }, + }, + }, + ExtensionsSchema: &openapi3.Schema{ + Properties: map[string]*openapi3.SchemaRef{ + "spec": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "v20250312": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{}, + }, + }, + }, + }, + }, + }, + }, + }, + expectedExtensions: map[string]interface{}{ + "x-kubernetes-mapping": map[string]interface{}{ + "type": map[string]interface{}{ + "kind": "MyKind", + "group": "mygroup.example.com", + "version": "v1", + "resource": "myresources", + }, + "nameSelector": ".name", + "properties": []string{ + "status.id", + }, + }, + "x-openapi-mapping": map[string]interface{}{ + "property": "spec.myRef", + }, + }, + expectedErr: nil, + }, + "error when reference target has no properties": { + request: &ExtensionProcessorRequest{ + MappingConfig: &configv1alpha1.CRDMapping{ + MajorVersion: "v20250312", + ParametersMapping: configv1alpha1.PropertyMapping{ + References: []configv1alpha1.Reference{ + { + Name: "myRef", + Property: "spec.myRef", + Target: configv1alpha1.Target{ + Type: configv1alpha1.Type{ + Kind: "MyKind", + Group: "mygroup.example.com", + Version: "v1", + Resource: "myresources", + }, + Properties: []string{}, + }, + }, + }, + }, + }, + ExtensionsSchema: &openapi3.Schema{ + Properties: map[string]*openapi3.SchemaRef{ + "spec": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "v20250312": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{}, + }, + }, + }, + }, + }, + }, + }, + }, + expectedExtensions: nil, + expectedErr: errors.New("reference target must have at least one property defined"), + }, + } + for name, tt := range tests { + t.Run(name, func(t *testing.T) { + p := &ReferencesMetadata{} + err := p.Process(tt.request) + assert.Equal(t, tt.expectedErr, err) + if tt.expectedExtensions != nil { + assert.Equal(t, tt.expectedExtensions, tt.request.ExtensionsSchema.Properties["spec"].Value.Properties[tt.request.MappingConfig.MajorVersion].Value.Properties["myRef"].Value.Extensions) + } else { + assert.Empty(t, tt.request.ExtensionsSchema.Properties["spec"].Value.Properties[tt.request.MappingConfig.MajorVersion].Value.Properties) + } + }) + } +} diff --git a/tools/openapi2crd/pkg/plugins/references_test.go b/tools/openapi2crd/pkg/plugins/references_test.go new file mode 100644 index 0000000000..ab7113864c --- /dev/null +++ b/tools/openapi2crd/pkg/plugins/references_test.go @@ -0,0 +1,226 @@ +// Copyright 2025 MongoDB Inc +// +// 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 plugins + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/utils/ptr" + + configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" +) + +func TestReferenceName(t *testing.T) { + p := &References{} + assert.Equal(t, "reference", p.Name()) +} + +func TestReferenceProcess(t *testing.T) { + tests := map[string]struct { + request *MappingProcessorRequest + expectedProps map[string]apiextensions.JSONSchemaProps + expectedError error + }{ + "do nothing when no references": { + request: groupMappingRequest(t, groupBaseCRDWithMajorVersion(t), nil, nil), + expectedProps: map[string]apiextensions.JSONSchemaProps{}, + expectedError: nil, + }, + "add reference to group": { + request: mappingRequestWithReferences(t, dataFederationCRD(t)), + expectedProps: map[string]apiextensions.JSONSchemaProps{ + "groupRef": { + Type: "object", + Description: "A reference to a \"Group\" resource.\nThe value of \"$.status.v20250312.id\" will be used to set \"groupId\".\nMutually exclusive with the \"groupId\" property.", + Properties: map[string]apiextensions.JSONSchemaProps{ + "name": { + Type: "string", + Description: `Name of the "Group" resource.`, + }, + }, + }, + }, + expectedError: nil, + }, + "error when reference target has no properties": { + request: &MappingProcessorRequest{ + CRD: dataFederationCRD(t), + MappingConfig: &configv1alpha1.CRDMapping{ + MajorVersion: "v20250312", + OpenAPIRef: configv1alpha1.LocalObjectReference{ + Name: "v20250312", + }, + ParametersMapping: configv1alpha1.PropertyMapping{ + References: []configv1alpha1.Reference{ + { + Target: configv1alpha1.Target{}, + }, + }, + }, + }, + }, + expectedProps: map[string]apiextensions.JSONSchemaProps{}, + expectedError: errors.New("reference target must have at least one property defined"), + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + p := &References{} + err := p.Process(test.request) + assert.Equal(t, test.expectedError, err) + spec := test.request.CRD.Spec.Validation.OpenAPIV3Schema.Properties["spec"].Properties[test.request.MappingConfig.MajorVersion] + assert.Equal(t, test.expectedProps, spec.Properties) + }) + } +} + +func dataFederationCRD(t *testing.T) *apiextensions.CustomResourceDefinition { + t.Helper() + + return &apiextensions.CustomResourceDefinition{ + ObjectMeta: metav1.ObjectMeta{ + Name: "datafederations.atlas.generated.mongodb.com", + }, + Spec: apiextensions.CustomResourceDefinitionSpec{ + Group: "atlas.generated.mongodb.com", + Names: apiextensions.CustomResourceDefinitionNames{ + Plural: "datafederations", + Singular: "datafederation", + Kind: "DataFederation", + ShortNames: []string{"adf"}, + Categories: []string{"atlas"}, + }, + Scope: apiextensions.NamespaceScoped, + Versions: []apiextensions.CustomResourceDefinitionVersion{ + { + Name: "v1", + Served: true, + Storage: true, + }, + }, + PreserveUnknownFields: ptr.To(false), + Validation: &apiextensions.CustomResourceValidation{ + OpenAPIV3Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Description: "A group, managed by the MongoDB Kubernetes Atlas Operator.", + Properties: map[string]apiextensions.JSONSchemaProps{ + "spec": { + Type: "object", + Description: "Specification of the group supporting the following versions:\n\n- v20250312\n\nAt most one versioned spec can be specified. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status", + Properties: map[string]apiextensions.JSONSchemaProps{ + "v20250312": { + Type: "object", + Description: "The spec of the group resource for version v20250312.", + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + }, + }, + "status": { + Type: "object", + Description: `Most recently observed read-only status of the group for the specified resource version. This data may not be up to date and is populated by the system. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status`, + Properties: map[string]apiextensions.JSONSchemaProps{ + "conditions": { + Type: "array", + Description: "Represents the latest available observations of a resource's current state.", + Items: &apiextensions.JSONSchemaPropsOrArray{ + Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "type": { + Type: "string", + Description: "Type of condition.", + }, + "status": { + Type: "string", + Description: "Status of the condition, one of True, False, Unknown.", + }, + "lastTransitionTime": { + Type: "string", + Format: "date-time", + Description: "Last time the condition transitioned from one status to another.", + }, + "reason": { + Type: "string", + Description: "The reason for the condition's last transition.", + }, + "message": { + Type: "string", + Description: "A human readable message indicating details about the transition.", + }, + "observedGeneration": { + Type: "integer", + Description: "observedGeneration represents the .metadata.generation that the condition was set based upon.", + }, + }, + Required: []string{"type", "status"}, + }, + }, + XListMapKeys: []string{"type"}, + XListType: ptr.To("map"), + }, + }, + }, + }, + }, + }, + Subresources: &apiextensions.CustomResourceSubresources{ + Status: &apiextensions.CustomResourceSubresourceStatus{}, + }, + }, + } +} + +func mappingRequestWithReferences( + t *testing.T, + crd *apiextensions.CustomResourceDefinition, +) *MappingProcessorRequest { + t.Helper() + + return &MappingProcessorRequest{ + CRD: crd, + MappingConfig: &configv1alpha1.CRDMapping{ + MajorVersion: "v20250312", + OpenAPIRef: configv1alpha1.LocalObjectReference{ + Name: "v20250312", + }, + ParametersMapping: configv1alpha1.PropertyMapping{ + Path: configv1alpha1.PropertyPath{ + Name: "/api/atlas/v2/groups/{groupId}/dataFederation", + Verb: "post", + }, + References: []configv1alpha1.Reference{ + { + Name: "groupRef", + Property: "$.groupId", + Target: configv1alpha1.Target{ + Type: configv1alpha1.Type{ + Kind: "Group", + Resource: "Groups", + Group: "atlas.generated.mongodb.com", + Version: "v1", + }, + Properties: []string{"$.status.v20250312.id"}, + }, + }, + }, + }, + }, + } +} diff --git a/tools/openapi2crd/pkg/plugins/sensitive_properties.go b/tools/openapi2crd/pkg/plugins/sesitive_properties.go similarity index 53% rename from tools/openapi2crd/pkg/plugins/sensitive_properties.go rename to tools/openapi2crd/pkg/plugins/sesitive_properties.go index d6e972a16c..b0ad5c5274 100644 --- a/tools/openapi2crd/pkg/plugins/sensitive_properties.go +++ b/tools/openapi2crd/pkg/plugins/sesitive_properties.go @@ -17,37 +17,32 @@ package plugins import ( "fmt" + "strings" - "github.com/getkin/kin-openapi/openapi3" v1 "k8s.io/api/core/v1" "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" ) -type SensitiveProperties struct { - NoOp -} - -func NewSensitivePropertiesPlugin() *SensitiveProperties { - return &SensitiveProperties{} -} +type SensitiveProperties struct{} -func (s *SensitiveProperties) Name() string { - return "sensitive_properties" +func (p *SensitiveProperties) Name() string { + return "sensitive_property" } -func (n *SensitiveProperties) ProcessProperty(g Generator, propertyConfig *configv1alpha1.PropertyMapping, props *apiextensions.JSONSchemaProps, propertySchema *openapi3.Schema, extensionsSchema *openapi3.SchemaRef, path ...string) *apiextensions.JSONSchemaProps { - if !isSensitiveField(path, propertyConfig) { - return props +func (p *SensitiveProperties) Process(req *PropertyProcessorRequest) error { + if !isSensitiveField(req.Path, req.PropertyConfig) { + return nil } - props.ID = path[len(path)-1] + "SecretRef" + req.Property.ID = req.Path[len(req.Path)-1] + "SecretRef" - if extensionsSchema.Value.Extensions == nil { - extensionsSchema.Value.Extensions = map[string]interface{}{} + if req.ExtensionsSchema.Value.Extensions == nil { + req.ExtensionsSchema.Value.Extensions = map[string]interface{}{} } - extensionsSchema.Value.Extensions["x-kubernetes-mapping"] = map[string]interface{}{ + req.ExtensionsSchema.Value.Extensions["x-kubernetes-mapping"] = map[string]interface{}{ "type": map[string]interface{}{ "kind": "Secret", "resource": v1.ResourceSecrets, @@ -57,15 +52,15 @@ func (n *SensitiveProperties) ProcessProperty(g Generator, propertyConfig *confi "propertySelectors": []string{"$.data.#"}, } - extensionsSchema.Value.Extensions["x-openapi-mapping"] = map[string]interface{}{ - "property": "." + path[len(path)-1], - "type": propertySchema.Type, + req.ExtensionsSchema.Value.Extensions["x-openapi-mapping"] = map[string]interface{}{ + "property": "." + req.Path[len(req.Path)-1], + "type": req.OpenAPISchema.Type, } - props.Type = "object" - props.Description = fmt.Sprintf("SENSITIVE FIELD\n\nReference to a secret containing data for the %q field:\n\n%v", path[len(path)-1], propertySchema.Description) - defaultKey := apiextensions.JSON(".data." + path[len(path)-1]) - props.Properties = map[string]apiextensions.JSONSchemaProps{ + req.Property.Type = "object" + req.Property.Description = fmt.Sprintf("SENSITIVE FIELD\n\nReference to a secret containing data for the %q field:\n\n%v", req.Path[len(req.Path)-1], req.OpenAPISchema.Description) + defaultKey := apiextensions.JSON(".data." + req.Path[len(req.Path)-1]) + req.Property.Properties = map[string]apiextensions.JSONSchemaProps{ "name": { Type: "string", Description: `Name of the secret containing the sensitive field value.`, @@ -73,11 +68,11 @@ func (n *SensitiveProperties) ProcessProperty(g Generator, propertyConfig *confi "key": { Type: "string", Default: &defaultKey, - Description: fmt.Sprintf(`Key of the secret data containing the sensitive field value, defaults to %q.`, path[len(path)-1]), + Description: fmt.Sprintf(`Key of the secret data containing the sensitive field value, defaults to %q.`, req.Path[len(req.Path)-1]), }, } - return props + return nil } func isSensitiveField(path []string, mapping *configv1alpha1.PropertyMapping) bool { @@ -95,3 +90,8 @@ func isSensitiveField(path []string, mapping *configv1alpha1.PropertyMapping) bo return false } + +func jsonPath(path []string) string { + result := strings.Join(path, ".") + return strings.ReplaceAll(result, ".[*]", "[*]") +} diff --git a/tools/openapi2crd/pkg/plugins/sesitive_properties_test.go b/tools/openapi2crd/pkg/plugins/sesitive_properties_test.go new file mode 100644 index 0000000000..322808641e --- /dev/null +++ b/tools/openapi2crd/pkg/plugins/sesitive_properties_test.go @@ -0,0 +1,164 @@ +// Copyright 2025 MongoDB Inc +// +// 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 plugins + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + v1 "k8s.io/api/core/v1" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + + configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" +) + +func TestSensitivePropertyName(t *testing.T) { + p := &SensitiveProperties{} + assert.Equal(t, "sensitive_property", p.Name()) +} + +func TestSensitivePropertyProcess(t *testing.T) { + stringJson := apiextensions.JSON(".data.password") + + tests := map[string]struct { + request *PropertyProcessorRequest + expectedProps *apiextensions.JSONSchemaProps + expectedExtensions *openapi3.SchemaRef + expectedError error + }{ + "do nothing when property config is nil": { + request: &PropertyProcessorRequest{ + Property: &apiextensions.JSONSchemaProps{ + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + }, + expectedProps: &apiextensions.JSONSchemaProps{ + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + expectedError: nil, + }, + "convert sensitive property": { + request: &PropertyProcessorRequest{ + PropertyConfig: &configv1alpha1.PropertyMapping{ + Filters: configv1alpha1.Filters{ + SensitiveProperties: []string{"$.password"}, + }, + }, + Property: &apiextensions.JSONSchemaProps{ + Required: nil, + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + ExtensionsSchema: &openapi3.SchemaRef{Value: &openapi3.Schema{}}, + OpenAPISchema: &openapi3.Schema{Type: &openapi3.Types{"string"}, Description: "the password"}, + Path: []string{"$", "password"}, + }, + expectedProps: &apiextensions.JSONSchemaProps{ + ID: "passwordSecretRef", + Type: "object", + Description: "SENSITIVE FIELD\n\nReference to a secret containing data for the \"password\" field:\n\nthe password", + Properties: map[string]apiextensions.JSONSchemaProps{ + "name": { + Type: "string", + Description: `Name of the secret containing the sensitive field value.`, + }, + "key": { + Type: "string", + Default: &stringJson, + Description: `Key of the secret data containing the sensitive field value, defaults to "password".`, + }, + }, + }, + expectedExtensions: &openapi3.SchemaRef{Value: &openapi3.Schema{ + Extensions: map[string]interface{}{ + "x-kubernetes-mapping": map[string]interface{}{ + "type": map[string]interface{}{ + "kind": "Secret", + "resource": v1.ResourceSecrets, + "version": "v1", + }, + "nameSelector": ".name", + "propertySelectors": []string{"$.data.#"}, + }, + "x-openapi-mapping": map[string]interface{}{ + "property": ".password", + "type": &openapi3.Types{"string"}, + }, + }, + }}, + expectedError: nil, + }, + "convert sensitive property in nested object": { + request: &PropertyProcessorRequest{ + PropertyConfig: &configv1alpha1.PropertyMapping{ + Filters: configv1alpha1.Filters{ + SensitiveProperties: []string{"$.credentials[*].password"}, + }, + }, + Property: &apiextensions.JSONSchemaProps{ + Required: nil, + Properties: map[string]apiextensions.JSONSchemaProps{}, + }, + ExtensionsSchema: &openapi3.SchemaRef{Value: &openapi3.Schema{}}, + OpenAPISchema: &openapi3.Schema{Type: &openapi3.Types{"string"}, Description: "the credentials password"}, + Path: []string{"$", "credentials[*]", "password"}, + }, + expectedProps: &apiextensions.JSONSchemaProps{ + ID: "passwordSecretRef", + Type: "object", + Description: "SENSITIVE FIELD\n\nReference to a secret containing data for the \"password\" field:\n\nthe credentials password", + Properties: map[string]apiextensions.JSONSchemaProps{ + "name": { + Type: "string", + Description: `Name of the secret containing the sensitive field value.`, + }, + "key": { + Type: "string", + Default: &stringJson, + Description: `Key of the secret data containing the sensitive field value, defaults to "password".`, + }, + }, + }, + expectedExtensions: &openapi3.SchemaRef{Value: &openapi3.Schema{ + Extensions: map[string]interface{}{ + "x-kubernetes-mapping": map[string]interface{}{ + "type": map[string]interface{}{ + "kind": "Secret", + "resource": v1.ResourceSecrets, + "version": "v1", + }, + "nameSelector": ".name", + "propertySelectors": []string{"$.data.#"}, + }, + "x-openapi-mapping": map[string]interface{}{ + "property": ".password", + "type": &openapi3.Types{"string"}, + }, + }, + }}, + expectedError: nil, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + p := &SensitiveProperties{} + err := p.Process(test.request) + assert.Equal(t, test.expectedError, err) + assert.Equal(t, test.expectedProps, test.request.Property) + assert.Equal(t, test.expectedExtensions, test.request.ExtensionsSchema) + }) + } +} diff --git a/tools/openapi2crd/pkg/plugins/skipped_properties.go b/tools/openapi2crd/pkg/plugins/skipped_properties.go index e82c810c21..6fdf5b035e 100644 --- a/tools/openapi2crd/pkg/plugins/skipped_properties.go +++ b/tools/openapi2crd/pkg/plugins/skipped_properties.go @@ -18,58 +18,44 @@ package plugins import ( "slices" - "github.com/getkin/kin-openapi/openapi3" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" ) -type SkippedProperties struct { - NoOp -} +type SkippedProperties struct{} -func NewSkippedPropertiesPlugin() *SkippedProperties { - return &SkippedProperties{} +func (p *SkippedProperties) Name() string { + return "skipped_property" } -func (s *SkippedProperties) Name() string { - return "skipped_properties" -} +func (p *SkippedProperties) Process(req *PropertyProcessorRequest) error { + if isSkippedField(req.Path, req.PropertyConfig) { + req.Property = nil -func (n *SkippedProperties) ProcessProperty(g Generator, propertyConfig *configv1alpha1.PropertyMapping, props *apiextensions.JSONSchemaProps, propertySchema *openapi3.Schema, extensionsSchema *openapi3.SchemaRef, path ...string) *apiextensions.JSONSchemaProps { - if isSkippedField(path, propertyConfig) { return nil } - if propertyConfig == nil || len(propertyConfig.Filters.SkipProperties) == 0 { - return props - } - requiredPaths := make(map[string]string) - for _, r := range propertySchema.Required { - requiredPaths[jsonPath(append(path, r))] = r + for _, r := range req.OpenAPISchema.Required { + requiredPaths[jsonPath(append(req.Path, r))] = r } - for _, s := range propertyConfig.Filters.SkipProperties { + for _, s := range req.PropertyConfig.Filters.SkipProperties { if _, ok := requiredPaths[s]; ok { delete(requiredPaths, s) } } - props.Required = make([]string, 0, len(props.Required)) + req.Property.Required = make([]string, 0, len(req.Property.Required)) for _, r := range requiredPaths { - props.Required = append(props.Required, r) + req.Property.Required = append(req.Property.Required, r) } - slices.Sort(props.Required) + slices.Sort(req.Property.Required) - return props + return nil } func isSkippedField(path []string, mapping *configv1alpha1.PropertyMapping) bool { - if mapping == nil { - return false - } - p := jsonPath(path) for _, skippedField := range mapping.Filters.SkipProperties { @@ -80,22 +66,3 @@ func isSkippedField(path []string, mapping *configv1alpha1.PropertyMapping) bool return false } - -func removeJsonPathEntries(source, entries []string) []string { - filtered := []string{} - for _, s := range source { - if !contains(entries, "$."+s) { - filtered = append(filtered, s) - } - } - return filtered -} - -func contains(s []string, str string) bool { - for _, v := range s { - if v == str { - return true - } - } - return false -} diff --git a/tools/openapi2crd/pkg/plugins/skipped_properties_test.go b/tools/openapi2crd/pkg/plugins/skipped_properties_test.go new file mode 100644 index 0000000000..999d74b3d0 --- /dev/null +++ b/tools/openapi2crd/pkg/plugins/skipped_properties_test.go @@ -0,0 +1,172 @@ +// Copyright 2025 MongoDB Inc +// +// 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 plugins + +import ( + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/stretchr/testify/assert" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + + configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" +) + +func TestSkippedPropertyName(t *testing.T) { + p := &SkippedProperties{} + assert.Equal(t, "skipped_property", p.Name()) +} + +func TestSkippedPropertyProcess(t *testing.T) { + tests := map[string]struct { + request *PropertyProcessorRequest + expectedProps *apiextensions.JSONSchemaProps + expectedError error + }{ + "do nothing when skipped property config is empty": { + request: &PropertyProcessorRequest{ + PropertyConfig: &configv1alpha1.PropertyMapping{}, + Property: &apiextensions.JSONSchemaProps{ + Properties: map[string]apiextensions.JSONSchemaProps{ + "description": { + Type: "string", + }, + }, + Required: []string{"description"}, + }, + OpenAPISchema: &openapi3.Schema{Type: &openapi3.Types{"object"}, Required: []string{"description"}}, + }, + expectedProps: &apiextensions.JSONSchemaProps{ + Properties: map[string]apiextensions.JSONSchemaProps{ + "description": { + Type: "string", + }, + }, + Required: []string{"description"}, + }, + expectedError: nil, + }, + "skip property": { + request: &PropertyProcessorRequest{ + PropertyConfig: &configv1alpha1.PropertyMapping{ + Filters: configv1alpha1.Filters{ + SkipProperties: []string{"$.description"}, + }, + }, + Property: &apiextensions.JSONSchemaProps{ + Properties: map[string]apiextensions.JSONSchemaProps{ + "description": { + Type: "string", + }, + }, + Required: []string{"description"}, + }, + Path: []string{"$", "description"}, + }, + expectedProps: nil, + expectedError: nil, + }, + "remove required property set to skip": { + request: &PropertyProcessorRequest{ + PropertyConfig: &configv1alpha1.PropertyMapping{ + Filters: configv1alpha1.Filters{ + SkipProperties: []string{"$.details[*].description"}, + }, + }, + Property: &apiextensions.JSONSchemaProps{ + Properties: map[string]apiextensions.JSONSchemaProps{ + "details": { + Type: "array", + Items: &apiextensions.JSONSchemaPropsOrArray{ + Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "name": { + Type: "string", + }, + "description": { + Type: "string", + }, + }, + }, + }, + }, + }, + Required: []string{"name", "description"}, + }, + OpenAPISchema: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "details": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Items: &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: map[string]*openapi3.SchemaRef{ + "name": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + "description": { + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + }, + }, + }, + }, + }, + }, + }, + }, + Required: []string{"name", "description"}, + }, + Path: []string{"$", "details[*]"}, + }, + expectedProps: &apiextensions.JSONSchemaProps{ + Properties: map[string]apiextensions.JSONSchemaProps{ + "details": { + Type: "array", + Items: &apiextensions.JSONSchemaPropsOrArray{ + Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "name": { + Type: "string", + }, + "description": { + Type: "string", + }, + }, + }, + }, + }, + }, + Required: []string{"name"}, + }, + expectedError: nil, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + p := &SkippedProperties{} + err := p.Process(test.request) + assert.Equal(t, test.expectedError, err) + assert.Equal(t, test.expectedProps, test.request.Property) + }) + } +} diff --git a/tools/openapi2crd/pkg/plugins/status.go b/tools/openapi2crd/pkg/plugins/status.go index 81b78a1692..da0605f5b3 100644 --- a/tools/openapi2crd/pkg/plugins/status.go +++ b/tools/openapi2crd/pkg/plugins/status.go @@ -19,41 +19,40 @@ import ( "fmt" "github.com/getkin/kin-openapi/openapi3" - "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" - configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" -) - -type StatusPlugin struct { - NoOp - crd *apiextensions.CustomResourceDefinition -} -var _ Plugin = &StatusPlugin{} + "tools/openapi2crd/pkg/converter" +) -func NewStatusPlugin(crd *apiextensions.CustomResourceDefinition) *StatusPlugin { - return &StatusPlugin{ - crd: crd, - } -} +// Status plugin adds the status schema to the CRD if specified in the mapping configuration. +// It requires the base plugin to be run first. +type Status struct{} -func (s *StatusPlugin) Name() string { +func (p *Status) Name() string { return "status" } -func (s *StatusPlugin) ProcessMapping(g Generator, mappingConfig *configv1alpha1.CRDMapping, openApiSpec *openapi3.T, extensionsSchema *openapi3.Schema) error { - if mappingConfig.StatusMapping.Schema == "" { +func (p *Status) Process(req *MappingProcessorRequest) error { + if req.MappingConfig.StatusMapping.Schema == "" { return nil } - statusSchema, ok := openApiSpec.Components.Schemas[mappingConfig.StatusMapping.Schema] + statusSchema, ok := req.OpenAPISpec.Components.Schemas[req.MappingConfig.StatusMapping.Schema] if !ok { - return fmt.Errorf("status schema %q not found in openapi spec", mappingConfig.StatusMapping.Schema) + return fmt.Errorf("status schema %q not found in openapi spec", req.MappingConfig.StatusMapping.Schema) } - statusProps := g.ConvertProperty(statusSchema, openapi3.NewSchemaRef("", openapi3.NewSchema()), &mappingConfig.StatusMapping, 0) - statusProps.Description = fmt.Sprintf("The last observed Atlas state of the %v resource for version %v.", s.crd.Spec.Names.Singular, mappingConfig.MajorVersion) + statusProps := req.Converter.Convert( + converter.PropertyConvertInput{ + Schema: statusSchema, + ExtensionsSchemaRef: openapi3.NewSchemaRef("", openapi3.NewSchema()), + PropertyConfig: &req.MappingConfig.StatusMapping, + Depth: 0, + Path: nil, + }, + ) if statusProps != nil { - s.crd.Spec.Validation.OpenAPIV3Schema.Properties["status"].Properties[mappingConfig.MajorVersion] = *statusProps + statusProps.Description = fmt.Sprintf("The last observed Atlas state of the %v resource for version %v.", req.CRD.Spec.Names.Singular, req.MappingConfig.MajorVersion) + req.CRD.Spec.Validation.OpenAPIV3Schema.Properties["status"].Properties[req.MappingConfig.MajorVersion] = *statusProps } return nil diff --git a/tools/openapi2crd/pkg/plugins/status_test.go b/tools/openapi2crd/pkg/plugins/status_test.go new file mode 100644 index 0000000000..933a29e27e --- /dev/null +++ b/tools/openapi2crd/pkg/plugins/status_test.go @@ -0,0 +1,176 @@ +// Copyright 2025 MongoDB Inc +// +// 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 plugins + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "k8s.io/apiextensions-apiserver/pkg/apis/apiextensions" + "k8s.io/utils/ptr" + + configv1alpha1 "tools/openapi2crd/pkg/apis/config/v1alpha1" + "tools/openapi2crd/pkg/converter" +) + +func TestStatusName(t *testing.T) { + p := &Status{} + assert.Equal(t, "status", p.Name()) +} + +func TestStatusProcess(t *testing.T) { + tests := map[string]struct { + request *MappingProcessorRequest + expectedProps apiextensions.JSONSchemaProps + expectedError error + }{ + "do nothing when no status mapping": { + request: &MappingProcessorRequest{ + CRD: groupBaseCRD(t), + MappingConfig: &configv1alpha1.CRDMapping{}, + }, + expectedProps: apiextensions.JSONSchemaProps{ + Type: "object", + Description: `Most recently observed read-only status of the group for the specified resource version. This data may not be up to date and is populated by the system. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status`, + Properties: map[string]apiextensions.JSONSchemaProps{ + "conditions": { + Type: "array", + Description: "Represents the latest available observations of a resource's current state.", + Items: &apiextensions.JSONSchemaPropsOrArray{ + Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "type": { + Type: "string", + Description: "Type of condition.", + }, + "status": { + Type: "string", + Description: "Status of the condition, one of True, False, Unknown.", + }, + "lastTransitionTime": { + Type: "string", + Format: "date-time", + Description: "Last time the condition transitioned from one status to another.", + }, + "reason": { + Type: "string", + Description: "The reason for the condition's last transition.", + }, + "message": { + Type: "string", + Description: "A human readable message indicating details about the transition.", + }, + "observedGeneration": { + Type: "integer", + Description: "observedGeneration represents the .metadata.generation that the condition was set based upon.", + }, + }, + Required: []string{"type", "status"}, + }, + }, + XListMapKeys: []string{"type"}, + XListType: ptr.To("map"), + }, + }, + }, + expectedError: nil, + }, + "add status schema to the CRD mapped to path": { + request: groupMappingRequest(t, groupBaseCRD(t), nil, groupStatusConvertMock(t)), + expectedProps: apiextensions.JSONSchemaProps{ + Type: "object", + Description: `Most recently observed read-only status of the group for the specified resource version. This data may not be up to date and is populated by the system. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#spec-and-status`, + Properties: map[string]apiextensions.JSONSchemaProps{ + "v20250312": { + Type: "object", + Description: "The last observed Atlas state of the group resource for version v20250312.", + Properties: map[string]apiextensions.JSONSchemaProps{ + "id": { + Type: "string", + Description: "Unique 24-hexadecimal digit string that identifies the group", + }, + }, + }, + "conditions": { + Type: "array", + Description: "Represents the latest available observations of a resource's current state.", + Items: &apiextensions.JSONSchemaPropsOrArray{ + Schema: &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "type": { + Type: "string", + Description: "Type of condition.", + }, + "status": { + Type: "string", + Description: "Status of the condition, one of True, False, Unknown.", + }, + "lastTransitionTime": { + Type: "string", + Format: "date-time", + Description: "Last time the condition transitioned from one status to another.", + }, + "reason": { + Type: "string", + Description: "The reason for the condition's last transition.", + }, + "message": { + Type: "string", + Description: "A human readable message indicating details about the transition.", + }, + "observedGeneration": { + Type: "integer", + Description: "observedGeneration represents the .metadata.generation that the condition was set based upon.", + }, + }, + Required: []string{"type", "status"}, + }, + }, + XListMapKeys: []string{"type"}, + XListType: ptr.To("map"), + }, + }, + }, + }, + } + for name, test := range tests { + t.Run(name, func(t *testing.T) { + p := &Status{} + err := p.Process(test.request) + assert.Equal(t, test.expectedError, err) + props := test.request.CRD.Spec.Validation.OpenAPIV3Schema.Properties["status"] + assert.Equal(t, test.expectedProps, props) + }) + } +} + +func groupStatusConvertMock(t *testing.T) converterFuncMock { + t.Helper() + + return func(input converter.PropertyConvertInput) *apiextensions.JSONSchemaProps { + return &apiextensions.JSONSchemaProps{ + Type: "object", + Properties: map[string]apiextensions.JSONSchemaProps{ + "id": { + Type: "string", + Description: "Unique 24-hexadecimal digit string that identifies the group", + }, + }, + } + } +}