Skip to content
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

Merged
merged 2 commits into from
May 7, 2024
Merged

Conversation

deniseli
Copy link
Contributor

@deniseli deniseli commented May 6, 2024

This PR:

  • Rips out the existing textMarshaler and jsonMarshaler usage from encoding.go. We may want to add those back for Allow FTL to support external types by utilising type widening #1296 down the road, but we will need to be thoughtful about how we do that. Removing it for now keeps the logic much more predictable.
  • Moves the (un)marshaling logic for ftl.Option out of option.go and into encoding.go.
  • Special-cases both time.Time (the only stdlib type we currently support) and ftl.Option. Also json.RawMessage for just encoding to preserve the existing omitempty behavior.
  • Fixes some existing issues where the Pointer unmarshaling wasn't actually working correctly
  • [eww] Adds a rather grotesque alternative to Peek() in isNextTokenNull() because json Decoder does not support Peek.
  • [eww] Makes the ftl.Option struct fields public so that they are settable by 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.

@deniseli deniseli requested a review from a team as a code owner May 6, 2024 22:43
@deniseli deniseli requested review from worstell and removed request for a team May 6, 2024 22:43
@alecthomas alecthomas mentioned this pull request May 6, 2024
}

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())
Copy link
Contributor

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?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nice catch - fixed!

// 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
Copy link
Contributor

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!

Copy link
Contributor Author

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 (
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice!

progress

make encode work for option

time

partial

progress

progress

catch up

fix pointer parsing

rip out json and text marshalers and make time parsing work

cleanup

add rawmessage support

fix todo

final fixes

broken test

lint

cleanup

lots of style improvements
@deniseli deniseli merged commit 053f922 into main May 7, 2024
10 checks passed
@deniseli deniseli deleted the dli/encoding branch May 7, 2024 01:42
return err
}
data, err = json.Marshal(string(data))
case t == reflect.TypeFor[json.RawMessage]():
Copy link
Collaborator

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.

Copy link
Contributor Author

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 {
Copy link
Collaborator

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?

Copy link
Contributor Author

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

Copy link
Contributor Author

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.

Copy link
Collaborator

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.

Copy link
Contributor

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

Copy link
Contributor

@worstell worstell May 7, 2024

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

Copy link
Contributor

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))
Copy link
Collaborator

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.

Copy link
Contributor Author

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) {
Copy link
Collaborator

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.

Copy link
Contributor Author

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?

Copy link
Collaborator

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
Copy link
Collaborator

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.

Copy link
Contributor Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Disallow json.(Un)Marshaler/encoding.Text(Un)Marshaler on user-defined types
3 participants