-
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
Conversation
go-runtime/encoding/encoding.go
Outdated
} | ||
|
||
switch v.Kind() { | ||
case reflect.Struct: | ||
return decodeStruct(d, v) | ||
|
||
case reflect.Ptr: | ||
if token, err := d.Token(); err != nil { | ||
allBytes, err := io.ReadAll(d.Buffered()) |
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.
maybe move this into isNextTokenNull
?
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 catch - fixed!
go-runtime/encoding/encoding.go
Outdated
// isNextTokenNull implements a cheap/dirty version of `Peek()`, which json.Decoder does | ||
// not support. This function is used to conditionally execute logic for handling nulls | ||
// without popping the next token, so that downstream code for the non-null case is still | ||
// able to pop it. Remember to pop the next token (d.Token()) inside your conditional |
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.
nit: i wonder if we should also handle consuming this token in isNextTokenNull
(and maybe rename/change signature as needed) so callers don't need to manage this
i don't feel strongly!
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.
ooooo callbacks! Done! Looks way better now :D
|
||
"github.com/TBD54566975/ftl/backend/schema/strcase" | ||
) | ||
|
||
var ( |
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!
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 comment
The reason will be displayed to describe this comment to others. Learn more.
Are we using json.RawMessage
somewhere internally? I ask because I'm assuming we should only be supporting types explicitly supported by FTL.
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.
We use it in ingress: https://github.com/TBD54566975/ftl/blob/main/backend/controller/ingress/response.go#L19
Without this, the omitempty
behavior doesn't happen, and this Ingress test fails because "error": ""
gets included in the response body, which also breaks the status code.
That's a really good point... should I update encoding to handle the omitempty
next, so that this special case for json.RawMessage
can be removed?
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 comment
The reason will be displayed to describe this comment to others. Learn more.
Mmmm... this doesn't seem like an improvement over keeping UnmarshalJSON()
on ftl.Option
does it? What's the rationale for doing that?
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.
Thanks for catching that... we originally did this to break a circular dependency between the encoding
and ftl
packages, but since call
's dependency prevents that anyway, this is now pointless. cc @worstell so we can discuss
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.
One option is to move the logic back to option.go
, undo the broken encapsulation, and then special case Option
explicitly in the switch
in encodeValue
.
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.
That sounds like a reasonable approach.
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.
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 comment
The reason will be displayed to describe this comment to others. Learn more.
a potential solution with callbacks—adding an interface like this in encoding.go
:
type OptionMarshaler interface {
Marshal(marshalSome func ... ) ([]byte, error)
Unmarshal(unmarshalSome func ... ) error
}
implemented in optional.go
(this just subs the ftlencoding
calls from our current JSON overrides in option.go
with the callbacks), e.g.:
func (o Option[T]) Marshal(marshalSome func(v any) ([]byte, error)) ([]byte, error) {
if o.ok {
return marshalSome(o.value) // was `ftlencoding.Marshal(o.value)`
}
return []byte("null"), nil
}
func (o *Option[T]) Unmarshal(data []byte, unmarshalSome func(data []byte, v any) error) error {
if string(data) == "null" {
o.ok = false
return nil
}
if err := unmarshalSome(data, &o.value); err != nil { // was `ftlencoding.Unmarshal(data, &o.value)`
return err
}
o.ok = true
return nil
}
then encoding.go
uses OptionMarshaler.Marshal/Unmarshal
if the value implements OptionMarshaler
. the callbacks it would pass would be the original Marshal/Unmarshal
calls
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.
this is implemented in #1431
// 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 comment
The reason will be displayed to describe this comment to others. Learn more.
The additional .(time.Time)
is redundant here (and below), it's just getting cast back to an any
anyway.
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.
Whoops, thanks for catching that! Fixed in #1424
|
||
// 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 comment
The 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 json.Decoder
that supports peeking, rather than doing this.
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.
It is very very gross. We spent a bit of time working on wrapping json.Decoder
, but the issue is that we can't just wrap Token()
(returning the last Peek
value when defined instead of what's at the top of the buffer) and add Peek
because d.Decode
will pop tokens off the underlying buffer, and if we've popped a token into the wrapper object, the underlying Decoder's Decode()
function won't use that. So unless there's an alternative I haven't thought of, we'd effectively have to reimplement the whole json.Decoder
. Do you have any ideas?
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.
Ah right, that makes sense.
I think we want to ditch json.Decoder
altogether, but that's not appropriate for this PR.
} | ||
|
||
// An Option type is a type that can contain a value or nothing. | ||
type Option[T any] struct { | ||
value T | ||
ok bool | ||
Val T |
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.
We really shouldn't be doing this, it violates encapsulation completely and it's a public type exposed to end users.
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.
I added a proposal for fixing this here: #1417 (comment)
What do you think? Pending agreement, I'll fix forward using that strategy.
Reverts #1417 Broke PFI release. See Slack thread: https://sq-tbd.slack.com/archives/C04PEQERFM0/p1715064621920319
This PR:
ftl.Option
out ofoption.go
and intoencoding.go
.time.Time
(the only stdlib type we currently support) andftl.Option
. Alsojson.RawMessage
for just encoding to preserve the existingomitempty
behavior.Peek()
inisNextTokenNull()
because json Decoder does not support Peek.encoding.go
.Suggestions welcome for both counts of [eww] above :)
Fixes #1247.
Addresses most of #1262, except
omitempty
is only working for json.RawMessage for now.