Skip to content

Commit

Permalink
fix: ensure Go runtime uses the correct field names without "json" ta…
Browse files Browse the repository at this point in the history
…gs (#706)

FTL's schema requires all fields be in "lowerCamelCase", but the
go-runtime has been encoding fields with the literal field name, usually
resulting in "UpperCamelCase". This change adds a custom encoder
(because there is seemingly no existing library that will do this).

Decoding should be fine because Go's JSON decoder is case-insensitive.
  • Loading branch information
alecthomas authored Dec 6, 2023
1 parent d602c21 commit 3a15b55
Show file tree
Hide file tree
Showing 3 changed files with 163 additions and 1 deletion.
125 changes: 125 additions & 0 deletions go-runtime/encoding/encoding.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Package encoding defines the internal encoding that FTL uses to encode and
// decode messages. It is currently JSON.
package encoding

import (
"bytes"
"fmt"
"reflect"

"github.com/iancoleman/strcase"
)

func Marshal(v any) ([]byte, error) {
w := &bytes.Buffer{}
err := encodeValue(reflect.ValueOf(v), w)
return w.Bytes(), err
}

func encodeValue(v reflect.Value, w *bytes.Buffer) error {
switch v.Kind() {
case reflect.Struct:
return encodeStruct(v, w)

case reflect.Ptr:
if v.IsNil() {
w.WriteString("null")
return nil
}
return encodeValue(v.Elem(), w)

case reflect.Slice:
return encodeSlice(v, w)

case reflect.Map:
return encodeMap(v, w)

case reflect.String:
return encodeString(v, w)

case reflect.Int:
return encodeInt(v, w)

case reflect.Float64:
return encodeFloat(v, w)

case reflect.Bool:
return encodeBool(v, w)

default:
return fmt.Errorf("unsupported type: %s", v.Type())
}
}

func encodeStruct(v reflect.Value, w *bytes.Buffer) error {
w.WriteRune('{')
for i := 0; i < v.NumField(); i++ {
if i > 0 {
w.WriteRune(',')
}
field := v.Type().Field(i)
w.WriteString(`"` + strcase.ToLowerCamel(field.Name) + `":`)
if err := encodeValue(v.Field(i), w); err != nil {
return err
}
}
w.WriteRune('}')
return nil
}

func encodeSlice(v reflect.Value, w *bytes.Buffer) error {
w.WriteRune('[')
for i := 0; i < v.Len(); i++ {
if i > 0 {
w.WriteRune(',')
}
if err := encodeValue(v.Index(i), w); err != nil {
return err
}
}
w.WriteRune(']')
return nil
}

func encodeMap(v reflect.Value, w *bytes.Buffer) error {
w.WriteRune('{')
for i, key := range v.MapKeys() {
if i > 0 {
w.WriteRune(',')
}
w.WriteRune('"')
w.WriteString(key.String())
w.WriteString(`":`)
if err := encodeValue(v.MapIndex(key), w); err != nil {
return err
}
}
w.WriteRune('}')
return nil
}

func encodeBool(v reflect.Value, w *bytes.Buffer) error {
if v.Bool() {
w.WriteString("true")
} else {
w.WriteString("false")
}
return nil
}

func encodeInt(v reflect.Value, w *bytes.Buffer) error {
fmt.Fprintf(w, "%d", v.Int())
return nil
}

func encodeFloat(v reflect.Value, w *bytes.Buffer) error {
fmt.Fprintf(w, "%g", v.Float())
return nil
}

func encodeString(v reflect.Value, w *bytes.Buffer) error {
w.WriteRune('"')
fmt.Fprintf(w, "%s", v.String())
w.WriteRune('"')
return nil
}
36 changes: 36 additions & 0 deletions go-runtime/encoding/encoding_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package encoding

import (
"testing"

"github.com/alecthomas/assert/v2"
)

func TestMarshal(t *testing.T) {
tests := []struct {
name string
input any
expected string
err string
}{
{name: "FieldRenaming", input: struct{ FooBar string }{""}, expected: `{"fooBar":""}`},
{name: "String", input: struct{ String string }{"foo"}, expected: `{"string":"foo"}`},
{name: "Int", input: struct{ Int int }{42}, expected: `{"int":42}`},
{name: "Float", input: struct{ Float float64 }{42.42}, expected: `{"float":42.42}`},
{name: "Bool", input: struct{ Bool bool }{true}, expected: `{"bool":true}`},
{name: "Nil", input: struct{ Nil *int }{nil}, expected: `{"nil":null}`},
{name: "Slice", input: struct{ Slice []int }{[]int{1, 2, 3}}, expected: `{"slice":[1,2,3]}`},
{name: "Map", input: struct{ Map map[string]int }{map[string]int{"foo": 42}}, expected: `{"map":{"foo":42}}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
actual, err := Marshal(tt.input)
if tt.err != "" {
assert.EqualError(t, err, tt.err)
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.expected, string(actual))
})
}
}
3 changes: 2 additions & 1 deletion go-runtime/sdk/call.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/iancoleman/strcase"

"github.com/TBD54566975/ftl/backend/common/rpc"
"github.com/TBD54566975/ftl/go-runtime/encoding"
ftlv1 "github.com/TBD54566975/ftl/protos/xyz/block/ftl/v1"
"github.com/TBD54566975/ftl/protos/xyz/block/ftl/v1/ftlv1connect"
)
Expand All @@ -20,7 +21,7 @@ import (
func Call[Req, Resp any](ctx context.Context, verb Verb[Req, Resp], req Req) (resp Resp, err error) {
callee := ToVerbRef(verb)
client := rpc.ClientFromContext[ftlv1connect.VerbServiceClient](ctx)
reqData, err := json.Marshal(req)
reqData, err := encoding.Marshal(req)
if err != nil {
return resp, fmt.Errorf("%s: failed to marshal request: %w", callee, err)
}
Expand Down

0 comments on commit 3a15b55

Please sign in to comment.