Repository: uber-go/fx
Pull request #757 (2021-07-10 05:05:35)

Add fx.Annotate
This adds fx.Annotate, along with fx.Annotation, fx.ParamTags, and fx.ResultTags.

fx.Annotate takes an arbitrary function and annotates it with supplied fx.Annotations.

fx.ParamTags takes in a list of strings in the form of struct tags that can be used as tags to each parameter to the supplied function, and returns an fx.Annotation that can be used with fx.Annotate. 

fx.ResultTags also takes in a list of strings in the form of struct tags that can be used as tags to each result of the supplied function, and returns an fx.Annotation that can be used with fx.Annotate.

Using this, one can annotate both parameter and result to `fx.Provide`d or `Invoke`d functions.

Current implementation makes heavy use of reflection to construct a struct type at runtime, both for the parameters and results depending on which tags are specified. fx.Annotate uses reflection to generate a function wrapper that invokes the provided function with an a struct that contains all the arguments with annotated parameters, and wraps the result inside a struct with the specified result tags.

We may decide in the future to replace the implementation similar to how fx.Annotated is implemented, but for now this should still work.

Refs GO-549.


codecov[bot]: # [Codecov](https://codecov.io/gh/uber-go/fx/pull/757?src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go) Report
> Merging [#757](https://codecov.io/gh/uber-go/fx/pull/757?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go) (6d3bad7) into [master](https://codecov.io/gh/uber-go/fx/commit/734db16873c487e7f28bd5f08c89190961c5814c?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go) (734db16) will **decrease** coverage by `1.51%`.
> The diff coverage is `84.33%`.

[![Impacted file tree graph](https://codecov.io/gh/uber-go/fx/pull/757/graphs/tree.svg?width=650&height=150&src=pr&token=PYY9PSfaC6&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go)](https://codecov.io/gh/uber-go/fx/pull/757?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go)

```diff
@@            Coverage Diff             @@
##           master     #757      +/-   ##
==========================================
- Coverage   98.06%   96.55%   -1.52%     
==========================================
  Files          24       24              
  Lines         671      754      +83     
==========================================
+ Hits          658      728      +70     
- Misses         10       20      +10     
- Partials        3        6       +3     
```


| [Impacted Files](https://codecov.io/gh/uber-go/fx/pull/757?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go) | Coverage Δ | |
|---|---|---|
| [annotated.go](https://codecov.io/gh/uber-go/fx/pull/757/diff?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go#diff-YW5ub3RhdGVkLmdv) | `85.71% <84.33%> (-14.29%)` | :arrow_down: |

------

[Continue to review full report at Codecov](https://codecov.io/gh/uber-go/fx/pull/757?src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go).
> **Legend** - [Click here to learn more](https://docs.codecov.io/docs/codecov-delta?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go)
> `Δ = absolute <relative> (impact)`, `ø = not affected`, `? = missing data`
> Powered by [Codecov](https://codecov.io/gh/uber-go/fx/pull/757?src=pr&el=footer&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go). Last update [734db16...6d3bad7](https://codecov.io/gh/uber-go/fx/pull/757?src=pr&el=lastupdated&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go). Read the [comment docs](https://docs.codecov.io/docs/pull-request-comments?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=uber-go).


Patch:
diff --git a/annotated.go b/annotated.go
index 5df0cdb..13da01d 100644
--- a/annotated.go
+++ b/annotated.go
@@ -1,4 +1,4 @@
-// Copyright (c) 2020 Uber Technologies, Inc.
+// Copyright (c) 2020-2021 Uber Technologies, Inc.
 //
 // Permission is hereby granted, free of charge, to any person obtaining a copy
 // of this software and associated documentation files (the "Software"), to deal
@@ -21,10 +21,13 @@
 package fx
 
 import (
+	"errors"
 	"fmt"
+	"reflect"
 	"strings"
 
 	"go.uber.org/fx/internal/fxreflect"
+	"go.uber.org/multierr"
 )
 
 // Annotated annotates a constructor provided to Fx with additional options.
@@ -89,3 +92,301 @@ func (a Annotated) String() string {
 	}
 	return fmt.Sprintf("fx.Annotated{%v}", strings.Join(fields, ", "))
 }
+
+// annotations is used for building out the info needed to generate struct
+// with tags using reflection.
+type annotations struct {
+	fType reflect.Type
+
+	Ins  []reflect.Type
+	Outs []reflect.Type
+
+	annotatedIn  bool
+	annotatedOut bool
+}
+
+func newAnnotations(fType reflect.Type) annotations {
+	numIn := fType.NumIn()
+	numOut := fType.NumOut()
+	ins := make([]reflect.Type, numIn)
+	outs := make([]reflect.Type, numOut)
+
+	for i := 0; i < numIn; i++ {
+		ins[i] = fType.In(i)
+	}
+	for i := 0; i < numOut; i++ {
+		outs[i] = fType.Out(i)
+	}
+	return annotations{
+		fType: fType,
+		Ins:   ins,
+		Outs:  outs,
+	}
+}
+
+// Annotation can be passed to Annotate(f interface{}, anns ...Annotation)
+// for annotating the parameter and result types of a function.
+type Annotation interface {
+	apply(*annotations) error
+}
+
+var (
+	_typeOfError reflect.Type = reflect.TypeOf((*error)(nil)).Elem()
+	_nilError                 = reflect.Zero(_typeOfError)
+)
+
+// annotationError is a wrapper for an error that was encountered while
+// applying annotation to a function. It contains the specific error
+// that it encountered as well as the target interface that was attempted
+// to be annotated.
+type annotationError struct {
+	target interface{}
+	err    error
+}
+
+func (e *annotationError) Error() string {
+	return e.err.Error()
+}
+
+type paramTagsAnnotation struct {
+	tags []string
+}
+
+var _ paramTagsAnnotation = paramTagsAnnotation{}
+
+// Given func(T1, T2, T3, ..., TN), this generates a type roughly
+// equivalent to,
+//
+//   struct {
+//     fx.In
+//
+//     Field1 T1 `$tags[0]`
+//     Field2 T2 `$tags[1]`
+//     ...
+//     FieldN TN `$tags[N-1]`
+//   }
+//
+// If there has already been a ParamTag that was applied, this
+// will return an error.
+
+func (pt paramTagsAnnotation) apply(ann *annotations) error {
+	if ann.annotatedIn {
+		return errors.New("cannot apply more than one line of ParamTags")
+	}
+	ann.annotatedIn = true
+	fType := ann.fType
+
+	annotatedParams := []reflect.StructField{{
+		Name:      "In",
+		Type:      reflect.TypeOf(In{}),
+		Anonymous: true,
+	}}
+	for i := 0; i < fType.NumIn(); i++ {
+		structField := reflect.StructField{
+			Name: fmt.Sprintf("Field%d", i),
+			Type: fType.In(i),
+		}
+		if i < len(pt.tags) {
+			structField.Tag = reflect.StructTag(pt.tags[i])
+		}
+		annotatedParams = append(annotatedParams, structField)
+	}
+
+	ann.Ins = []reflect.Type{reflect.StructOf(annotatedParams)}
+	return nil
+}
+
+// ParamTags is an Annotation that annotates the parameter(s) of a function.
+// When multiple tags are specified, each tag is mapped to the corresponding
+// positional parameter.
+func ParamTags(tags ...string) Annotation {
+	return paramTagsAnnotation{tags}
+}
+
+type resultTagsAnnotation struct {
+	tags []string
+}
+
+var _ resultTagsAnnotation = resultTagsAnnotation{}
+
+// Given func(T1, T2, T3, ..., TN), this generates a type roughly
+// equivalent to,
+//
+//   struct {
+//     fx.Out
+//
+//     Field1 T1 `$tags[0]`
+//     Field2 T2 `$tags[1]`
+//     ...
+//     FieldN TN `$tags[N-1]`
+//   }
+//
+// If there has already been a ResultTag that was applied, this
+// will return an error.
+func (rt resultTagsAnnotation) apply(ann *annotations) error {
+	if ann.annotatedOut {
+		return errors.New("cannot apply more than one line of ResultTags")
+	}
+	ann.annotatedOut = true
+	fType := ann.fType
+
+	annotatedResult := []reflect.StructField{{
+		Name:      "Out",
+		Type:      reflect.TypeOf(Out{}),
+		Anonymous: true,
+	}}
+
+	returnsError := false
+	for i := 0; i < fType.NumOut(); i++ {
+		if fType.Out(i) == _typeOfError {
+			returnsError = true
+			continue
+		}
+		structField := reflect.StructField{
+			Name: fmt.Sprintf("Field%d", i),
+			Type: fType.Out(i),
+		}
+		if i < len(rt.tags) {
+			structField.Tag = reflect.StructTag(rt.tags[i])
+		}
+		annotatedResult = append(annotatedResult, structField)
+	}
+	ann.Outs = []reflect.Type{reflect.StructOf(annotatedResult)}
+	if returnsError {
+		ann.Outs = append(ann.Outs, _typeOfError)
+	}
+	return nil
+}
+
+// ResultTags is an Annotation that annotates the result(s) of a function.
+// When multiple tags are specified, each tag is mapped to the corresponding
+// positional result.
+func ResultTags(tags ...string) Annotation {
+	return resultTagsAnnotation{tags}
+}
+
+// Annotate lets you annotate a function's paramter and returns with tags
+// without you having to declare separate struct definitions for them.
+//
+// For example,
+//   func NewGateway(ro, rw *db.Conn) *Gateway { ... }
+//   fx.Provide(
+//     fx.Annotate(
+//       NewGateway,
+//       fx.ParamTags(`name:"ro" optional:"true"`, `name:"rw"`),
+//       fx.ResultTags(`name:"foo"`),
+//     ),
+//   )
+//
+// Is equivalent to,
+//
+//  type params struct {
+//    fx.In
+//
+//    RO *db.Conn `name:"ro" optional:"true"`
+//    RW *db.Conn `name:"rw"`
+//  }
+//
+//  type result struct {
+//    fx.Out
+//
+//    GW *Gateway `name:"foo"`
+//   }
+//
+//   fx.Provide(func(p params) result {
+//     return result{GW: NewGateway(p.RO, p.RW)}
+//   })
+//
+// Using the same annotation multiple times is invalid.
+// For example, the following will fail with an error:
+//
+//  fx.Provide(
+//    fx.Annotate(
+//      NewGateWay,
+//      fx.ParamTags(`name:"ro" optional:"true"`),
+//      fx.ParamTags(`name:"rw"), // ERROR: ParamTags was already used above
+//      fx.ResultTags(`name:"foo"`)
+//    )
+//  )
+//
+// is considered an invalid usage and will not apply any of the
+// Annotations to NewGateway.
+//
+// If more tags are given than the number of parameters/results, only
+// the ones up to the number of parameters/results will be applied.
+func Annotate(f interface{}, anns ...Annotation) interface{} {
+	fVal := reflect.ValueOf(f)
+	fType := fVal.Type()
+	numIn := fType.NumIn()
+	numOut := fType.NumOut()
+	annotations := newAnnotations(fType)
+
+	for _, ann := range anns {
+		if e := ann.apply(&annotations); e != nil {
+			return annotationError{
+				target: f,
+				err:    e,
+			}
+		}
+	}
+
+	ins := annotations.Ins
+	outs := annotations.Outs
+
+	newF := func(args []reflect.Value) []reflect.Value {
+		var fParams, fResults []reflect.Value
+		if annotations.annotatedIn {
+			fParams = make([]reflect.Value, numIn)
+			params := args[0]
+			for i := 0; i < numIn; i++ {
+				fParams[i] = params.Field(i + 1)
+			}
+		} else {
+			fParams = args
+		}
+
+		// Call the wrapped function.
+		fResults = fVal.Call(fParams)
+
+		if !annotations.annotatedOut {
+			return fResults
+		}
+
+		var errValue reflect.Value
+		returnsError := len(outs) > 1
+
+		// wrap the result in an annotated struct
+		results := reflect.New(outs[0]).Elem()
+
+		// aggregate the errors from the annotated function
+		// into one error.
+		if returnsError {
+			errValue = reflect.New(outs[1]).Elem()
+		}
+
+		var errResults error
+		for i := 0; i < numOut; i++ {
+			if fResults[i].Type() == _typeOfError {
+				if err, _ := fResults[i].Interface().(error); err != nil {
+					errResults = multierr.Append(errResults, err)
+				}
+				continue
+			}
+			results.FieldByName(fmt.Sprintf("Field%d",
+				i)).Set(fResults[i])
+		}
+		if returnsError {
+			if errResults != nil {
+				errValue = reflect.ValueOf(errResults).Elem()
+				return []reflect.Value{results, errValue}
+			}
+			// error is nil. Return nil error Value.
+			return []reflect.Value{results, _nilError}
+		}
+		return []reflect.Value{results}
+	}
+
+	annotatedFuncType := reflect.FuncOf(ins, outs, false)
+	annotatedFunc := reflect.MakeFunc(annotatedFuncType, newF)
+	return annotatedFunc.Interface()
+}
diff --git a/app.go b/app.go
index e7d6b27..ecc75e2 100644
--- a/app.go
+++ b/app.go
@@ -796,6 +796,12 @@ func (app *App) provide(p provide) {
 		app.log.LogEvent(ev)
 	}()
 
+	if annError, ok := constructor.(annotationError); ok {
+		app.err = fmt.Errorf("encountered error while applying annotation using fx.Annotate to %s: %+v",
+			fxreflect.FuncName(annError.target), annError.err)
+		return
+	}
+
 	if ann, ok := constructor.(Annotated); ok {
 		switch {
 		case len(ann.Group) > 0 && len(ann.Name) > 0:


Test patch:
diff --git a/annotated_test.go b/annotated_test.go
index 2b3158a..b7b1571 100644
--- a/annotated_test.go
+++ b/annotated_test.go
@@ -176,3 +176,186 @@ func TestAnnotatedString(t *testing.T) {
 		})
 	}
 }
+
+func TestAnnotate(t *testing.T) {
+	type a struct{}
+	type b struct{ a *a }
+	type c struct{ b *b }
+	newA := func() *a { return &a{} }
+	newB := func(a *a) *b {
+		return &b{a}
+	}
+	newC := func(b *b) *c {
+		return &c{b}
+	}
+
+	t.Run("Provide with optional", func(t *testing.T) {
+		app := fxtest.New(t,
+			fx.Provide(
+				fx.Annotate(newB, fx.ParamTags(`name:"a" optional:"true"`)),
+			),
+			fx.Invoke(newC),
+		)
+		defer app.RequireStart().RequireStop()
+		require.NoError(t, app.Err())
+	})
+
+	t.Run("Provide with many annotated params", func(t *testing.T) {
+		app := fxtest.New(t,
+			fx.Provide(
+				fx.Annotate(newB, fx.ParamTags(`optional:"true"`)),
+				fx.Annotate(func(a *a, b *b) interface{} { return nil },
+					fx.ParamTags(`name:"a" optional:"true"`, `name:"b"`),
+					fx.ResultTags(`name:"nil"`),
+				),
+			),
+			fx.Invoke(newC),
+		)
+		defer app.RequireStart().RequireStop()
+		require.NoError(t, app.Err())
+	})
+
+	t.Run("Invoke with optional", func(t *testing.T) {
+		app := fx.New(
+			fx.Invoke(
+				fx.Annotate(newB, fx.ParamTags(`optional:"true"`)),
+			),
+		)
+		err := app.Err()
+		require.NoError(t, err)
+	})
+
+	t.Run("Invoke with a missing dependency", func(t *testing.T) {
+		app := fx.New(
+			fx.Invoke(
+				fx.Annotate(newB, fx.ParamTags(`name:"a"`)),
+			),
+		)
+		err := app.Err()
+		require.Error(t, err)
+		assert.Contains(t, err.Error(), `missing dependencies`)
+		assert.Contains(t, err.Error(), `missing type: *fx_test.a[name="a"]`)
+	})
+
+	t.Run("provide with annotated results", func(t *testing.T) {
+		app := fxtest.New(t,
+			fx.Provide(
+				fx.Annotate(func() *a {
+					return &a{}
+				}, fx.ResultTags(`name:"firstA"`)),
+				fx.Annotate(func() *a {
+					return &a{}
+				}, fx.ResultTags(`name:"secondA"`)),
+				fx.Annotate(func() *a {
+					return &a{}
+				}, fx.ResultTags(`name:"thirdA"`)),
+				fx.Annotate(func(a1 *a, a2 *a, a3 *a) *b {
+					return &b{a1}
+				}, fx.ParamTags(
+					`name:"firstA"`,
+					`name:"secondA"`,
+					`name:"thirdA"`,
+				)),
+			),
+			fx.Invoke(newC),
+		)
+
+		require.NoError(t, app.Err())
+		defer app.RequireStart().RequireStop()
+	})
+
+	t.Run("provide with missing annotated results", func(t *testing.T) {
+		app := fx.New(
+			fx.Provide(
+				fx.Annotate(func() *a {
+					return &a{}
+				}, fx.ResultTags(`name:"firstA"`)),
+				fx.Annotate(func() *a {
+					return &a{}
+				}, fx.ResultTags(`name:"secondA"`)),
+				fx.Annotate(func() *a {
+					return &a{}
+				}, fx.ResultTags(`name:"fourthA"`)),
+			),
+			fx.Invoke(
+				fx.Annotate(func(a1 *a, a2 *a, a3 *a) *b {
+					return &b{a1}
+				}, fx.ParamTags(
+					`name:"firstA"`,
+					`name:"secondA"`,
+					`name:"thirdA"`)),
+			),
+		)
+		err := app.Err()
+		require.Error(t, err)
+		assert.Contains(t, err.Error(), `missing type: *fx_test.a[name="thirdA"]`)
+	})
+
+	t.Run("provide with annotated results with error", func(t *testing.T) {
+		app := fxtest.New(t,
+			fx.Provide(
+				//lint:ignore ST1008 we want to test error in the middle.
+				fx.Annotate(func() (*a, error, *a) {
+					return &a{}, nil, &a{}
+				}, fx.ResultTags(`name:"firstA"`, ``, `name:"secondA"`)),
+				fx.Annotate(func() (*a, error) {
+					return &a{}, nil
+				}, fx.ResultTags(`name:"thirdA"`)),
+			),
+			fx.Invoke(fx.Annotate(func(a1 *a, a2 *a, a3 *a) *b {
+				return &b{a2}
+			}, fx.ParamTags(`name:"firstA"`, `name:"secondA"`, `name:"thirdA"`))))
+
+		require.NoError(t, app.Err())
+		defer app.RequireStart().RequireStop()
+	})
+
+	t.Run("specify more ParamTags than Params", func(t *testing.T) {
+		app := fxtest.New(t,
+			fx.Provide(
+				// This should just leave newA as it is.
+				fx.Annotate(newA, fx.ParamTags(`name:"something"`)),
+			),
+			fx.Invoke(newB),
+		)
+
+		err := app.Err()
+		require.NoError(t, err)
+		defer app.RequireStart().RequireStop()
+	})
+
+	t.Run("specify two ParamTags", func(t *testing.T) {
+		app := fx.New(
+			fx.Provide(
+				// This should just leave newA as it is.
+				fx.Annotate(
+					newA,
+					fx.ParamTags(`name:"something"`),
+					fx.ParamTags(`name:"anotherThing"`),
+				),
+			),
+			fx.Invoke(newB),
+		)
+		err := app.Err()
+		require.Error(t, err)
+		assert.Contains(t, err.Error(), "encountered error while applying annotation using fx.Annotate to go.uber.org/fx_test.TestAnnotate.func1(): cannot apply more than one line of ParamTags")
+	})
+
+	t.Run("specify two ResultTags", func(t *testing.T) {
+		app := fx.New(
+			fx.Provide(
+				// This should just leave newA as it is.
+				fx.Annotate(
+					newA,
+					fx.ResultTags(`name:"A"`),
+					fx.ResultTags(`name:"AA"`),
+				),
+			),
+			fx.Invoke(newB),
+		)
+
+		err := app.Err()
+		require.Error(t, err)
+		assert.Contains(t, err.Error(), "encountered error while applying annotation using fx.Annotate to go.uber.org/fx_test.TestAnnotate.func1(): cannot apply more than one line of ResultTags")
+	})
+}