-
Notifications
You must be signed in to change notification settings - Fork 8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
fix: fix several go-runtime JSON encoding issues #1417
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -4,22 +4,18 @@ package encoding | |
|
||
import ( | ||
"bytes" | ||
"encoding" | ||
"encoding/base64" | ||
"encoding/json" | ||
"fmt" | ||
"io" | ||
"reflect" | ||
"strings" | ||
"time" | ||
"unicode" | ||
|
||
"github.com/TBD54566975/ftl/backend/schema/strcase" | ||
) | ||
|
||
var ( | ||
textMarshaler = reflect.TypeOf((*encoding.TextMarshaler)(nil)).Elem() | ||
textUnmarshaler = reflect.TypeOf((*encoding.TextUnmarshaler)(nil)).Elem() | ||
jsonMarshaler = reflect.TypeOf((*json.Marshaler)(nil)).Elem() | ||
jsonUnmarshaler = reflect.TypeOf((*json.Unmarshaler)(nil)).Elem() | ||
) | ||
|
||
func Marshal(v any) ([]byte, error) { | ||
w := &bytes.Buffer{} | ||
err := encodeValue(reflect.ValueOf(v), w) | ||
|
@@ -31,37 +27,29 @@ func encodeValue(v reflect.Value, w *bytes.Buffer) error { | |
w.WriteString("null") | ||
return nil | ||
} | ||
|
||
t := v.Type() | ||
switch { | ||
case t.Kind() == reflect.Ptr && t.Elem().Implements(jsonMarshaler): | ||
v = v.Elem() | ||
fallthrough | ||
|
||
case t.Implements(jsonMarshaler): | ||
enc := v.Interface().(json.Marshaler) //nolint:forcetypeassert | ||
data, err := enc.MarshalJSON() | ||
// Special-cased types | ||
switch { | ||
case t == reflect.TypeFor[time.Time](): | ||
data, err := json.Marshal(v.Interface().(time.Time)) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The additional There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Whoops, thanks for catching that! Fixed in #1424 |
||
if err != nil { | ||
return err | ||
} | ||
w.Write(data) | ||
return nil | ||
|
||
case t.Kind() == reflect.Ptr && t.Elem().Implements(textMarshaler): | ||
v = v.Elem() | ||
fallthrough | ||
|
||
case t.Implements(textMarshaler): | ||
enc := v.Interface().(encoding.TextMarshaler) //nolint:forcetypeassert | ||
data, err := enc.MarshalText() | ||
if err != nil { | ||
return err | ||
} | ||
data, err = json.Marshal(string(data)) | ||
case t == reflect.TypeFor[json.RawMessage](): | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Are we using There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We use it in ingress: https://github.com/TBD54566975/ftl/blob/main/backend/controller/ingress/response.go#L19 Without this, the That's a really good point... should I update encoding to handle the |
||
data, err := json.Marshal(v.Interface().(json.RawMessage)) | ||
if err != nil { | ||
return err | ||
} | ||
w.Write(data) | ||
return nil | ||
|
||
case isOption(v.Type()): | ||
return encodeOption(v, w) | ||
} | ||
|
||
switch v.Kind() { | ||
|
@@ -107,6 +95,24 @@ func encodeValue(v reflect.Value, w *bytes.Buffer) error { | |
} | ||
} | ||
|
||
var ftlOptionTypePath = "github.com/TBD54566975/ftl/go-runtime/ftl.Option" | ||
|
||
func isOption(t reflect.Type) bool { | ||
return strings.HasPrefix(t.PkgPath()+"."+t.Name(), ftlOptionTypePath) | ||
} | ||
|
||
func encodeOption(v reflect.Value, w *bytes.Buffer) error { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Mmmm... this doesn't seem like an improvement over keeping There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Thanks for catching that... we originally did this to break a circular dependency between the There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. One option is to move the logic back to There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That sounds like a reasonable approach. There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. ah actually we needed to move the implementation into encoding.go to prepare for a following change that will pass context through to the custom encoder. the json marshaling overrides for option must call into the custom encoder to marshal/unmarshal the optional’s underlying value, and we don’t have a context to pass from these callers There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. a potential solution with callbacks—adding an interface like this in
implemented in
then There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. this is implemented in #1431 |
||
if v.NumField() != 2 { | ||
return fmt.Errorf("value cannot have type ftl.Option since it has %d fields rather than 2: %v", v.NumField(), v) | ||
} | ||
optionOk := v.Field(1).Bool() | ||
if !optionOk { | ||
w.WriteString("null") | ||
return nil | ||
} | ||
return encodeValue(v.Field(0), w) | ||
} | ||
|
||
func encodeStruct(v reflect.Value, w *bytes.Buffer) error { | ||
w.WriteRune('{') | ||
afterFirst := false | ||
|
@@ -213,50 +219,34 @@ func Unmarshal(data []byte, v any) error { | |
|
||
func decodeValue(d *json.Decoder, v reflect.Value) error { | ||
if !v.CanSet() { | ||
return fmt.Errorf("cannot set value") | ||
allBytes, _ := io.ReadAll(d.Buffered()) | ||
return fmt.Errorf("cannot set value: %v", string(allBytes)) | ||
} | ||
|
||
t := v.Type() | ||
switch { | ||
case v.Kind() != reflect.Ptr && v.CanAddr() && v.Addr().Type().Implements(jsonUnmarshaler): | ||
v = v.Addr() | ||
fallthrough | ||
|
||
case t.Implements(jsonUnmarshaler): | ||
if v.IsNil() { | ||
v.Set(reflect.New(t.Elem())) | ||
} | ||
o := v.Interface() | ||
return d.Decode(&o) | ||
|
||
case v.Kind() != reflect.Ptr && v.CanAddr() && v.Addr().Type().Implements(textUnmarshaler): | ||
v = v.Addr() | ||
fallthrough | ||
|
||
case t.Implements(textUnmarshaler): | ||
if v.IsNil() { | ||
v.Set(reflect.New(t.Elem())) | ||
} | ||
dec := v.Interface().(encoding.TextUnmarshaler) //nolint:forcetypeassert | ||
var s string | ||
if err := d.Decode(&s); err != nil { | ||
return err | ||
} | ||
return dec.UnmarshalText([]byte(s)) | ||
// Special-case types | ||
switch { | ||
case t == reflect.TypeFor[time.Time](): | ||
return d.Decode(v.Addr().Interface()) | ||
case isOption(v.Type()): | ||
return decodeOption(d, v) | ||
} | ||
|
||
switch v.Kind() { | ||
case reflect.Struct: | ||
return decodeStruct(d, v) | ||
|
||
case reflect.Ptr: | ||
if token, err := d.Token(); err != nil { | ||
return err | ||
} else if token == nil { | ||
return handleIfNextTokenIsNull(d, func(d *json.Decoder) error { | ||
v.Set(reflect.Zero(v.Type())) | ||
return nil | ||
} | ||
return decodeValue(d, v.Elem()) | ||
}, func(d *json.Decoder) error { | ||
if v.IsNil() { | ||
v.Set(reflect.New(v.Type().Elem())) | ||
} | ||
return decodeValue(d, v.Elem()) | ||
}) | ||
|
||
case reflect.Slice: | ||
if v.Type().Elem().Kind() == reflect.Uint8 { | ||
|
@@ -278,6 +268,63 @@ func decodeValue(d *json.Decoder, v reflect.Value) error { | |
} | ||
} | ||
|
||
func handleIfNextTokenIsNull(d *json.Decoder, ifNullFn func(*json.Decoder) error, elseFn func(*json.Decoder) error) error { | ||
isNull, err := isNextTokenNull(d) | ||
if err != nil { | ||
return err | ||
} | ||
if isNull { | ||
err = ifNullFn(d) | ||
if err != nil { | ||
return err | ||
} | ||
// Consume the null token | ||
_, err := d.Token() | ||
if err != nil { | ||
return err | ||
} | ||
return nil | ||
} | ||
return elseFn(d) | ||
} | ||
|
||
// isNextTokenNull implements a cheap/dirty version of `Peek()`, which json.Decoder does | ||
// not support. | ||
func isNextTokenNull(d *json.Decoder) (bool, error) { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This feels pretty hacky. I'd be tempted to write a thin layer over There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is very very gross. We spent a bit of time working on wrapping There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Ah right, that makes sense. I think we want to ditch |
||
s, err := io.ReadAll(d.Buffered()) | ||
if err != nil { | ||
return false, err | ||
} | ||
if len(s) == 0 { | ||
return false, fmt.Errorf("cannot check emptystring for token \"null\"") | ||
} | ||
if s[0] != ':' { | ||
return false, fmt.Errorf("cannot check emptystring for token \"null\"") | ||
} | ||
i := 1 | ||
for len(s) > i && unicode.IsSpace(rune(s[i])) { | ||
i++ | ||
} | ||
if len(s) < i+4 { | ||
return false, nil | ||
} | ||
return string(s[i:i+4]) == "null", nil | ||
} | ||
|
||
func decodeOption(d *json.Decoder, v reflect.Value) error { | ||
return handleIfNextTokenIsNull(d, func(d *json.Decoder) error { | ||
v.FieldByName("Okay").SetBool(false) | ||
return nil | ||
}, func(d *json.Decoder) error { | ||
err := decodeValue(d, v.FieldByName("Val")) | ||
if err != nil { | ||
return err | ||
} | ||
v.FieldByName("Okay").SetBool(true) | ||
return nil | ||
}) | ||
} | ||
|
||
func decodeStruct(d *json.Decoder, v reflect.Value) error { | ||
if err := expectDelim(d, '{'); err != nil { | ||
return err | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Nice!