Repository: alecthomas/kong
Pull request #493 (2025-01-28 21:42:08)

Support hooks on `embed:""` fields
(I realize that I opened this without discussing the feature first,
so if you don't think this feature makes sense, feel free to close this PR.
I've tried to outline the reasoning below.)

---

Relates to 840220c (#90)

This change adds support for hooks to be called on fields
that are tagged with `embed:""`.

### Use case

If a command has several subcommands,
many (but not all) of which need the same external resource,
this allows defining the flag-level inputs for that resource centrally,
allowing subcommands to declare their dependency on these resources
and use them in their `Run` in a highly composible manner.

For example, imagine:

```go
type githubClientProvider struct {
    Token string `name:"github-token" env:"GITHUB_TOKEN"`
    URL   string `name:"github-url" env:"GITHUB_URL"`
}

func (g *githubClientProvider) BeforeApply(kctx *kong.Context) error {
  return kctx.BindToProvider(func() (*github.Client, error) {
    return github.NewClient(...), nil
  })
}
```

Then, any command that needs GitHub client will add this field,
any other resource providers it needs,
and add parameters to its `Run` method to accept those resources:

```go
type listUsersCmd struct {
    GitHub githubClientProvider `embed:""`
    S3     s3ClientProvider     `embed:""`
}

func (l *listUsersCmd) Run(gh *github.Client, s3 *s3.Client) error {
    ...
}
```

### Alternatives

It is possible to do the same today if the `*Provider` struct above
is actually a Go embed instead of a Kong embed, *and* it is exported.

```
type GitHubClientProvider struct{ ... }

type listUsersCmd struct {
    GithubClientProvider
    S3ClientProvider
}
```

The difference is whether the struct defining the flags
is required to be exported or not.



Patch:
diff --git a/callbacks.go b/callbacks.go
index c1fac81..e541f5a 100644
--- a/callbacks.go
+++ b/callbacks.go
@@ -80,7 +80,19 @@ func getMethods(value reflect.Value, name string) []reflect.Value {
 		for i := 0; i < value.NumField(); i++ {
 			field := value.Field(i)
 			fieldType := t.Field(i)
-			if fieldType.IsExported() && fieldType.Anonymous {
+			if !fieldType.IsExported() {
+				continue
+			}
+
+			// Hooks on exported embedded fields should be called.
+			if fieldType.Anonymous {
+				receivers = append(receivers, field)
+				continue
+			}
+
+			// Hooks on exported fields that are not exported,
+			// but are tagged with `embed:""` should be called.
+			if _, ok := fieldType.Tag.Lookup("embed"); ok {
 				receivers = append(receivers, field)
 			}
 		}


Test patch:
diff --git a/kong_test.go b/kong_test.go
index a7c03b2..874b90b 100644
--- a/kong_test.go
+++ b/kong_test.go
@@ -2413,9 +2413,19 @@ func (e *EmbeddedCallback) AfterApply() error {
 	return nil
 }
 
+type taggedEmbeddedCallback struct {
+	Tagged bool
+}
+
+func (e *taggedEmbeddedCallback) AfterApply() error {
+	e.Tagged = true
+	return nil
+}
+
 type EmbeddedRoot struct {
 	EmbeddedCallback
-	Root bool
+	Tagged taggedEmbeddedCallback `embed:""`
+	Root   bool
 }
 
 func (e *EmbeddedRoot) AfterApply() error {
@@ -2432,6 +2442,9 @@ func TestEmbeddedCallbacks(t *testing.T) {
 		EmbeddedCallback: EmbeddedCallback{
 			Embedded: true,
 		},
+		Tagged: taggedEmbeddedCallback{
+			Tagged: true,
+		},
 		Root: true,
 	}
 	assert.Equal(t, expected, actual)