diff --git a/cmd/openapi-gen/args/args.go b/cmd/openapi-gen/args/args.go index 6472971d9..7634c727b 100644 --- a/cmd/openapi-gen/args/args.go +++ b/cmd/openapi-gen/args/args.go @@ -34,9 +34,11 @@ type Args struct { // Otherwise default value "-" will be used which indicates stdout. ReportFilename string - // UseOpenAPIModelNames specifies the use of OpenAPI model names instead of - // Go '.' names for types in the OpenAPI spec. - UseOpenAPIModelNames bool + // OutputModelNameFile is the name of the file to be generated for OpenAPI schema name + // accessor functions. If empty, no model name accessor functions are generated. + // When this is specified, the OpenAPI spec generator will use the function names + // instead of Go type names for schema names. + OutputModelNameFile string } // New returns default arguments for the generator. Returning the arguments instead @@ -58,11 +60,17 @@ func (args *Args) AddFlags(fs *pflag.FlagSet) { "the base Go import-path under which to generate results") fs.StringVar(&args.OutputFile, "output-file", "generated.openapi.go", "the name of the file to be generated") + fs.StringVar(&args.OutputModelNameFile, "output-model-name-file", "", + `The filename for generated model name accessor functions. +If specified, a file with this name will be created in each package containing +a "+k8s:openapi-model-package" tag. The generated functions return fully qualified +model names, which are used in the OpenAPI spec as schema references instead of +Go type names. If empty, no model name accessor functions are generated and names +are inferred from Go type names.`) fs.StringVar(&args.GoHeaderFile, "go-header-file", "", "the path to a file containing boilerplate header text; the string \"YEAR\" will be replaced with the current 4-digit year") fs.StringVarP(&args.ReportFilename, "report-filename", "r", args.ReportFilename, "Name of report file used by API linter to print API violations. Default \"-\" stands for standard output. NOTE that if valid filename other than \"-\" is specified, API linter won't return error on detected API violations. This allows further check of existing API violations without stopping the OpenAPI generation toolchain.") - fs.BoolVar(&args.UseOpenAPIModelNames, "use-openapi-model-names", false, "Use OpenAPI model names instead of Go '.' names for types in the OpenAPI spec.") } // Validate checks the given arguments. diff --git a/cmd/openapi-gen/openapi-gen.go b/cmd/openapi-gen/openapi-gen.go index b466019ad..b19da6f63 100644 --- a/cmd/openapi-gen/openapi-gen.go +++ b/cmd/openapi-gen/openapi-gen.go @@ -25,6 +25,7 @@ import ( "log" "github.com/spf13/pflag" + "k8s.io/gengo/v2" "k8s.io/gengo/v2/generator" "k8s.io/klog/v2" @@ -45,15 +46,35 @@ func main() { log.Fatalf("Arguments validation error: %v", err) } - myTargets := func(context *generator.Context) []generator.Target { - return generators.GetTargets(context, args) + boilerplate, err := gengo.GoBoilerplate(args.GoHeaderFile, gengo.StdBuildTag, gengo.StdGeneratedBy) + if err != nil { + log.Fatalf("Failed loading boilerplate: %v", err) + } + + // Generates the code for model name accessors. + if len(args.OutputModelNameFile) > 0 { + modelNameTargets := func(context *generator.Context) []generator.Target { + return generators.GetModelNameTargets(context, args, boilerplate) + } + if err := gengo.Execute( + generators.NameSystems(), + generators.DefaultNameSystem(), + modelNameTargets, + gengo.StdBuildTag, + pflag.Args(), + ); err != nil { + log.Fatalf("Model name code generation error: %v", err) + } } // Generates the code for the OpenAPIDefinitions. + openAPITargets := func(context *generator.Context) []generator.Target { + return generators.GetOpenAPITargets(context, args, boilerplate) + } if err := gengo.Execute( generators.NameSystems(), generators.DefaultNameSystem(), - myTargets, + openAPITargets, gengo.StdBuildTag, pflag.Args(), ); err != nil { diff --git a/pkg/generators/config.go b/pkg/generators/config.go index 9f60b0357..8335ed82e 100644 --- a/pkg/generators/config.go +++ b/pkg/generators/config.go @@ -19,7 +19,6 @@ package generators import ( "path" - "k8s.io/gengo/v2" "k8s.io/gengo/v2/generator" "k8s.io/gengo/v2/namer" "k8s.io/gengo/v2/types" @@ -49,12 +48,8 @@ func DefaultNameSystem() string { return "sorting_namer" } -func GetTargets(context *generator.Context, args *args.Args) []generator.Target { - boilerplate, err := gengo.GoBoilerplate(args.GoHeaderFile, gengo.StdBuildTag, gengo.StdGeneratedBy) - if err != nil { - klog.Fatalf("Failed loading boilerplate: %v", err) - } - +// GetOpenAPITargets returns the targets for OpenAPI definition generation. +func GetOpenAPITargets(context *generator.Context, args *args.Args, boilerplate []byte) []generator.Target { reportPath := "-" if args.ReportFilename != "" { reportPath = args.ReportFilename @@ -74,7 +69,7 @@ func GetTargets(context *generator.Context, args *args.Args) []generator.Target newOpenAPIGen( args.OutputFile, args.OutputPkg, - args.UseOpenAPIModelNames, + len(args.OutputModelNameFile) > 0, ), newAPIViolationGen(), } @@ -83,3 +78,56 @@ func GetTargets(context *generator.Context, args *args.Args) []generator.Target }, } } + +// GetModelNameTargets returns the targets for model name generation. +func GetModelNameTargets(context *generator.Context, args *args.Args, boilerplate []byte) []generator.Target { + var targets []generator.Target + for _, i := range context.Inputs { + klog.V(5).Infof("Considering pkg %q", i) + + pkg := context.Universe[i] + + openAPISchemaNamePackage, err := extractOpenAPISchemaNamePackage(pkg.Comments) + if err != nil { + klog.Fatalf("Package %v: invalid %s:%v", i, tagModelPackage, err) + } + hasPackageTag := len(openAPISchemaNamePackage) > 0 + + hasCandidates := false + for _, t := range pkg.Types { + v, err := singularTag(tagModelPackage, t.CommentLines) + if err != nil { + klog.Fatalf("Type %v: invalid %s:%v", t.Name, tagModelPackage, err) + } + hasTag := hasPackageTag || v != nil + hasModel := isSchemaNameType(t) + if hasModel && hasTag { + hasCandidates = true + break + } + } + if !hasCandidates { + klog.V(5).Infof(" skipping package") + continue + } + + klog.V(3).Infof("Generating package %q", pkg.Path) + + targets = append(targets, + &generator.SimpleTarget{ + PkgName: path.Base(pkg.Path), + PkgPath: pkg.Path, + PkgDir: pkg.Dir, // output pkg is the same as the input + HeaderComment: boilerplate, + FilterFunc: func(c *generator.Context, t *types.Type) bool { + return t.Name.Package == pkg.Path + }, + GeneratorsFunc: func(c *generator.Context) (generators []generator.Generator) { + return []generator.Generator{ + NewSchemaNameGen(args.OutputModelNameFile, pkg.Path, openAPISchemaNamePackage), + } + }, + }) + } + return targets +} diff --git a/pkg/generators/model_names.go b/pkg/generators/model_names.go new file mode 100644 index 000000000..783e975d4 --- /dev/null +++ b/pkg/generators/model_names.go @@ -0,0 +1,177 @@ +/* +Copyright 2025 The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package generators + +import ( + "fmt" + "io" + "strings" + + "k8s.io/gengo/v2" + "k8s.io/gengo/v2/generator" + "k8s.io/gengo/v2/namer" + "k8s.io/gengo/v2/types" + "k8s.io/klog/v2" +) + +const ( + tagModelPackage = "k8s:openapi-model-package" +) + +func extractOpenAPISchemaNamePackage(comments []string) (string, error) { + v, err := singularTag(tagModelPackage, comments) + if v == nil || err != nil { + return "", err + } + return v.Value, nil +} + +func singularTag(tagName string, comments []string) (*gengo.Tag, error) { + tags, err := gengo.ExtractFunctionStyleCommentTags("+", []string{tagName}, comments) + if err != nil { + return nil, err + } + if len(tags) == 0 { + return nil, nil + } + if len(tags) > 1 { + return nil, fmt.Errorf("multiple %s tags found", tagName) + } + tag := tags[tagName] + if len(tag) == 0 { + return nil, nil + } + if len(tag) > 1 { + klog.V(5).Infof("multiple %s tags found, using the first one", tagName) + } + value := tag[0] + return &value, nil +} + +// genSchemaName produces a file with autogenerated openapi schema name functions. +type genSchemaName struct { + generator.GoGenerator + targetPackage string + imports namer.ImportTracker + typesForInit []*types.Type + openAPISchemaNamePackage string +} + +// NewSchemaNameGen creates a generator +func NewSchemaNameGen(outputFilename, targetPackage string, openAPISchemaNamePackage string) generator.Generator { + return &genSchemaName{ + GoGenerator: generator.GoGenerator{ + OutputFilename: outputFilename, + }, + targetPackage: targetPackage, + imports: generator.NewImportTracker(), + typesForInit: make([]*types.Type, 0), + openAPISchemaNamePackage: openAPISchemaNamePackage, + } +} + +func (g *genSchemaName) Namers(c *generator.Context) namer.NameSystems { + return namer.NameSystems{ + "public": namer.NewPublicNamer(1), + "local": namer.NewPublicNamer(0), + "raw": namer.NewRawNamer("", nil), + } +} + +func (g *genSchemaName) Filter(c *generator.Context, t *types.Type) bool { + // Filter out types not being processed or not copyable within the package. + if !isSchemaNameType(t) { + klog.V(2).Infof("Type %v is not a valid target for OpenAPI schema name", t) + return false + } + g.typesForInit = append(g.typesForInit, t) + return true +} + +// isSchemaNameType indicates whether or not a type could be used to serve an API. +func isSchemaNameType(t *types.Type) bool { + // Filter out private types. + if namer.IsPrivateGoName(t.Name.Name) { + return false + } + + for t.Kind == types.Alias { + t = t.Underlying + } + + if t.Kind != types.Struct { + return false + } + return true +} + +func (g *genSchemaName) isOtherPackage(pkg string) bool { + if pkg == g.targetPackage { + return false + } + if strings.HasSuffix(pkg, ""+g.targetPackage+"") { + return false + } + return true +} + +func (g *genSchemaName) Imports(c *generator.Context) (imports []string) { + importLines := []string{} + for _, singleImport := range g.imports.ImportLines() { + if g.isOtherPackage(singleImport) { + importLines = append(importLines, singleImport) + } + } + return importLines +} + +func (g *genSchemaName) Init(c *generator.Context, w io.Writer) error { + return nil +} + +func (g *genSchemaName) GenerateType(c *generator.Context, t *types.Type, w io.Writer) error { + klog.V(3).Infof("Generating openapi schema name for type %v", t) + + openAPISchemaNamePackage := g.openAPISchemaNamePackage + v, err := singularTag(tagModelPackage, t.CommentLines) + if err != nil { + return fmt.Errorf("type %v: invalid %s:%v", t.Name, tagModelPackage, err) + } + if v != nil && v.Value != "" { + openAPISchemaNamePackage = v.Value + } + + if openAPISchemaNamePackage == "" { + return nil + } + + schemaName := openAPISchemaNamePackage + "." + t.Name.Name + + a := map[string]interface{}{ + "type": t, + "schemaName": schemaName, + } + + sw := generator.NewSnippetWriter(w, c, "$", "$") + + sw.Do("// OpenAPIModelName returns the OpenAPI model name for this type.\n", a) + sw.Do("func (in $.type|local$) OpenAPIModelName() string {\n", a) + sw.Do("\treturn \"$.schemaName$\"\n", a) + sw.Do("}\n\n", nil) + + return sw.Error() +} diff --git a/pkg/generators/openapi.go b/pkg/generators/openapi.go index 75a6706f2..621cc91d2 100644 --- a/pkg/generators/openapi.go +++ b/pkg/generators/openapi.go @@ -127,19 +127,19 @@ const ( type openAPIGen struct { generator.GoGenerator // TargetPackage is the package that will get GetOpenAPIDefinitions function returns all open API definitions. - targetPackage string - imports namer.ImportTracker - useOpenAPIModelNames bool + targetPackage string + imports namer.ImportTracker + useGeneratedModelNames bool } -func newOpenAPIGen(outputFilename string, targetPackage string, useOpenAPIModelNames bool) generator.Generator { +func newOpenAPIGen(outputFilename string, targetPackage string, useGeneratedModelNames bool) generator.Generator { return &openAPIGen{ GoGenerator: generator.GoGenerator{ OutputFilename: outputFilename, }, - imports: generator.NewImportTrackerForPackage(targetPackage), - targetPackage: targetPackage, - useOpenAPIModelNames: useOpenAPIModelNames, + imports: generator.NewImportTrackerForPackage(targetPackage), + targetPackage: targetPackage, + useGeneratedModelNames: useGeneratedModelNames, } } @@ -181,7 +181,7 @@ func (g *openAPIGen) Init(c *generator.Context, w io.Writer) error { sw.Do("return map[string]$.OpenAPIDefinition|raw${\n", argsFromType(nil)) for _, t := range c.Order { - err := newOpenAPITypeWriter(sw, c, g.useOpenAPIModelNames).generateCall(t) + err := newOpenAPITypeWriter(sw, c, g.useGeneratedModelNames).generateCall(t) if err != nil { return err } @@ -196,7 +196,7 @@ func (g *openAPIGen) Init(c *generator.Context, w io.Writer) error { func (g *openAPIGen) GenerateType(c *generator.Context, t *types.Type, w io.Writer) error { klog.V(5).Infof("generating for type %v", t) sw := generator.NewSnippetWriter(w, c, "$", "$") - err := newOpenAPITypeWriter(sw, c, g.useOpenAPIModelNames).generate(t) + err := newOpenAPITypeWriter(sw, c, g.useGeneratedModelNames).generate(t) if err != nil { return err } @@ -235,16 +235,16 @@ type openAPITypeWriter struct { refTypes map[string]*types.Type enumContext *enumContext GetDefinitionInterface *types.Type - useOpenAPIModelNames bool + useGeneratedModelNames bool } -func newOpenAPITypeWriter(sw *generator.SnippetWriter, c *generator.Context, useOpenAPIModelNames bool) openAPITypeWriter { +func newOpenAPITypeWriter(sw *generator.SnippetWriter, c *generator.Context, useGeneratedModelNames bool) openAPITypeWriter { return openAPITypeWriter{ - SnippetWriter: sw, - context: c, - refTypes: map[string]*types.Type{}, - enumContext: newEnumContext(c), - useOpenAPIModelNames: useOpenAPIModelNames, + SnippetWriter: sw, + context: c, + refTypes: map[string]*types.Type{}, + enumContext: newEnumContext(c), + useGeneratedModelNames: useGeneratedModelNames, } } @@ -349,7 +349,7 @@ func (g openAPITypeWriter) generateCall(t *types.Type) error { args := argsFromType(t) - if g.useOpenAPIModelNames { + if g.useGeneratedModelNames { g.Do("$.|raw${}.OpenAPIModelName(): ", t) } else { // Legacy case: use the "canonical type name" @@ -671,6 +671,9 @@ func (g openAPITypeWriter) generate(t *types.Type) error { deps := []string{} for _, k := range keys { v := g.refTypes[k] + if t.Kind != types.Struct { + continue + } if t, _ := openapi.OpenAPITypeFormat(v.String()); t != "" { // This is a known type, we do not need a reference to it // Will eliminate special case of time.Time @@ -682,7 +685,7 @@ func (g openAPITypeWriter) generate(t *types.Type) error { g.Do("Dependencies: []string{\n", args) for _, k := range deps { t := g.refTypes[k] - if g.useOpenAPIModelNames { + if g.useGeneratedModelNames { g.Do("$.|raw${}.OpenAPIModelName(),", t) } else { g.Do("\"$.$\",", k) @@ -1030,8 +1033,10 @@ func (g openAPITypeWriter) generateProperty(m *types.Member, parent *types.Type) if err := g.generateSliceProperty(t); err != nil { return fmt.Errorf("failed to generate slice property in %v: %v: %v", parent, m.Name, err) } - case types.Struct, types.Interface: + case types.Struct: g.generateReferenceProperty(t) + case types.Interface: + // Don't generate references to interfaces since we don't declare them default: return fmt.Errorf("cannot generate spec for type %v", t) } @@ -1046,7 +1051,7 @@ func (g openAPITypeWriter) generateSimpleProperty(typeString, format string) { func (g openAPITypeWriter) generateReferenceProperty(t *types.Type) { g.refTypes[t.Name.String()] = t - if g.useOpenAPIModelNames { + if g.useGeneratedModelNames { g.Do("Ref: ref($.|raw${}.OpenAPIModelName()),\n", t) } else { g.Do("Ref: ref(\"$.$\"),\n", t.Name.String()) diff --git a/test/integration/README.md b/test/integration/README.md index c6c28b698..3bfeb42d1 100644 --- a/test/integration/README.md +++ b/test/integration/README.md @@ -25,6 +25,15 @@ $ go run ../../cmd/openapi-gen/openapi-gen.go \ --go-header-file ../../boilerplate/boilerplate.go.txt \ --report-filename ./testdata/golden.v2.report \ ./testdata/custom ./testdata/enumtype ./testdata/listtype ./testdata/maptype ./testdata/structtype ./testdata/dummytype ./testdata/uniontype ./testdata/defaults ./testdata/valuevalidation + +$ go run ../../cmd/openapi-gen/openapi-gen.go \ + --output-dir pkg/generated/namedmodels \ + --output-pkg generated \ + --output-file openapi_generated.go \ + --output-model-name-file zz_generated_model_name.go \ + --go-header-file ../../boilerplate/boilerplate.go.txt \ + --report-filename ./testdata/namedmodels/golden.v3.report \ + ./testdata/namedmodels ``` The generated file `pkg/generated/openapi_generated.go` should have been created. diff --git a/test/integration/integration_suite_test.go b/test/integration/integration_suite_test.go index 50e195da4..beb3e2062 100644 --- a/test/integration/integration_suite_test.go +++ b/test/integration/integration_suite_test.go @@ -29,18 +29,19 @@ import ( ) const ( - headerFilePath = "../../boilerplate/boilerplate.go.txt" - testdataDir = "./testdata" - testPkgRoot = "k8s.io/kube-openapi/test/integration/testdata" - outputPkg = testPkgRoot + "/pkg/generated" - generatedCodeFileName = "openapi_generated.go" - goldenCodeFilePath = "pkg/generated/" + generatedCodeFileName - generatedSwaggerFileName = "generated.v2.json" - goldenSwaggerFileName = "golden.v2.json" - generatedReportFileName = "generated.v2.report" - goldenReportFileName = "golden.v2.report" - generatedOpenAPIv3FileName = "generated.v3.json" - goldenOpenAPIv3Filename = "golden.v3.json" + headerFilePath = "../../boilerplate/boilerplate.go.txt" + testdataDir = "./testdata" + testPkgRoot = "k8s.io/kube-openapi/test/integration/testdata" + outputPkg = testPkgRoot + "/pkg/generated" + generatedCodeFileName = "openapi_generated.go" + generatedSchemaNameCodeFileName = "zz_generated_model_name.go" + goldenCodeFilePath = "pkg/generated/" + generatedCodeFileName + generatedSwaggerFileName = "generated.v2.json" + goldenSwaggerFileName = "golden.v2.json" + generatedReportFileName = "generated.v2.report" + goldenReportFileName = "golden.v2.report" + generatedOpenAPIv3FileName = "generated.v3.json" + goldenOpenAPIv3Filename = "golden.v3.json" timeoutSeconds = 60.0 ) @@ -104,7 +105,7 @@ var _ = BeforeSuite(func() { Expect(err).ShouldNot(HaveOccurred()) Eventually(session, timeoutSeconds).Should(gexec.Exit(0)) - // Run the OpenAPI code generator with --use-openapi-model-names + // Run the OpenAPI code generator with --output-model-name-file Expect(terr).ShouldNot(HaveOccurred()) By("'namedmodels' running openapi-gen") @@ -112,7 +113,7 @@ var _ = BeforeSuite(func() { "--output-dir", tempDir + "/namedmodels", "--output-pkg", outputPkg + "/namedmodels", "--output-file", generatedCodeFileName, - "--use-openapi-model-names", + "--output-model-name-file", generatedSchemaNameCodeFileName, "--go-header-file", headerFilePath, }, path.Join(testPkgRoot, "namedmodels")) command = exec.Command(openAPIGenPath, args...) diff --git a/test/integration/testdata/namedmodels/doc.go b/test/integration/testdata/namedmodels/doc.go new file mode 100644 index 000000000..afc56e588 --- /dev/null +++ b/test/integration/testdata/namedmodels/doc.go @@ -0,0 +1,3 @@ +// +k8s:openapi-gen=true +// +k8s:openapi-model-package=io.k8s.kube-openapi.test.integration.testdata.namedmodels +package namedmodels diff --git a/test/integration/testdata/namedmodels/golden.v3.report b/test/integration/testdata/namedmodels/golden.v3.report new file mode 100644 index 000000000..84e5b7a4e --- /dev/null +++ b/test/integration/testdata/namedmodels/golden.v3.report @@ -0,0 +1,3 @@ +API rule violation: names_match,k8s.io/kube-openapi/test/integration/testdata/namedmodels,AtomicStruct,Field +API rule violation: names_match,k8s.io/kube-openapi/test/integration/testdata/namedmodels,Struct,Field +API rule violation: names_match,k8s.io/kube-openapi/test/integration/testdata/namedmodels,Struct,OtherField diff --git a/test/integration/testdata/namedmodels/struct.go b/test/integration/testdata/namedmodels/struct.go index 58313a481..7a136a10a 100644 --- a/test/integration/testdata/namedmodels/struct.go +++ b/test/integration/testdata/namedmodels/struct.go @@ -1,27 +1,12 @@ -package structtype +package namedmodels -// +k8s:openapi-gen=true type Struct struct { Field ContainedStruct OtherField int } -func (Struct) OpenAPIModelName() string { - return "com.example.Struct" -} - -// +k8s:openapi-gen=true type ContainedStruct struct{} -func (ContainedStruct) OpenAPIModelName() string { - return "com.example.ContainedStruct" -} - -// +k8s:openapi-gen=true type AtomicStruct struct { Field int } - -func (AtomicStruct) OpenAPIModelName() string { - return "com.example.AtomicStruct" -} diff --git a/test/integration/testdata/namedmodels/zz_generated_model_name.go b/test/integration/testdata/namedmodels/zz_generated_model_name.go new file mode 100644 index 000000000..0c2dcbd10 --- /dev/null +++ b/test/integration/testdata/namedmodels/zz_generated_model_name.go @@ -0,0 +1,37 @@ +//go:build !ignore_autogenerated +// +build !ignore_autogenerated + +/* +Copyright The Kubernetes Authors. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +// Code generated by openapi-gen. DO NOT EDIT. + +package namedmodels + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in AtomicStruct) OpenAPIModelName() string { + return "io.k8s.kube-openapi.test.integration.testdata.namedmodels.AtomicStruct" +} + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in ContainedStruct) OpenAPIModelName() string { + return "io.k8s.kube-openapi.test.integration.testdata.namedmodels.ContainedStruct" +} + +// OpenAPIModelName returns the OpenAPI model name for this type. +func (in Struct) OpenAPIModelName() string { + return "io.k8s.kube-openapi.test.integration.testdata.namedmodels.Struct" +}