diff --git a/pkg/crd/markers/validation.go b/pkg/crd/markers/validation.go index ccd52e53c..e1cad593c 100644 --- a/pkg/crd/markers/validation.go +++ b/pkg/crd/markers/validation.go @@ -103,6 +103,9 @@ var FieldOnlyMarkers = []*definitionWithHelp{ must(markers.MakeDefinition(SchemalessName, markers.DescribesField, Schemaless{})). WithHelp(Schemaless{}.Help()), + + must(markers.MakeAnyTypeDefinition("kubebuilder:title", markers.DescribesField, Title{})). + WithHelp(Title{}.Help()), } // ValidationIshMarkers are field-and-type markers that don't fall under the @@ -242,6 +245,17 @@ type Default struct { Value interface{} } +// +controllertools:marker:generateHelp:category="CRD validation" +// Title sets the title for this field. +// +// The title is metadata that makes the OpenAPI documentation more user-friendly, +// making the schema more understandable when viewed in documentation tools. +// It's a metadata field that doesn't affect validation but provides +// important context about what the schema represents. +type Title struct { + Value interface{} +} + // +controllertools:marker:generateHelp:category="CRD validation" // Default sets the default value for this field. // @@ -527,6 +541,19 @@ func (m Default) ApplyPriority() ApplyPriority { return 10 } +func (m Title) ApplyToSchema(schema *apiext.JSONSchemaProps) error { + if m.Value == nil { + // only apply to the schema if we have a non-nil title + return nil + } + title, isStr := m.Value.(string) + if !isStr { + return fmt.Errorf("expected string, got %T", m.Value) + } + schema.Title = title + return nil +} + func (m *KubernetesDefault) ParseMarker(_ string, _ string, restFields string) error { if strings.HasPrefix(strings.TrimSpace(restFields), "ref(") { // Skip +default=ref(...) values for now, since we don't have a good way to evaluate go constant values via AST. diff --git a/pkg/crd/markers/zz_generated.markerhelp.go b/pkg/crd/markers/zz_generated.markerhelp.go index a9ee23311..7d8282cfc 100644 --- a/pkg/crd/markers/zz_generated.markerhelp.go +++ b/pkg/crd/markers/zz_generated.markerhelp.go @@ -469,6 +469,22 @@ func (SubresourceStatus) Help() *markers.DefinitionHelp { } } +func (Title) Help() *markers.DefinitionHelp { + return &markers.DefinitionHelp{ + Category: "CRD validation", + DetailedHelp: markers.DetailedHelp{ + Summary: "sets the title for this field.", + Details: "The title is metadata that makes the OpenAPI documentation more user-friendly,\nmaking the schema more understandable when viewed in documentation tools.\nIt's a metadata field that doesn't affect validation but provides\nimportant context about what the schema represents.", + }, + FieldHelp: map[string]markers.DetailedHelp{ + "Value": { + Summary: "", + Details: "", + }, + }, + } +} + func (Type) Help() *markers.DefinitionHelp { return &markers.DefinitionHelp{ Category: "CRD validation", diff --git a/pkg/crd/parser_integration_test.go b/pkg/crd/parser_integration_test.go index 6065fc45b..59e62cff8 100644 --- a/pkg/crd/parser_integration_test.go +++ b/pkg/crd/parser_integration_test.go @@ -187,6 +187,19 @@ var _ = Describe("CRD Generation From Parsing to CustomResourceDefinition", func }) }) + Context("Field with unvalid title format", func() { + BeforeEach(func() { + pkgPaths = []string{"./wrong_title_format"} + expPkgLen = 1 + }) + It("cannot generate title field from integer", func() { + assertError(pkgs[0], "JobSpec", "expected string, got int") + }) + It("cannot generate title field from map", func() { + assertError(pkgs[0], "TestType", "expected string, got map[string]interface {}") + }) + }) + Context("CronJob API without group", func() { BeforeEach(func() { pkgPaths = []string{"./nogroup"} diff --git a/pkg/crd/testdata/cronjob_types.go b/pkg/crd/testdata/cronjob_types.go index c7c96103e..e496407b2 100644 --- a/pkg/crd/testdata/cronjob_types.go +++ b/pkg/crd/testdata/cronjob_types.go @@ -113,20 +113,24 @@ type CronJobSpec struct { // This tests that primitive defaulting can be performed. // +kubebuilder:default=forty-two // +kubebuilder:example=forty-two + // +kubebuilder:title=DefaultedString DefaultedString string `json:"defaultedString"` // This tests that slice defaulting can be performed. // +kubebuilder:default={a,b} // +kubebuilder:example={a,b} + // +kubebuilder:title=DefaultedSlice DefaultedSlice []string `json:"defaultedSlice"` // This tests that slice and object defaulting can be performed. // +kubebuilder:default={{nested: {foo: "baz", bar: true}},{nested: {foo: "qux", bar: false}}} // +kubebuilder:example={{nested: {foo: "baz", bar: true}},{nested: {foo: "qux", bar: false}}} + // +kubebuilder:title="124" DefaultedObject []RootObject `json:"defaultedObject"` // This tests that empty slice defaulting can be performed. // +kubebuilder:default={} + // +kubebuilder:title="{}" DefaultedEmptySlice []string `json:"defaultedEmptySlice"` // This tests that an empty object defaulting can be performed on a map. diff --git a/pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml b/pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml index ab81550fc..8bbbaf1a1 100644 --- a/pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml +++ b/pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml @@ -131,6 +131,7 @@ spec: description: This tests that empty slice defaulting can be performed. items: type: string + title: '{}' type: array defaultedObject: default: @@ -163,6 +164,7 @@ spec: required: - nested type: object + title: "124" type: array defaultedSlice: default: @@ -174,11 +176,13 @@ spec: - b items: type: string + title: DefaultedSlice type: array defaultedString: default: forty-two description: This tests that primitive defaulting can be performed. example: forty-two + title: DefaultedString type: string doubleDefaultedString: default: kubebuilder-default diff --git a/pkg/crd/testdata/wrong_title_format/types.go b/pkg/crd/testdata/wrong_title_format/types.go new file mode 100644 index 000000000..998866184 --- /dev/null +++ b/pkg/crd/testdata/wrong_title_format/types.go @@ -0,0 +1,65 @@ +/* + +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. +*/ + +// +groupName=testdata.kubebuilder.io +// +versionName=v1beta1 +package job + +import ( + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + + "testdata.kubebuilder.io/cronjob/unserved" +) + +// +kubebuilder:object:root=true +// +kubebuilder:subresource:status +// +kubebuilder:resource:singular=job + +type TestType struct { + // Count is the number of times a job may be executed. + // + // +kubebuilder:title={} + Count int32 `json:"count"` +} + +// JobSpec is the spec for the jobs API. +type JobSpec struct { + // FriendlyName is the friendly name for the job. + // + // +kubebuilder:title=123 + FriendlyName string `json:"friendlyName"` + + // CronJob is the spec for the related CrongJob. + CronnJob unserved.CronJobSpec `json:"crongJob"` +} + +// Job is the Schema for the jobs API +type Job struct { + /* + */ + metav1.TypeMeta `json:",inline"` + metav1.ObjectMeta `json:"metadata,omitempty"` + + Spec JobSpec `json:"spec"` +} + +// +kubebuilder:object:root=true + +// JobList contains a list of Job +type JobList struct { + metav1.TypeMeta `json:",inline"` + metav1.ListMeta `json:"metadata,omitempty"` + Items []Job `json:"items"` +}