Skip to content

Commit e38b155

Browse files
generator: add opt-in GetX/SetX accessors to generated Go models
Generate pointer-in/pointer-out accessor methods for every field of every struct in the generated Go models, so consumers can abstract over resources with interfaces and generics. Folded into generateGo as a single AST post-process step so it applies uniformly across all generation paths. Gated behind a new features.generateGoModelAccessors config flag (off by default), threaded from config through the project build/run and function generate commands into the Go generator. Skips any GetX/SetX whose name already exists on a type, so the accessors never collide with methods oapi-codegen already emits (e.g. union or additionalProperties helpers). Verified by a compile gate that builds the generated module with a consumer that uses an accessor through an interface; the gate skips gracefully when module dependencies cannot be resolved offline. Signed-off-by: Erik Miller <erik.miller@gusto.com>
1 parent 65c41bc commit e38b155

12 files changed

Lines changed: 633 additions & 27 deletions

File tree

cmd/crossplane/config/help/config.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,10 @@ Enable alpha commands:
1818
```shell
1919
crossplane config set features.enableAlpha true
2020
```
21+
22+
Generate GetX/SetX accessor methods on generated Go models (off by default), so
23+
generated resources can be used through interfaces and generics:
24+
25+
```shell
26+
crossplane config set features.generateGoModelAccessors true
27+
```

cmd/crossplane/config/set.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,8 +42,9 @@ type boolSetter func(c *config.Config, v bool)
4242
//
4343
//nolint:gochecknoglobals // This is a constant.
4444
var boolKeys = map[string]boolSetter{
45-
"features.enableAlpha": func(c *config.Config, v bool) { c.Features.EnableAlpha = v },
46-
"features.disableBeta": func(c *config.Config, v bool) { c.Features.DisableBeta = v },
45+
"features.enableAlpha": func(c *config.Config, v bool) { c.Features.EnableAlpha = v },
46+
"features.disableBeta": func(c *config.Config, v bool) { c.Features.DisableBeta = v },
47+
"features.generateGoModelAccessors": func(c *config.Config, v bool) { c.Features.GenerateGoModelAccessors = v },
4748
}
4849

4950
func (c *setCmd) AfterApply() error {

cmd/crossplane/function/generate.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import (
3939
"github.com/crossplane/crossplane-runtime/v2/pkg/xpkg"
4040

4141
v1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1"
42+
"github.com/crossplane/cli/v2/internal/config"
4243
"github.com/crossplane/cli/v2/internal/filesystem"
4344
"github.com/crossplane/cli/v2/internal/kcl"
4445
"github.com/crossplane/cli/v2/internal/project/projectfile"
@@ -144,7 +145,7 @@ func functionSchemaLanguage(functionLang string) string {
144145
}
145146

146147
// Run generates a function scaffold.
147-
func (c *generateCmd) Run(sp terminal.SpinnerPrinter) error {
148+
func (c *generateCmd) Run(sp terminal.SpinnerPrinter, cfg *config.Config) error {
148149
if err := c.validatePaths(); err != nil {
149150
return err
150151
}
@@ -156,7 +157,7 @@ func (c *generateCmd) Run(sp terminal.SpinnerPrinter) error {
156157
}
157158
schemaMgr := manager.New(
158159
c.schemasFS,
159-
generator.Filter(generator.AllLanguages(), c.proj.Spec.Schemas.GetLanguages()),
160+
generator.Filter(generator.AllLanguages(generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors)), c.proj.Spec.Schemas.GetLanguages()),
160161
runner.NewRealSchemaRunner(runner.WithImageConfig(c.proj.Spec.ImageConfigs)),
161162
)
162163

cmd/crossplane/function/generate_test.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
apiextv1 "github.com/crossplane/crossplane/apis/v2/apiextensions/v1"
3131

3232
v1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1"
33+
"github.com/crossplane/cli/v2/internal/config"
3334
"github.com/crossplane/cli/v2/internal/terminal"
3435
)
3536

@@ -324,7 +325,7 @@ func TestRunErrors(t *testing.T) {
324325
case "afterApply":
325326
err = c.AfterApply()
326327
case "run":
327-
err = c.Run(terminal.NewSpinnerPrinter(io.Discard, false))
328+
err = c.Run(terminal.NewSpinnerPrinter(io.Discard, false), &config.Config{})
328329
}
329330
if err == nil {
330331
t.Fatalf("expected error containing %q, got nil", tc.wantErrSubstring)

cmd/crossplane/main.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,8 @@ func main() {
126126
// at runtime.
127127
kong.BindTo(logger, (*logging.Logger)(nil)),
128128
kong.BindTo(configcmd.ConfigPath(cfgPath), (*configcmd.ConfigPath)(nil)),
129+
// Bind the loaded config so commands can read feature flags at runtime.
130+
kong.Bind(cfg),
129131
kong.Help(helpPrinter),
130132
kong.UsageOnError())
131133

cmd/crossplane/project/build.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import (
3131

3232
devv1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1"
3333
"github.com/crossplane/cli/v2/internal/async"
34+
"github.com/crossplane/cli/v2/internal/config"
3435
"github.com/crossplane/cli/v2/internal/dependency"
3536
"github.com/crossplane/cli/v2/internal/project"
3637
"github.com/crossplane/cli/v2/internal/project/functions"
@@ -83,7 +84,7 @@ func (c *buildCmd) AfterApply() error {
8384
}
8485

8586
// Run executes the build command.
86-
func (c *buildCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter) error {
87+
func (c *buildCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config) error {
8788
ctx := context.Background()
8889

8990
if c.Repository != "" {
@@ -97,7 +98,7 @@ func (c *buildCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter) error
9798
concurrency := max(1, c.MaxConcurrency)
9899

99100
schemasFS := afero.NewBasePathFs(c.projFS, c.proj.Spec.Paths.Schemas)
100-
generators := generator.Filter(generator.AllLanguages(), c.proj.Spec.Schemas.GetLanguages())
101+
generators := generator.Filter(generator.AllLanguages(generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors)), c.proj.Spec.Schemas.GetLanguages())
101102
schemaRunner := runner.NewRealSchemaRunner(runner.WithImageConfig(c.proj.Spec.ImageConfigs))
102103
schemaMgr := manager.New(schemasFS, generators, schemaRunner)
103104
cacheDir := c.CacheDir

cmd/crossplane/project/run.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ import (
4242
devv1alpha1 "github.com/crossplane/cli/v2/apis/dev/v1alpha1"
4343
"github.com/crossplane/cli/v2/cmd/crossplane/render"
4444
"github.com/crossplane/cli/v2/internal/async"
45+
"github.com/crossplane/cli/v2/internal/config"
4546
"github.com/crossplane/cli/v2/internal/dependency"
4647
"github.com/crossplane/cli/v2/internal/project"
4748
"github.com/crossplane/cli/v2/internal/project/controlplane"
@@ -132,7 +133,7 @@ func (c *runCmd) AfterApply() error {
132133
}
133134

134135
// Run executes the run command.
135-
func (c *runCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter) error { //nolint:gocyclo // Main command orchestration.
136+
func (c *runCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter, cfg *config.Config) error { //nolint:gocyclo // Main command orchestration.
136137
ctx := context.Background()
137138

138139
if c.Repository != "" {
@@ -150,7 +151,7 @@ func (c *runCmd) Run(logger logging.Logger, sp terminal.SpinnerPrinter) error {
150151
concurrency := max(1, c.MaxConcurrency)
151152

152153
schemasFS := afero.NewBasePathFs(c.projFS, c.proj.Spec.Paths.Schemas)
153-
generators := generator.Filter(generator.AllLanguages(), c.proj.Spec.Schemas.GetLanguages())
154+
generators := generator.Filter(generator.AllLanguages(generator.WithGoModelAccessors(cfg.Features.GenerateGoModelAccessors)), c.proj.Spec.Schemas.GetLanguages())
154155
schemaRunner := runner.NewRealSchemaRunner(runner.WithImageConfig(c.proj.Spec.ImageConfigs))
155156
schemaMgr := manager.New(schemasFS, generators, schemaRunner)
156157
cacheDir := c.CacheDir

internal/config/config.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,11 @@ type Config struct {
4242
type Features struct {
4343
EnableAlpha bool `json:"enableAlpha,omitempty"`
4444
DisableBeta bool `json:"disableBeta,omitempty"`
45+
46+
// GenerateGoModelAccessors enables generation of GetX/SetX accessor methods
47+
// on generated Go models. Disabled by default; opt in to use generics and
48+
// interfaces over generated resources.
49+
GenerateGoModelAccessors bool `json:"generateGoModelAccessors,omitempty"`
4550
}
4651

4752
// Load reads a Config from path. A missing file is not an error; the zero
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
/*
2+
Copyright 2026 The Crossplane Authors.
3+
4+
Licensed under the Apache License, Version 2.0 (the "License");
5+
you may not use this file except in compliance with the License.
6+
You may obtain a copy of the License at
7+
8+
http://www.apache.org/licenses/LICENSE-2.0
9+
10+
Unless required by applicable law or agreed to in writing, software
11+
distributed under the License is distributed on an "AS IS" BASIS,
12+
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
See the License for the specific language governing permissions and
14+
limitations under the License.
15+
*/
16+
17+
package generator
18+
19+
import (
20+
"go/ast"
21+
"go/format"
22+
"go/parser"
23+
"go/token"
24+
"strings"
25+
26+
"github.com/crossplane/crossplane-runtime/v2/pkg/errors"
27+
)
28+
29+
// accessorReceiver is the receiver variable name used by generated accessor
30+
// methods. A single letter cannot collide with any generated package import
31+
// alias, which are all multi-letter.
32+
const accessorReceiver = "o"
33+
34+
// addAccessors generates GetX/SetX accessor methods for every field of every
35+
// struct type declared in the given Go source. Getters return the field's
36+
// (pointer) type as-is and setters take the same type, so the generated methods
37+
// reference only types already present in the file and never require new
38+
// imports. Type aliases are skipped: their Type is not a struct literal, so they
39+
// share the underlying struct's method set for free.
40+
func addAccessors(code string) (string, error) {
41+
fset := token.NewFileSet()
42+
f, err := parser.ParseFile(fset, "", code, parser.ParseComments)
43+
if err != nil {
44+
return "", errors.Wrap(err, "failed to parse Go code for accessors")
45+
}
46+
47+
// Collect the methods that already exist on each type, so we never emit a
48+
// GetX/SetX that collides with a method oapi-codegen already generated
49+
// (e.g. GetAdditionalProperties, or union As/From/Merge helpers). A
50+
// duplicate method would make the package fail to compile.
51+
existing := collectExistingMethods(f)
52+
53+
var b strings.Builder
54+
// Walk declarations in source order so the generated output is stable.
55+
for _, decl := range f.Decls {
56+
gen, ok := decl.(*ast.GenDecl)
57+
if !ok || gen.Tok != token.TYPE {
58+
continue
59+
}
60+
for _, spec := range gen.Specs {
61+
ts, ok := spec.(*ast.TypeSpec)
62+
if !ok {
63+
continue
64+
}
65+
// Skip type aliases (`type Foo = Bar`); only generate accessors for
66+
// struct type definitions.
67+
if ts.Assign.IsValid() {
68+
continue
69+
}
70+
st, ok := ts.Type.(*ast.StructType)
71+
if !ok || st.Fields == nil {
72+
continue
73+
}
74+
writeStructAccessors(&b, fset, ts.Name.Name, st, existing[ts.Name.Name])
75+
}
76+
}
77+
78+
if b.Len() == 0 {
79+
return code, nil
80+
}
81+
82+
combined := code + "\n" + b.String()
83+
formatted, err := format.Source([]byte(combined))
84+
if err != nil {
85+
return "", errors.Wrap(err, "failed to format generated accessors")
86+
}
87+
return string(formatted), nil
88+
}
89+
90+
// collectExistingMethods returns, per receiver type name, the set of method
91+
// names already declared in the file.
92+
func collectExistingMethods(f *ast.File) map[string]map[string]bool {
93+
existing := map[string]map[string]bool{}
94+
for _, decl := range f.Decls {
95+
fn, ok := decl.(*ast.FuncDecl)
96+
if !ok || fn.Recv == nil || len(fn.Recv.List) != 1 {
97+
continue
98+
}
99+
recv := receiverTypeName(fn.Recv.List[0].Type)
100+
if recv == "" {
101+
continue
102+
}
103+
if existing[recv] == nil {
104+
existing[recv] = map[string]bool{}
105+
}
106+
existing[recv][fn.Name.Name] = true
107+
}
108+
return existing
109+
}
110+
111+
// receiverTypeName returns the bare type name of a method receiver, stripping a
112+
// leading pointer if present (e.g. `*Foo` -> `Foo`).
113+
func receiverTypeName(e ast.Expr) string {
114+
if star, ok := e.(*ast.StarExpr); ok {
115+
e = star.X
116+
}
117+
if id, ok := e.(*ast.Ident); ok {
118+
return id.Name
119+
}
120+
return ""
121+
}
122+
123+
// writeStructAccessors appends a getter and setter for each named field of the
124+
// given struct to b. Any accessor whose name already exists in skip is omitted
125+
// to avoid colliding with methods oapi-codegen already generated.
126+
func writeStructAccessors(b *strings.Builder, fset *token.FileSet, typeName string, st *ast.StructType, skip map[string]bool) {
127+
for _, field := range st.Fields.List {
128+
// Skip embedded/anonymous fields; generated models don't use them.
129+
if len(field.Names) == 0 {
130+
continue
131+
}
132+
133+
var typ strings.Builder
134+
if err := format.Node(&typ, fset, field.Type); err != nil {
135+
// format.Node only fails on malformed nodes, which cannot occur for
136+
// a node we just parsed; skip defensively rather than panic.
137+
continue
138+
}
139+
fieldType := typ.String()
140+
141+
for _, name := range field.Names {
142+
fieldName := name.Name
143+
144+
// Getter.
145+
if !skip["Get"+fieldName] {
146+
b.WriteString("\n// Get" + fieldName + " returns the " + fieldName + " field.\n")
147+
b.WriteString("func (" + accessorReceiver + " *" + typeName + ") Get" + fieldName + "() " + fieldType + " {\n")
148+
b.WriteString("\treturn " + accessorReceiver + "." + fieldName + "\n")
149+
b.WriteString("}\n")
150+
}
151+
152+
// Setter.
153+
if !skip["Set"+fieldName] {
154+
b.WriteString("\n// Set" + fieldName + " sets the " + fieldName + " field.\n")
155+
b.WriteString("func (" + accessorReceiver + " *" + typeName + ") Set" + fieldName + "(v " + fieldType + ") {\n")
156+
b.WriteString("\t" + accessorReceiver + "." + fieldName + " = v\n")
157+
b.WriteString("}\n")
158+
}
159+
}
160+
}
161+
}

0 commit comments

Comments
 (0)