Skip to content

Commit

Permalink
feat(go): register exported properties as callbacks (#4104)
Browse files Browse the repository at this point in the history
When passing arbitrary structs through an `interface{}` type, the JS value had no properties or methods, leading to it having an empty object JSON representation.

In order to improve the situation, discover exported properties of such structs, and register them as property callbacks, more closely matching the behavior of JS in the same configuration.

The callbacks use the same naming transformation as the standard JSON serialization mechanism in go: using the `json` tag if present with the same semantics as `encoding/json`, and fall back to the raw field name otherwise (with no case conversion applied).

Related: cdk8s-team/cdk8s#1326



---

By submitting this pull request, I confirm that my contribution is made under the terms of the [Apache 2.0 license].

[Apache 2.0 license]: https://www.apache.org/licenses/LICENSE-2.0
  • Loading branch information
RomainMuller committed May 16, 2023
1 parent eaaf3ee commit fa921ec
Show file tree
Hide file tree
Showing 12 changed files with 695 additions and 164 deletions.
18 changes: 18 additions & 0 deletions packages/@jsii/go-runtime-test/project/callbacks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package tests
import (
"testing"

"github.com/aws/jsii-runtime-go"
calc "github.com/aws/jsii/jsii-calc/go/jsiicalc/v3"
)

Expand All @@ -18,10 +19,27 @@ func TestPureInterfacesCanBeUsedTransparently(t *testing.T) {
}
}

func TestPropertyAccessThroughAny(t *testing.T) {
any := &ABC{
PropA: "Hello",
ProbB: "World",
}
calc.AnyPropertyAccess_MutateProperties(any, jsii.String("a"), jsii.String("b"), jsii.String("result"))
if *any.PropC != "Hello+World" {
t.Errorf("Expected Hello+World; actual %v", any.PropC)
}
}

type StructReturningDelegate struct {
expected *calc.StructB
}

func (o *StructReturningDelegate) ReturnStruct() *calc.StructB {
return o.expected
}

type ABC struct {
PropA string `json:"a"`
ProbB string `json:"b"`
PropC *string `json:"result,omitempty"`
}
106 changes: 102 additions & 4 deletions packages/@jsii/go-runtime/jsii-runtime-go/internal/kernel/callbacks.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package kernel
import (
"fmt"
"reflect"
"strings"

"github.com/aws/jsii-runtime-go/internal/api"
)
Expand Down Expand Up @@ -78,9 +79,45 @@ func (g *getCallback) handle(cookie string) (retval reflect.Value, err error) {
client := GetClient()

receiver := reflect.ValueOf(client.GetObject(g.ObjRef))
method := receiver.MethodByName(cookie)

return client.invoke(method, nil)
if strings.HasPrefix(cookie, ".") {
// Ready to catch an error if the access panics...
defer func() {
if r := recover(); r != nil {
if err == nil {
var ok bool
if err, ok = r.(error); !ok {
err = fmt.Errorf("%v", r)
}
} else {
// This is not expected - so we panic!
panic(r)
}
}
}()

// Need to access the underlying struct...
receiver = receiver.Elem()
retval = receiver.FieldByName(cookie[1:])

if retval.IsZero() {
// Omit zero-values if a json tag instructs so...
field, _ := receiver.Type().FieldByName(cookie[1:])
if tag := field.Tag.Get("json"); tag != "" {
for _, attr := range strings.Split(tag, ",")[1:] {
if attr == "omitempty" {
retval = reflect.ValueOf(nil)
break
}
}
}
}

return
} else {
method := receiver.MethodByName(cookie)
return client.invoke(method, nil)
}
}

type setCallback struct {
Expand All @@ -93,9 +130,70 @@ func (s *setCallback) handle(cookie string) (retval reflect.Value, err error) {
client := GetClient()

receiver := reflect.ValueOf(client.GetObject(s.ObjRef))
method := receiver.MethodByName(fmt.Sprintf("Set%v", cookie))
if strings.HasPrefix(cookie, ".") {
// Ready to catch an error if the access panics...
defer func() {
if r := recover(); r != nil {
if err == nil {
var ok bool
if err, ok = r.(error); !ok {
err = fmt.Errorf("%v", r)
}
} else {
// This is not expected - so we panic!
panic(r)
}
}
}()

// Need to access the underlying struct...
receiver = receiver.Elem()
field := receiver.FieldByName(cookie[1:])
meta, _ := receiver.Type().FieldByName(cookie[1:])

field.Set(convert(reflect.ValueOf(s.Value), meta.Type))
// Both retval & err are set to zero values here...
return
} else {
method := receiver.MethodByName(fmt.Sprintf("Set%v", cookie))
return client.invoke(method, []interface{}{s.Value})
}
}

func convert(value reflect.Value, typ reflect.Type) reflect.Value {
retry:
vt := value.Type()

if vt.AssignableTo(typ) {
return value
}
if value.CanConvert(typ) {
return value.Convert(typ)
}

if typ.Kind() == reflect.Ptr {
switch value.Kind() {
case reflect.String:
str := value.String()
value = reflect.ValueOf(&str)
case reflect.Bool:
bool := value.Bool()
value = reflect.ValueOf(&bool)
case reflect.Int:
int := value.Int()
value = reflect.ValueOf(&int)
case reflect.Float64:
float := value.Float()
value = reflect.ValueOf(&float)
default:
iface := value.Interface()
value = reflect.ValueOf(&iface)
}
goto retry
}

return client.invoke(method, []interface{}{s.Value})
// Unsure what to do... let default behavior happen...
return value
}

func (c *Client) invoke(method reflect.Value, args []interface{}) (retval reflect.Value, err error) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package kernel

import (
"fmt"
"reflect"
"strings"

"github.com/aws/jsii-runtime-go/internal/api"
)
Expand All @@ -18,6 +20,14 @@ func (c *Client) ManageObject(v reflect.Value) (ref api.ObjectRef, err error) {
}
interfaces, overrides := c.Types().DiscoverImplementation(vt)

found := make(map[string]bool)
for _, override := range overrides {
if prop, ok := override.(*api.PropertyOverride); ok {
found[prop.JsiiProperty] = true
}
}
overrides = appendExportedProperties(vt, overrides, found)

var resp CreateResponse
resp, err = c.Create(CreateProps{
FQN: objectFQN,
Expand All @@ -33,3 +43,49 @@ func (c *Client) ManageObject(v reflect.Value) (ref api.ObjectRef, err error) {

return
}

func appendExportedProperties(vt reflect.Type, overrides []api.Override, found map[string]bool) []api.Override {
if vt.Kind() == reflect.Ptr {
vt = vt.Elem()
}

if vt.Kind() == reflect.Struct {
for idx := 0; idx < vt.NumField(); idx++ {
field := vt.Field(idx)
// Unexported fields are not relevant here...
if !field.IsExported() {
continue
}

// Anonymous fields are embed, we traverse them for fields, too...
if field.Anonymous {
overrides = appendExportedProperties(field.Type, overrides, found)
continue
}

jsonName := field.Tag.Get("json")
if jsonName == "-" {
// Explicit omit via `json:"-"`
continue
} else if jsonName != "" {
// There could be attributes after the field name (e.g. `json:"foo,omitempty"`)
jsonName = strings.Split(jsonName, ",")[0]
}
// The default behavior is to use the field name as-is in JSON.
if jsonName == "" {
jsonName = field.Name
}

if !found[jsonName] {
overrides = append(overrides, &api.PropertyOverride{
JsiiProperty: jsonName,
// Using the "." prefix to signify this isn't actually a getter, just raw field access.
GoGetter: fmt.Sprintf(".%s", field.Name),
})
found[jsonName] = true
}
}
}

return overrides
}
21 changes: 21 additions & 0 deletions packages/jsii-calc/lib/compliance.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3141,3 +3141,24 @@ export class PromiseNothing {
return PromiseNothing.promiseIt();
}
}

export class AnyPropertyAccess {
/**
* Sets obj[resultProp] to `${obj[propA]}+${obj[propB]}`.
*
* @param obj the receiver object.
* @param propA the first property to read.
* @param propB the second property to read.
* @param resultProp the property to write into.
*/
public static mutateProperties(
obj: any,
propA: string,
propB: string,
resultProp: string,
) {
obj[resultProp] = `${obj[propA]}+${obj[propB]}`;
}

private constructor() {}
}
68 changes: 67 additions & 1 deletion packages/jsii-calc/test/assembly.jsii
Original file line number Diff line number Diff line change
Expand Up @@ -1538,6 +1538,72 @@
"name": "AnonymousImplementationProvider",
"symbolId": "lib/compliance:AnonymousImplementationProvider"
},
"jsii-calc.AnyPropertyAccess": {
"assembly": "jsii-calc",
"docs": {
"stability": "stable"
},
"fqn": "jsii-calc.AnyPropertyAccess",
"kind": "class",
"locationInModule": {
"filename": "lib/compliance.ts",
"line": 3145
},
"methods": [
{
"docs": {
"stability": "stable",
"summary": "Sets obj[resultProp] to `${obj[propA]}+${obj[propB]}`."
},
"locationInModule": {
"filename": "lib/compliance.ts",
"line": 3154
},
"name": "mutateProperties",
"parameters": [
{
"docs": {
"summary": "the receiver object."
},
"name": "obj",
"type": {
"primitive": "any"
}
},
{
"docs": {
"summary": "the first property to read."
},
"name": "propA",
"type": {
"primitive": "string"
}
},
{
"docs": {
"summary": "the second property to read."
},
"name": "propB",
"type": {
"primitive": "string"
}
},
{
"docs": {
"summary": "the property to write into."
},
"name": "resultProp",
"type": {
"primitive": "string"
}
}
],
"static": true
}
],
"name": "AnyPropertyAccess",
"symbolId": "lib/compliance:AnyPropertyAccess"
},
"jsii-calc.AsyncVirtualMethods": {
"assembly": "jsii-calc",
"docs": {
Expand Down Expand Up @@ -18843,5 +18909,5 @@
}
},
"version": "3.20.120",
"fingerprint": "EH7xszNdCh9PCFUZ8Foi7g2CPhdrKeZm8CQaUCNv4GQ="
"fingerprint": "kOCIHox3N0mzJsbC3zUF0dELGRsdq5jP57dDIOu3fDE="
}

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit fa921ec

Please sign in to comment.