Skip to content

Commit 1694f3d

Browse files
pedjakclaude
andcommitted
feat: add kubebuilder:externalDoc marker
Implement the `kubebuilder:externalDoc` marker to allow specifying external documentation (url and description) on fields and types, populating the externalDocs field in the generated OpenAPI schema per the OpenAPI 3.0.0 External Documentation Object specification. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 51ae3bf commit 1694f3d

File tree

8 files changed

+357
-0
lines changed

8 files changed

+357
-0
lines changed

pkg/crd/markers/crd.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,11 @@ var CRDMarkers = []*definitionWithHelp{
5757

5858
must(markers.MakeDefinition("kubebuilder:selectablefield", markers.DescribesType, SelectableField{})).
5959
WithHelp(SelectableField{}.Help()),
60+
61+
must(markers.MakeDefinition("kubebuilder:externalDoc", markers.DescribesField, ExternalDoc{})).
62+
WithHelp(ExternalDoc{}.Help()),
63+
must(markers.MakeDefinition("kubebuilder:externalDoc", markers.DescribesType, ExternalDoc{})).
64+
WithHelp(ExternalDoc{}.Help()),
6065
}
6166

6267
// TODO: categories and singular used to be annotations types
@@ -419,3 +424,25 @@ func (s SelectableField) ApplyToCRD(crd *apiextensionsv1.CustomResourceDefinitio
419424

420425
return nil
421426
}
427+
428+
// +controllertools:marker:generateHelp:category=CRD
429+
430+
// ExternalDoc specifies external documentation for this field or type.
431+
//
432+
// The url is required and must be a valid URL. The description is optional
433+
// and provides a short description of the external documentation.
434+
type ExternalDoc struct {
435+
// URL specifies the URL for the target documentation.
436+
URL string `marker:"url"`
437+
438+
// Description is a short description of the target documentation.
439+
Description string `marker:",optional"`
440+
}
441+
442+
func (m ExternalDoc) ApplyToSchema(schema *apiextensionsv1.JSONSchemaProps) error {
443+
schema.ExternalDocs = &apiextensionsv1.ExternalDocumentation{
444+
URL: m.URL,
445+
Description: m.Description,
446+
}
447+
return nil
448+
}

pkg/crd/markers/zz_generated.markerhelp.go

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/crd/parser_integration_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -224,6 +224,52 @@ var _ = Describe("CRD Generation From Parsing to CustomResourceDefinition", func
224224
})
225225
})
226226

227+
Context("ExternalDoc API", func() {
228+
BeforeEach(func() {
229+
pkgPaths = []string{"./external_docs/..."}
230+
expPkgLen = 1
231+
})
232+
It("should generate the CRD with external documentation and warn about link definition conflicts", func() {
233+
By("requesting that the ExternalDoc CRD be generated")
234+
groupKind := schema.GroupKind{Kind: "ExternalDoc", Group: "testdata.kubebuilder.io"}
235+
parser.NeedCRDFor(groupKind, nil)
236+
237+
By("fixing top level ObjectMeta on the ExternalDoc CRD")
238+
crd.FixTopLevelMetadata(parser.CustomResourceDefinitions[groupKind])
239+
240+
By("checking that expected warnings occurred")
241+
var errMsgs []string
242+
packages.Visit([]*packages.Package{pkgs[0].Package}, nil, func(pkgRaw *packages.Package) {
243+
for _, err := range pkgRaw.Errors {
244+
if err.Kind == packages.TypeError {
245+
continue
246+
}
247+
errMsgs = append(errMsgs, err.Msg)
248+
}
249+
})
250+
Expect(errMsgs).To(ContainElement(ContainSubstring("doc comment contains multiple link definitions for externalDoc")))
251+
Expect(errMsgs).To(ContainElement(ContainSubstring("kubebuilder:externalDoc marker is set, but doc comment also contains link definitions")))
252+
253+
By("checking that the ExternalDoc CRD is present")
254+
Expect(parser.CustomResourceDefinitions).To(HaveKey(groupKind))
255+
256+
By("loading the desired ExternalDoc YAML")
257+
expectedFile, err := os.ReadFile("testdata.kubebuilder.io_externaldocs.yaml")
258+
Expect(err).NotTo(HaveOccurred())
259+
260+
By("parsing the desired ExternalDoc YAML")
261+
var expectedCRD apiextensionsv1.CustomResourceDefinition
262+
Expect(yaml.Unmarshal(expectedFile, &expectedCRD)).To(Succeed())
263+
delete(expectedCRD.Annotations, "controller-gen.kubebuilder.io/version")
264+
if len(expectedCRD.Annotations) == 0 {
265+
expectedCRD.Annotations = nil
266+
}
267+
268+
By("comparing the two ExternalDoc CRDs")
269+
Expect(parser.CustomResourceDefinitions[groupKind]).To(Equal(expectedCRD), "type not as expected, check pkg/crd/testdata/README.md for more details.\n\nDiff:\n\n%s", cmp.Diff(parser.CustomResourceDefinitions[groupKind], expectedCRD))
270+
})
271+
})
272+
227273
Context("CronJob API without group", func() {
228274
BeforeEach(func() {
229275
pkgPaths = []string{"./nogroup"}

pkg/crd/schema.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"errors"
2121
"fmt"
2222
"go/ast"
23+
"go/doc/comment"
2324
"go/token"
2425
"go/types"
2526
"slices"
@@ -213,6 +214,56 @@ func applyMarkers(ctx *schemaContext, markerSet markers.MarkerValues, props *api
213214
}
214215
}
215216

217+
// parseLinkDefs parses Go 1.19+ link definitions from a doc comment string.
218+
func parseLinkDefs(doc string) []*comment.LinkDef {
219+
if doc == "" {
220+
return nil
221+
}
222+
var p comment.Parser
223+
d := p.Parse(doc)
224+
return d.Links
225+
}
226+
227+
// applyDocLinkDef sets ExternalDocs from doc comment link definitions.
228+
// It warns if multiple link defs are found or if both a marker and link defs exist.
229+
func applyDocLinkDef(ctx *schemaContext, props *apiextensionsv1.JSONSchemaProps, doc string, node ast.Node) {
230+
links := parseLinkDefs(doc)
231+
if len(links) == 0 {
232+
return
233+
}
234+
235+
formatLinkDefs := func() string {
236+
parts := make([]string, len(links))
237+
for i, l := range links {
238+
parts[i] = fmt.Sprintf("[%s]: %s", l.Text, l.URL)
239+
}
240+
return strings.Join(parts, ", ")
241+
}
242+
243+
if props.ExternalDocs != nil {
244+
// Explicit marker already set ExternalDocs — warn about redundant link defs
245+
ctx.pkg.AddError(loader.ErrFromNode(
246+
fmt.Errorf("kubebuilder:externalDoc marker is set, but doc comment also contains link definitions; marker takes precedence (found: %s)",
247+
formatLinkDefs()),
248+
node,
249+
))
250+
return
251+
}
252+
253+
if len(links) > 1 {
254+
ctx.pkg.AddError(loader.ErrFromNode(
255+
fmt.Errorf("doc comment contains multiple link definitions for externalDoc; using the first one (found: %s)",
256+
formatLinkDefs()),
257+
node,
258+
))
259+
}
260+
261+
props.ExternalDocs = &apiextensionsv1.ExternalDocumentation{
262+
Description: links[0].Text,
263+
URL: links[0].URL,
264+
}
265+
}
266+
216267
// typeToSchema creates a schema for the given AST type.
217268
func typeToSchema(ctx *schemaContext, rawType ast.Expr) *apiextensionsv1.JSONSchemaProps {
218269
var props *apiextensionsv1.JSONSchemaProps
@@ -238,6 +289,7 @@ func typeToSchema(ctx *schemaContext, rawType ast.Expr) *apiextensionsv1.JSONSch
238289
props.Description = ctx.info.Doc
239290

240291
applyMarkers(ctx, ctx.info.Markers, props, rawType)
292+
applyDocLinkDef(ctx, props, ctx.info.Doc, rawType)
241293

242294
return props
243295
}
@@ -528,6 +580,7 @@ func structToSchema(ctx *schemaContext, structType *ast.StructType) *apiextensio
528580
propSchema.Description = field.Doc
529581

530582
applyMarkers(ctx, field.Markers, propSchema, field.RawField)
583+
applyDocLinkDef(ctx, propSchema, field.Doc, field.RawField)
531584

532585
if inline {
533586
props.AllOf = append(props.AllOf, *propSchema)

pkg/crd/testdata/cronjob_types.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,14 @@ type CronJobSpec struct {
368368
StringAliasWithAddedTitle StringAliasWithTitle `json:"stringAliasWithAddedTitle,omitempty"`
369369
StringAliasWithTitle StringAliasWithTitle `json:"stringAliasWithTitle,omitempty"`
370370

371+
// This tests that external documentation can be attached to a field.
372+
// +kubebuilder:externalDoc:url="https://example.com/docs",description="external docs description"
373+
FieldWithExternalDoc string `json:"fieldWithExternalDoc,omitempty"`
374+
375+
// This tests that external documentation can be attached with only url.
376+
// +kubebuilder:externalDoc:url="https://example.com/docs"
377+
FieldWithExternalDocURLOnly string `json:"fieldWithExternalDocURLOnly,omitempty"`
378+
371379
// This tests string slice validation.
372380
// +kubebuilder:validation:MinItems=2
373381
// +kubebuilder:validation:MaxItems=2
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
/*
2+
3+
Licensed under the Apache License, Version 2.0 (the "License");
4+
you may not use this file except in compliance with the License.
5+
You may obtain a copy of the License at
6+
7+
http://www.apache.org/licenses/LICENSE-2.0
8+
9+
Unless required by applicable law or agreed to in writing, software
10+
distributed under the License is distributed on an "AS IS" BASIS,
11+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
See the License for the specific language governing permissions and
13+
limitations under the License.
14+
*/
15+
16+
// +groupName=testdata.kubebuilder.io
17+
// +versionName=v1
18+
package external_docs
19+
20+
import (
21+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
22+
)
23+
24+
// +kubebuilder:object:root=true
25+
26+
// ExternalDocSpec defines the desired state of ExternalDoc
27+
type ExternalDocSpec struct {
28+
// This tests that external documentation can be attached to a field with url and description.
29+
// +kubebuilder:externalDoc:url="https://example.com/docs",description="external docs description"
30+
FieldWithExternalDoc string `json:"fieldWithExternalDoc,omitempty"`
31+
32+
// This tests that external documentation can be attached with only url.
33+
// +kubebuilder:externalDoc:url="https://example.com/docs"
34+
FieldWithExternalDocURLOnly string `json:"fieldWithExternalDocURLOnly,omitempty"`
35+
36+
// This tests that external documentation from a type is propagated.
37+
TypeWithExternalDoc TypeWithExternalDoc `json:"typeWithExternalDoc,omitempty"`
38+
39+
// This field has a doc comment link definition.
40+
//
41+
// [Link Def Docs]: https://example.com/link-def
42+
FieldWithLinkDef string `json:"fieldWithLinkDef,omitempty"`
43+
44+
// This field has multiple doc comment link definitions.
45+
//
46+
// [First Link]: https://example.com/first
47+
// [Second Link]: https://example.com/second
48+
FieldWithMultipleLinkDefs string `json:"fieldWithMultipleLinkDefs,omitempty"`
49+
50+
// This field has both a marker and a link definition.
51+
//
52+
// [Link Def Override]: https://example.com/link-def-override
53+
// +kubebuilder:externalDoc:url="https://example.com/marker-wins",description="marker wins"
54+
FieldWithMarkerAndLinkDef string `json:"fieldWithMarkerAndLinkDef,omitempty"`
55+
56+
// This field has no link definition, just a regular doc comment.
57+
FieldWithNoLinkDef string `json:"fieldWithNoLinkDef,omitempty"`
58+
59+
// This tests that a type-level link definition propagates.
60+
TypeWithLinkDef TypeWithLinkDef `json:"typeWithLinkDef,omitempty"`
61+
}
62+
63+
// TypeWithExternalDoc is a type with external documentation.
64+
// +kubebuilder:externalDoc:url="https://example.com/type-docs",description="type-level external docs"
65+
type TypeWithExternalDoc string
66+
67+
// TypeWithLinkDef is a type with a doc comment link definition.
68+
//
69+
// [Type Link Def]: https://example.com/type-link-def
70+
type TypeWithLinkDef string
71+
72+
// ExternalDoc is the Schema for the external docs API
73+
type ExternalDoc struct {
74+
metav1.TypeMeta `json:",inline"`
75+
metav1.ObjectMeta `json:"metadata,omitempty"`
76+
77+
Spec ExternalDocSpec `json:"spec,omitempty"`
78+
}
79+
80+
// +kubebuilder:object:root=true
81+
82+
// ExternalDocList contains a list of ExternalDoc
83+
type ExternalDocList struct {
84+
metav1.TypeMeta `json:",inline"`
85+
metav1.ListMeta `json:"metadata,omitempty"`
86+
Items []ExternalDoc `json:"items"`
87+
}

pkg/crd/testdata/testdata.kubebuilder.io_cronjobs.yaml

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -244,6 +244,19 @@ spec:
244244
for local type declarations.
245245
maxLength: 10
246246
type: string
247+
fieldWithExternalDoc:
248+
description: This tests that external documentation can be attached
249+
to a field.
250+
externalDocs:
251+
description: external docs description
252+
url: https://example.com/docs
253+
type: string
254+
fieldWithExternalDocURLOnly:
255+
description: This tests that external documentation can be attached
256+
with only url.
257+
externalDocs:
258+
url: https://example.com/docs
259+
type: string
247260
float64WithValidations:
248261
maximum: 1.5
249262
minimum: -0.5

0 commit comments

Comments
 (0)