diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 259037b1..475584db 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -41,7 +41,7 @@ jobs: test-go: name: Test with Go runs-on: ubuntu-latest - timeout-minutes: 5 + timeout-minutes: 15 strategy: matrix: go-version: ["1.22", "1.23"] diff --git a/cm/CHANGELOG.md b/cm/CHANGELOG.md index 7974b70e..5dcb3dd2 100644 --- a/cm/CHANGELOG.md +++ b/cm/CHANGELOG.md @@ -7,6 +7,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Added - Initial support for Component Model [async](https://github.com/WebAssembly/component-model/blob/main/design/mvp/Async.md) types `stream`, `future`, and `error-context`. +- Initial support for JSON serialization of WIT types, starting with `list` and `record`. ## [v0.1.0] — 2024-12-14 diff --git a/cm/list.go b/cm/list.go index 5c896d04..0a171dbd 100644 --- a/cm/list.go +++ b/cm/list.go @@ -1,6 +1,10 @@ package cm -import "unsafe" +import ( + "bytes" + "encoding/json" + "unsafe" +) // List represents a Component Model list. // The binary representation of list is similar to a Go slice minus the cap field. @@ -58,3 +62,57 @@ func (l list[T]) Data() *T { func (l list[T]) Len() uintptr { return l.len } + +// MarshalJSON implements json.Marshaler. +func (l list[T]) MarshalJSON() ([]byte, error) { + if l.len == 0 { + return []byte("[]"), nil + } + + s := l.Slice() + var zero T + if unsafe.Sizeof(zero) == 1 { + // The default Go json.Encoder will marshal []byte as base64. + // We override that behavior so all int types have the same serialization format. + // []uint8{1,2,3} -> [1,2,3] + // []uint32{1,2,3} -> [1,2,3] + return json.Marshal(sliceOf(s)) + } + return json.Marshal(s) +} + +type slice[T any] []entry[T] + +func sliceOf[S ~[]E, E any](s S) slice[E] { + return *(*slice[E])(unsafe.Pointer(&s)) +} + +type entry[T any] [1]T + +func (v entry[T]) MarshalJSON() ([]byte, error) { + return json.Marshal(v[0]) +} + +// UnmarshalJSON implements json.Unmarshaler. +func (l *list[T]) UnmarshalJSON(data []byte) error { + if bytes.Equal(data, nullLiteral) { + return nil + } + + var s []T + err := json.Unmarshal(data, &s) + if err != nil { + return err + } + + l.data = unsafe.SliceData([]T(s)) + l.len = uintptr(len(s)) + + return nil +} + +// nullLiteral is the JSON representation of a null literal. +// By convention, to approximate the behavior of Unmarshal itself, +// Unmarshalers implement UnmarshalJSON([]byte("null")) as a no-op. +// See https://pkg.go.dev/encoding/json#Unmarshaler for more information. +var nullLiteral = []byte("null") diff --git a/cm/list_test.go b/cm/list_test.go index 34d674ac..80101203 100644 --- a/cm/list_test.go +++ b/cm/list_test.go @@ -2,6 +2,12 @@ package cm import ( "bytes" + "encoding/json" + "errors" + "math" + "reflect" + "runtime" + "strings" "testing" ) @@ -14,3 +20,314 @@ func TestListMethods(t *testing.T) { t.Errorf("got (%s) != want (%s)", string(got), string(want)) } } + +func TestListMarshalJSON(t *testing.T) { + tests := []struct { + name string + w listTester + }{ + { + name: "encode error", + w: listMarshalTest(``, []errorEntry{{}}, true), + }, + { + name: "f32 nan", + w: listMarshalTest(``, []float32{float32(math.NaN())}, true), + }, + { + name: "f64 nan", + w: listMarshalTest(``, []float64{float64(math.NaN())}, true), + }, + { + name: "nil", + w: listMarshalTest[string](`[]`, nil, false), + }, + { + name: "empty", + w: listMarshalTest(`[]`, []string{}, false), + }, + { + name: "bool", + w: listMarshalTest(`[true,false]`, []bool{true, false}, false), + }, + { + name: "string", + w: listMarshalTest(`["one","two","three"]`, []string{"one", "two", "three"}, false), + }, + { + name: "char", + w: listMarshalTest(`[104,105,127942]`, []rune{'h', 'i', '🏆'}, false), + }, + { + name: "s8", + w: listMarshalTest(`[123,-123,127]`, []int8{123, -123, math.MaxInt8}, false), + }, + { + name: "u8", + w: listMarshalTest(`[123,0,255]`, []uint8{123, 0, math.MaxUint8}, false), + }, + { + name: "s16", + w: listMarshalTest(`[123,-123,32767]`, []int16{123, -123, math.MaxInt16}, false), + }, + { + name: "u16", + w: listMarshalTest(`[123,0,65535]`, []uint16{123, 0, math.MaxUint16}, false), + }, + { + name: "s32", + w: listMarshalTest(`[123,-123,2147483647]`, []int32{123, -123, math.MaxInt32}, false), + }, + { + name: "u32", + w: listMarshalTest(`[123,0,4294967295]`, []uint32{123, 0, math.MaxUint32}, false), + }, + { + name: "s64", + w: listMarshalTest(`[123,-123,9223372036854775807]`, []int64{123, -123, math.MaxInt64}, false), + }, + { + name: "u64", + w: listMarshalTest(`[123,0,18446744073709551615]`, []uint64{123, 0, math.MaxUint64}, false), + }, + { + name: "f32", + w: listMarshalTest(`[1.01,2,3.4028235e+38]`, []float32{1.01, 2, math.MaxFloat32}, false), + }, + { + name: "f64", + w: listMarshalTest(`[1.01,2,1.7976931348623157e+308]`, []float64{1.01, 2, math.MaxFloat64}, false), + }, + { + name: "struct", + w: listMarshalTest(`[{"name":"joe","age":10},{"name":"jane","age":20}]`, []testEntry{{Name: "joe", Age: 10}, {Name: "jane", Age: 20}}, false), + }, + { + name: "list", + w: listMarshalTest(`[["one","two","three"],["four","five","six"]]`, []List[string]{ToList([]string{"one", "two", "three"}), ToList([]string{"four", "five", "six"})}, false), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // NOTE(lxf): skip marshal errors in tinygo as it uses 'defer' + // needs tinygo 0.35-dev + if tt.w.WantErr() && runtime.Compiler == "tinygo" && strings.Contains(runtime.GOARCH, "wasm") { + return + } + + data, err := json.Marshal(tt.w.List()) + if err != nil { + if tt.w.WantErr() { + return + } + + t.Error(err) + return + } + + if tt.w.WantErr() { + t.Errorf("expected error, but got none. got (%s)", string(data)) + return + } + + if got, want := data, tt.w.JSON(); !bytes.Equal(got, want) { + t.Errorf("got (%v) != want (%v)", string(got), string(want)) + } + }) + } +} + +func TestListUnmarshalJSON(t *testing.T) { + tests := []struct { + name string + w listTester + }{ + { + name: "decode error", + w: listUnmarshalTest(`["joe"]`, []errorEntry{}, true), + }, + { + name: "invalid json", + w: listUnmarshalTest(`[joe]`, []string{}, true), + }, + { + name: "incompatible type", + w: listUnmarshalTest(`[123,456]`, []string{}, true), + }, + { + name: "incompatible bool", + w: listUnmarshalTest(`["true","false"]`, []bool{true, false}, true), + }, + { + name: "incompatible s32", + w: listUnmarshalTest(`["123","-123","2147483647"]`, []int32{}, true), + }, + { + name: "incompatible u32", + w: listUnmarshalTest(`["123","0","4294967295"]`, []uint32{}, true), + }, + + { + name: "null", + w: listUnmarshalTest[string](`null`, nil, false), + }, + { + name: "empty", + w: listUnmarshalTest(`[]`, []string{}, false), + }, + { + name: "bool", + w: listUnmarshalTest(`[true,false]`, []bool{true, false}, false), + }, + { + name: "string", + w: listUnmarshalTest(`["one","two","three"]`, []string{"one", "two", "three"}, false), + }, + { + name: "char", + w: listUnmarshalTest(`[104,105,127942]`, []rune{'h', 'i', '🏆'}, false), + }, + { + name: "s8", + w: listUnmarshalTest(`[123,-123,127]`, []int8{123, -123, math.MaxInt8}, false), + }, + { + name: "u8", + w: listUnmarshalTest(`[123,0,255]`, []uint8{123, 0, math.MaxUint8}, false), + }, + { + name: "s16", + w: listUnmarshalTest(`[123,-123,32767]`, []int16{123, -123, math.MaxInt16}, false), + }, + { + name: "u16", + w: listUnmarshalTest(`[123,0,65535]`, []uint16{123, 0, math.MaxUint16}, false), + }, + { + name: "s32", + w: listUnmarshalTest(`[123,-123,2147483647]`, []int32{123, -123, math.MaxInt32}, false), + }, + { + name: "u32", + w: listUnmarshalTest(`[123,0,4294967295]`, []uint32{123, 0, math.MaxUint32}, false), + }, + { + name: "s64", + w: listUnmarshalTest(`[123,-123,9223372036854775807]`, []int64{123, -123, math.MaxInt64}, false), + }, + { + name: "u64", + w: listUnmarshalTest(`[123,0,18446744073709551615]`, []uint64{123, 0, math.MaxUint64}, false), + }, + { + name: "f32", + w: listUnmarshalTest(`[1.01,2,3.4028235e+38]`, []float32{1.01, 2, math.MaxFloat32}, false), + }, + { + name: "f32 nan", + w: listUnmarshalTest(`[null]`, []float32{0}, false), + }, + { + name: "f64", + w: listUnmarshalTest(`[1.01,2,1.7976931348623157e+308]`, []float64{1.01, 2, math.MaxFloat64}, false), + }, + { + name: "f64 nan", + w: listUnmarshalTest(`[null]`, []float64{0}, false), + }, + { + name: "struct", + w: listUnmarshalTest(`[{"name":"joe","age":10},{"name":"jane","age":20}]`, []testEntry{{Name: "joe", Age: 10}, {Name: "jane", Age: 20}}, false), + }, + { + name: "list", + w: listUnmarshalTest(`[["one","two","three"],["four","five","six"]]`, []List[string]{ToList([]string{"one", "two", "three"}), ToList([]string{"four", "five", "six"})}, false), + }, + // tuple, result, option, and variant needs json implementation + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := json.Unmarshal(tt.w.JSON(), tt.w.List()) + if err != nil { + if tt.w.WantErr() { + return + } + + t.Error(err) + return + } + + if tt.w.WantErr() { + t.Errorf("expected error, but got none. got (%v)", tt.w.Slice()) + return + } + + if got, want := tt.w.Slice(), tt.w.WantSlice(); !reflect.DeepEqual(got, want) { + t.Errorf("got (%v) != want (%v)", got, want) + } + }) + } +} + +type listTester interface { + List() any + WantSlice() any + Slice() any + WantErr() bool + JSON() []byte +} + +type listWrapper[T comparable] struct { + json string + list List[T] + slice []T + wantErr bool +} + +func (w *listWrapper[T]) WantErr() bool { + return w.wantErr +} + +func (w *listWrapper[T]) List() any { + return &w.list +} + +func (w *listWrapper[T]) Slice() any { + return w.list.Slice() +} + +func (w *listWrapper[T]) WantSlice() any { + return w.slice +} + +func (w *listWrapper[T]) JSON() []byte { + return []byte(w.json) +} + +func listMarshalTest[T comparable](json string, want []T, wantErr bool) *listWrapper[T] { + return &listWrapper[T]{json: json, list: ToList(want), wantErr: wantErr} +} + +func listUnmarshalTest[T comparable](json string, want []T, wantErr bool) *listWrapper[T] { + return &listWrapper[T]{json: json, slice: want, wantErr: wantErr} +} + +type testEntry struct { + Name string `json:"name"` + Age int `json:"age"` +} + +type errorEntry struct { + Name string `json:"name"` + Age int `json:"age"` +} + +func (errorEntry) MarshalJSON() ([]byte, error) { + return nil, errors.New("MarshalJSON") +} + +func (*errorEntry) UnmarshalJSON(_ []byte) error { + return errors.New("UnmarshalJSON") +} diff --git a/cm/result_test.go b/cm/result_test.go index dc8f7330..759c03ea 100644 --- a/cm/result_test.go +++ b/cm/result_test.go @@ -1,7 +1,6 @@ package cm import ( - "fmt" "runtime" "testing" "unsafe" @@ -177,9 +176,9 @@ func TestIssue95String(t *testing.T) { want := "hello" res := OK[stringResult](want) got := *res.OK() - fmt.Printf("unsafe.Sizeof(res): %d\n", unsafe.Sizeof(res)) - fmt.Printf("got: %v (%d) want: %v (%d)\n", - unsafe.StringData(got), len(got), unsafe.StringData(want), len(want)) + // fmt.Printf("unsafe.Sizeof(res): %d\n", unsafe.Sizeof(res)) + // fmt.Printf("got: %v (%d) want: %v (%d)\n", + // unsafe.StringData(got), len(got), unsafe.StringData(want), len(want)) if got != want { t.Errorf("*res.OK(): %v, expected %v", got, want) } @@ -196,8 +195,8 @@ func TestIssue95Uint64(t *testing.T) { want := uint64(123) res := OK[uint64Result](want) got := *res.OK() - fmt.Printf("unsafe.Sizeof(res): %d\n", unsafe.Sizeof(res)) - fmt.Printf("got: %v want: %v\n", got, want) + // fmt.Printf("unsafe.Sizeof(res): %d\n", unsafe.Sizeof(res)) + // fmt.Printf("got: %v want: %v\n", got, want) if got != want { t.Errorf("*res.OK(): %v, expected %v", got, want) } @@ -221,8 +220,8 @@ func TestIssue95Struct(t *testing.T) { want := stringStruct{s: "hello"} res := OK[structResult](want) got := *res.OK() - fmt.Printf("unsafe.Sizeof(res): %d\n", unsafe.Sizeof(res)) - fmt.Printf("got: %v want: %v\n", got, want) + // fmt.Printf("unsafe.Sizeof(res): %d\n", unsafe.Sizeof(res)) + // fmt.Printf("got: %v want: %v\n", got, want) if got != want { t.Errorf("*res.OK(): %v, expected %v", got, want) } @@ -233,8 +232,8 @@ func TestIssue95BoolInt64(t *testing.T) { want := int64(1234567890) res := Err[boolInt64Result](1234567890) got := *res.Err() - fmt.Printf("unsafe.Sizeof(res): %d\n", unsafe.Sizeof(res)) - fmt.Printf("got: %v want: %v\n", got, want) + // fmt.Printf("unsafe.Sizeof(res): %d\n", unsafe.Sizeof(res)) + // fmt.Printf("got: %v want: %v\n", got, want) if got != want { t.Errorf("*res.OK(): %v, expected %v", got, want) }