Commit 841f107

mo khan <mo@mokhan.ca>
2022-10-22 20:02:15
feat: add package to serialize/deserialize json
1 parent 4789b7d
.github/workflows/ci.yml
@@ -11,5 +11,5 @@ jobs:
     - uses: actions/checkout@v3
     - uses: actions/setup-go@v3
       with:
-        go-version: 1.18
+        go-version: 1.19
     - run: go test -v ./...
pkg/serde/http.go
@@ -0,0 +1,15 @@
+package serde
+
+import (
+	"net/http"
+)
+
+func FromHTTP[T any](r *http.Request) (T, error) {
+	return From[T](r.Body, MediaTypeFor(r.Header.Get("Content-Type")))
+}
+
+func ToHTTP[T any](w http.ResponseWriter, r *http.Request, item T) error {
+	mediaType := MediaTypeFor(r.Header.Get("Accept"))
+	w.Header().Set("Content-Type", string(mediaType))
+	return To[T](w, item, mediaType)
+}
pkg/serde/http_test.go
@@ -0,0 +1,110 @@
+package serde
+
+import (
+	"net/http/httptest"
+	"strings"
+	"testing"
+
+	"github.com/google/jsonapi"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestFromHTTP(t *testing.T) {
+	type input struct {
+		ID   string `json:"id" jsonapi:"primary,inputs"`
+		Name string `json:"name" jsonapi:"attr,name"`
+	}
+
+	t.Run("parses a JSON request body by default", func(t *testing.T) {
+		body := strings.NewReader(`{"id":"1","name": "Faux"}`)
+		r := httptest.NewRequest("GET", "/", body)
+
+		item, err := FromHTTP[input](r)
+
+		require.NoError(t, err)
+		assert.NotZero(t, item)
+		assert.Equal(t, "1", item.ID)
+		assert.Equal(t, "Faux", item.Name)
+	})
+
+	t.Run("parses the request body as JSON", func(t *testing.T) {
+		body := strings.NewReader(`{"id":"1","name": "Faux"}`)
+		r := httptest.NewRequest("GET", "/", body)
+		r.Header.Set("Content-Type", "application/json")
+
+		item, err := FromHTTP[input](r)
+
+		require.NoError(t, err)
+		assert.NotZero(t, item)
+		assert.Equal(t, "1", item.ID)
+		assert.Equal(t, "Faux", item.Name)
+	})
+
+	t.Run("parses a JSON API request body", func(t *testing.T) {
+		body := strings.NewReader(`{"data":{"type":"inputs","id":"1","attributes":{"name": "Faux"}}}`)
+		r := httptest.NewRequest("GET", "/", body)
+		r.Header.Set("Content-Type", jsonapi.MediaType)
+
+		item, err := FromHTTP[input](r)
+
+		require.NoError(t, err)
+		assert.NotZero(t, item)
+		assert.Equal(t, "1", item.ID)
+		assert.Equal(t, "Faux", item.Name)
+	})
+}
+
+func TestToHTTP(t *testing.T) {
+	type output struct {
+		ID   string `json:"id" jsonapi:"primary,inputs"`
+		Name string `json:"name" jsonapi:"attr,name"`
+	}
+
+	t.Run("serializes the data as JSON by default", func(t *testing.T) {
+		r := httptest.NewRequest("GET", "/", nil)
+		w := httptest.NewRecorder()
+
+		require.NoError(t, ToHTTP[*output](w, r, &output{
+			ID:   "2",
+			Name: "Dave East",
+		}))
+
+		result, err := From[output](w.Body, JSON)
+		require.NoError(t, err)
+		assert.Equal(t, "2", result.ID)
+		assert.Equal(t, "Dave East", result.Name)
+	})
+
+	t.Run("serializes the data as JSON when specified in the request Accept header", func(t *testing.T) {
+		r := httptest.NewRequest("GET", "/", nil)
+		r.Header.Set("Accept", "application/json")
+		w := httptest.NewRecorder()
+
+		require.NoError(t, ToHTTP[*output](w, r, &output{
+			ID:   "2",
+			Name: "Dave East",
+		}))
+
+		result, err := From[output](w.Body, JSON)
+		require.NoError(t, err)
+		assert.Equal(t, "2", result.ID)
+		assert.Equal(t, "Dave East", result.Name)
+	})
+
+	t.Run("serializes the data as JSON API when specified in the request Accept header", func(t *testing.T) {
+		r := httptest.NewRequest("GET", "/", nil)
+		r.Header.Set("Accept", jsonapi.MediaType)
+		w := httptest.NewRecorder()
+
+		require.NoError(t, ToHTTP[*output](w, r, &output{
+			ID:   "2",
+			Name: "Dave East",
+		}))
+
+		result, err := From[output](w.Body, JSONAPI)
+		require.NoError(t, err)
+		assert.Equal(t, "2", result.ID)
+		assert.Equal(t, "Dave East", result.Name)
+	})
+}
pkg/serde/io.go
@@ -0,0 +1,23 @@
+package serde
+
+import (
+	"io"
+)
+
+func From[T any](r io.Reader, mediaType MediaType) (T, error) {
+	if mediaType == JSONAPI {
+		return FromJSONAPI[T](r)
+	}
+	return FromJSON[T](r)
+}
+
+func To[T any](w io.Writer, item T, mediaType MediaType) error {
+	switch mediaType {
+	case JSONAPI:
+		return ToJSONAPI[T](w, item)
+	case Text:
+		return ToPlain[T](w, item)
+	default:
+		return ToJSON[T](w, item)
+	}
+}
pkg/serde/io_test.go
@@ -0,0 +1,213 @@
+package serde
+
+import (
+	"bytes"
+	"strings"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+func TestFrom(t *testing.T) {
+	type Example struct {
+		Key   string `json:"key" jsonapi:"primary,examples"`
+		Value string `json:"value" jsonapi:"attr,value"`
+	}
+
+	t.Run("parses a single item from JSON data", func(t *testing.T) {
+		body := strings.NewReader(`{"key":"my-key","value":"my-value"}`)
+
+		result, err := From[Example](body, JSON)
+
+		require.NoError(t, err)
+		assert.Equal(t, "my-key", result.Key)
+		assert.Equal(t, "my-value", result.Value)
+	})
+
+	t.Run("parses a single *item from JSON data", func(t *testing.T) {
+		body := strings.NewReader(`{"key":"my-key","value":"my-value"}`)
+
+		result, err := From[*Example](body, JSON)
+
+		require.NoError(t, err)
+		assert.Equal(t, "my-key", result.Key)
+		assert.Equal(t, "my-value", result.Value)
+	})
+
+	t.Run("parses a slices of items from JSON data", func(t *testing.T) {
+		body := strings.NewReader(`[{"key":"my-key","value":"my-value"}]`)
+
+		results, err := From[[]Example](body, JSON)
+
+		require.NoError(t, err)
+		require.Equal(t, 1, len(results))
+		assert.Equal(t, "my-key", results[0].Key)
+		assert.Equal(t, "my-value", results[0].Value)
+	})
+
+	t.Run("parses a slices of *items from JSON data", func(t *testing.T) {
+		body := strings.NewReader(`[{"key":"my-key","value":"my-value"}]`)
+
+		results, err := From[[]*Example](body, JSON)
+
+		require.NoError(t, err)
+		require.Equal(t, 1, len(results))
+		assert.Equal(t, "my-key", results[0].Key)
+		assert.Equal(t, "my-value", results[0].Value)
+	})
+
+	t.Run("parses a single item from JSON API data", func(t *testing.T) {
+		body := strings.NewReader(`{"data":{"type":"examples","id":"my-key","attributes":{"value":"my-value"}}}`)
+
+		result, err := From[Example](body, JSONAPI)
+
+		require.NoError(t, err)
+		assert.Equal(t, "my-key", result.Key)
+		assert.Equal(t, "my-value", result.Value)
+	})
+
+	t.Run("parses a single *item from JSON API data", func(t *testing.T) {
+		body := strings.NewReader(`{"data":{"type":"examples","id":"my-key","attributes":{"value":"my-value"}}}`)
+
+		result, err := From[*Example](body, JSONAPI)
+
+		require.NoError(t, err)
+		assert.Equal(t, "my-key", result.Key)
+		assert.Equal(t, "my-value", result.Value)
+	})
+
+	t.Run("parses a slice of items from JSON API data", func(t *testing.T) {
+		t.Skip()
+		body := strings.NewReader(`{"data":[{"type":"examples","id":"my-key","attributes":{"value":"my-value"}}]}`)
+
+		results, err := From[[]Example](body, JSONAPI)
+
+		require.NoError(t, err)
+		require.Equal(t, 1, len(results))
+		assert.Equal(t, "my-key", results[0].Key)
+		assert.Equal(t, "my-value", results[0].Value)
+	})
+
+	t.Run("parses a slice of *items from JSON API data", func(t *testing.T) {
+		body := strings.NewReader(`{"data":[{"type":"examples","id":"my-key","attributes":{"value":"my-value"}}]}`)
+
+		results, err := From[[]*Example](body, JSONAPI)
+
+		require.NoError(t, err)
+		require.Equal(t, 1, len(results))
+		assert.Equal(t, "my-key", results[0].Key)
+		assert.Equal(t, "my-value", results[0].Value)
+	})
+}
+
+func TestTo(t *testing.T) {
+	type Example struct {
+		Key   string `json:"key" jsonapi:"primary,examples"`
+		Value string `json:"value" jsonapi:"attr,value"`
+	}
+
+	t.Run("serializes an item to JSON", func(t *testing.T) {
+		w := bytes.NewBuffer(nil)
+		require.NoError(t, To[Example](w, Example{
+			Key:   "my-key",
+			Value: "my-value",
+		}, JSON))
+		expected := `{
+  "key": "my-key",
+  "value": "my-value"
+}
+`
+		assert.Equal(t, expected, w.String())
+	})
+
+	t.Run("serializes an *item to JSON", func(t *testing.T) {
+		w := bytes.NewBuffer(nil)
+		require.NoError(t, To[*Example](w, &Example{
+			Key:   "my-key",
+			Value: "my-value",
+		}, JSON))
+		expected := `{
+  "key": "my-key",
+  "value": "my-value"
+}
+`
+		assert.Equal(t, expected, w.String())
+	})
+
+	t.Run("serializes items to JSON", func(t *testing.T) {
+		w := bytes.NewBuffer(nil)
+		require.NoError(t, To(w, []Example{{
+			Key:   "my-key",
+			Value: "my-value",
+		}}, JSON))
+		expected := `[
+  {
+    "key": "my-key",
+    "value": "my-value"
+  }
+]
+`
+		assert.Equal(t, expected, w.String())
+	})
+
+	t.Run("serializes *items to JSON", func(t *testing.T) {
+		w := bytes.NewBuffer(nil)
+		require.NoError(t, To(w, []*Example{{
+			Key:   "my-key",
+			Value: "my-value",
+		}}, JSON))
+		expected := `[
+  {
+    "key": "my-key",
+    "value": "my-value"
+  }
+]
+`
+		assert.Equal(t, expected, w.String())
+	})
+
+	t.Run("serializes an item to JSON API", func(t *testing.T) {
+		w := bytes.NewBuffer(nil)
+		require.NoError(t, To[Example](w, Example{
+			Key:   "my-key",
+			Value: "my-value",
+		}, JSONAPI))
+		expected := `{"data":{"type":"examples","id":"my-key","attributes":{"value":"my-value"}}}
+`
+		assert.Equal(t, expected, w.String())
+	})
+
+	t.Run("serializes an *item to JSON API", func(t *testing.T) {
+		w := bytes.NewBuffer(nil)
+		require.NoError(t, To[*Example](w, &Example{
+			Key:   "my-key",
+			Value: "my-value",
+		}, JSONAPI))
+		expected := `{"data":{"type":"examples","id":"my-key","attributes":{"value":"my-value"}}}
+`
+		assert.Equal(t, expected, w.String())
+	})
+
+	t.Run("serializes a slice of items to JSON API", func(t *testing.T) {
+		w := bytes.NewBuffer(nil)
+		require.NoError(t, To[[]Example](w, []Example{{
+			Key:   "my-key",
+			Value: "my-value",
+		}}, JSONAPI))
+		expected := `{"data":[{"type":"examples","id":"my-key","attributes":{"value":"my-value"}}]}
+`
+		assert.Equal(t, expected, w.String())
+	})
+
+	t.Run("serializes a slice of *items to JSON API", func(t *testing.T) {
+		w := bytes.NewBuffer(nil)
+		require.NoError(t, To[[]*Example](w, []*Example{{
+			Key:   "my-key",
+			Value: "my-value",
+		}}, JSONAPI))
+		expected := `{"data":[{"type":"examples","id":"my-key","attributes":{"value":"my-value"}}]}
+`
+		assert.Equal(t, expected, w.String())
+	})
+}
pkg/serde/json.go
@@ -0,0 +1,17 @@
+package serde
+
+import (
+	"encoding/json"
+	"io"
+)
+
+func ToJSON[T any](w io.Writer, item T) error {
+	encoder := json.NewEncoder(w)
+	encoder.SetIndent("", "  ")
+	return encoder.Encode(item)
+}
+
+func FromJSON[T any](reader io.Reader) (T, error) {
+	var item T
+	return item, json.NewDecoder(reader).Decode(&item)
+}
pkg/serde/jsonapi.go
@@ -0,0 +1,59 @@
+package serde
+
+import (
+	"io"
+	"reflect"
+
+	"github.com/google/jsonapi"
+	"github.com/xlgmokha/x/pkg/x"
+)
+
+func ToJSONAPI[T any](w io.Writer, item T) error {
+	if err, ok := any(item).(*jsonapi.ErrorsPayload); ok {
+		return jsonapi.MarshalErrors(w, err.Errors)
+	}
+
+	if x.IsSlice[T](item) {
+		firstItem := reflect.TypeOf(item).Elem()
+		if firstItem.Kind() == reflect.Pointer {
+			return jsonapi.MarshalPayload(w, item)
+		}
+
+		sliceValue := reflect.ValueOf(item)
+		slice := reflect.MakeSlice(reflect.SliceOf(reflect.PointerTo(firstItem)), 0, sliceValue.Len())
+		for i := 0; i < sliceValue.Len(); i++ {
+			slice = reflect.Append(slice, sliceValue.Index(i).Addr())
+		}
+		return jsonapi.MarshalPayload(w, slice.Interface())
+	}
+
+	if x.IsPtr(item) {
+		return jsonapi.MarshalPayload(w, item)
+	}
+	return jsonapi.MarshalPayload(w, &item)
+}
+
+func FromJSONAPI[T any](reader io.Reader) (T, error) {
+	item := x.Default[T]()
+	if _, ok := any(item).(*jsonapi.ErrorsPayload); ok {
+		return FromJSON[T](reader)
+	}
+	if x.IsSlice[T](item) {
+		sliceType := reflect.TypeOf(item).Elem()
+
+		items, err := jsonapi.UnmarshalManyPayload(reader, sliceType)
+		if err != nil {
+			return item, err
+		}
+		slice := reflect.MakeSlice(reflect.SliceOf(sliceType), 0, len(items))
+		for _, item := range items {
+			slice = reflect.Append(slice, reflect.ValueOf(item))
+		}
+		return slice.Interface().(T), err
+	}
+
+	if x.IsPtr(item) {
+		return item, jsonapi.UnmarshalPayload(reader, item)
+	}
+	return item, jsonapi.UnmarshalPayload(reader, &item)
+}
pkg/serde/jsonapi_test.go
@@ -0,0 +1,54 @@
+package serde
+
+import (
+	"bytes"
+	"strings"
+	"testing"
+
+	"github.com/google/jsonapi"
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+type example struct {
+	ID   string `jsonapi:"primary,examples"`
+	Name string `jsonapi:"attr,name"`
+}
+
+func TestToJSONAPI(t *testing.T) {
+	t.Run("serializes a custom type", func(t *testing.T) {
+		io := bytes.NewBuffer(nil)
+
+		require.NoError(t, ToJSONAPI(io, &example{Name: "Example"}))
+
+		assert.Equal(t, `{"data":{"type":"examples","attributes":{"name":"Example"}}}`+"\n", io.String())
+	})
+
+	t.Run("serializes a jsonapi.ErrorsPayload", func(t *testing.T) {
+		io := bytes.NewBuffer(nil)
+
+		require.NoError(t, ToJSONAPI(io, &jsonapi.ErrorsPayload{
+			Errors: []*jsonapi.ErrorObject{
+				{
+					ID:     "id",
+					Title:  "Name is required",
+					Status: "400",
+				},
+			},
+		}))
+
+		assert.Equal(t, `{"errors":[{"id":"id","title":"Name is required","status":"400"}]}`+"\n", io.String())
+	})
+}
+
+func TestFromJSONAPI(t *testing.T) {
+	t.Run("from a single item", func(t *testing.T) {
+		io := strings.NewReader(`{"data":{"type":"examples","id":"42","attributes":{"name":"Example"}}}`)
+
+		item, err := FromJSONAPI[example](io)
+
+		require.NoError(t, err)
+		assert.Equal(t, "42", item.ID)
+		assert.Equal(t, "Example", item.Name)
+	})
+}
pkg/serde/media.go
@@ -0,0 +1,62 @@
+package serde
+
+import (
+	"mime"
+	"sort"
+	"strconv"
+	"strings"
+
+	"github.com/google/jsonapi"
+)
+
+type MediaType string
+
+const (
+	JSONAPI MediaType = jsonapi.MediaType
+	JSON    MediaType = "application/json"
+	Text    MediaType = "text/plain"
+	Default MediaType = JSON
+)
+
+func MediaTypeFor(value string) MediaType {
+	mediaTypes := sortedByQualityValues(strings.Split(value, ","))
+
+	for _, mediaType := range mediaTypes {
+		switch mediaType {
+		case jsonapi.MediaType:
+			return JSONAPI
+		case "application/json":
+			return JSON
+		case "text/plain":
+			return Text
+		default:
+			continue
+		}
+	}
+	return Default
+}
+
+func sortedByQualityValues(mimeTypes []string) []string {
+	weights := map[string]float64{}
+	valid := []string{}
+
+	for i, mimeType := range mimeTypes {
+		if mtype, params, err := mime.ParseMediaType(mimeType); err == nil {
+			valid = append(valid, mtype)
+			if _, ok := weights[mtype]; !ok {
+				weights[mtype] = 1.0 - (0.1 * float64(i))
+			}
+			if quality, ok := params["q"]; ok {
+				if val, err := strconv.ParseFloat(quality, 64); err == nil {
+					weights[mtype] = val
+				}
+			}
+		}
+	}
+	sort.Slice(valid, func(x, y int) bool {
+		xWeight := weights[valid[x]]
+		yWeight := weights[valid[y]]
+		return xWeight > yWeight
+	})
+	return valid
+}
pkg/serde/media_test.go
@@ -0,0 +1,35 @@
+package serde
+
+import (
+	"fmt"
+	"testing"
+
+	"github.com/google/jsonapi"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestMediaTypeFor(t *testing.T) {
+	tt := []struct {
+		input    string
+		expected MediaType
+	}{
+		{input: jsonapi.MediaType, expected: JSONAPI},
+		{input: "application/json", expected: JSON},
+		{input: "text/plain", expected: Text},
+		{input: "text/html", expected: Default},
+		{input: "*/*", expected: Default},
+		{input: "text/html, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8", expected: Default},
+		{input: "application/vnd.api+json, application/xhtml+xml, application/xml;q=0.9, */*;q=0.8", expected: JSONAPI},
+		{input: "application/vnd.api+json;q=0.1, application/json;q=0.2, text/plain;q=0.3, unknown/thing;q=0.4", expected: Text},
+		{input: "text/html; charset=UTF-8", expected: Default},
+		{input: "application/json; charset=UTF-8", expected: JSON},
+		{input: "application/vnd.api+json; charset=UTF-8", expected: JSONAPI},
+		{input: "text/plain; charset=UTF-8", expected: Text},
+		{input: "application/json, text/plain, */*", expected: JSON},
+	}
+	for _, row := range tt {
+		t.Run(fmt.Sprintf("%v returns %v", row.input, row.expected), func(t *testing.T) {
+			assert.Equal(t, row.expected, MediaTypeFor(row.input))
+		})
+	}
+}
pkg/serde/plain.go
@@ -0,0 +1,19 @@
+package serde
+
+import (
+	"fmt"
+	"io"
+
+	"github.com/google/jsonapi"
+)
+
+func ToPlain[T any](w io.Writer, item T) error {
+	if err, ok := any(item).(*jsonapi.ErrorsPayload); ok {
+		if len(err.Errors) == 1 {
+			_, err := w.Write([]byte(err.Errors[0].Title))
+			return err
+		}
+	}
+	_, err := w.Write([]byte(fmt.Sprintf("%v", item)))
+	return err
+}
pkg/serde/plain_test.go
@@ -0,0 +1,37 @@
+package serde
+
+import (
+	"bytes"
+	"fmt"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+	"github.com/stretchr/testify/require"
+)
+
+type nostringer struct {
+	Value string
+}
+
+type stringer struct {
+	Value string
+}
+
+func (s stringer) String() string {
+	return fmt.Sprintf("The %s", s.Value)
+}
+
+func TestToPlain(t *testing.T) {
+
+	t.Run("stringafies an item that doesn't implement fmt.Stringer", func(t *testing.T) {
+		w := bytes.NewBuffer(nil)
+		require.NoError(t, ToPlain[nostringer](w, nostringer{Value: "example"}))
+		assert.Equal(t, `{example}`, w.String())
+	})
+
+	t.Run("stringafies an item that implements fmt.Stringer", func(t *testing.T) {
+		w := bytes.NewBuffer(nil)
+		require.NoError(t, ToPlain[stringer](w, stringer{Value: "example"}))
+		assert.Equal(t, `The example`, w.String())
+	})
+}
pkg/x/types.go
@@ -1,6 +1,25 @@
 package x
 
+import "reflect"
+
 func Default[T any]() T {
 	var item T
+
+	if IsPtr[T](item) {
+		return reflect.New(reflect.TypeOf(item).Elem()).Interface().(T)
+	}
+
 	return item
 }
+
+func IsPtr[T any](item T) bool {
+	return Is[T](item, reflect.Pointer)
+}
+
+func IsSlice[T any](item T) bool {
+	return Is[T](item, reflect.Slice)
+}
+
+func Is[T any](item T, kind reflect.Kind) bool {
+	return reflect.TypeOf(item).Kind() == kind
+}
go.mod
@@ -2,7 +2,10 @@ module github.com/xlgmokha/x
 
 go 1.18
 
-require github.com/stretchr/testify v1.8.0
+require (
+	github.com/google/jsonapi v1.0.0
+	github.com/stretchr/testify v1.8.0
+)
 
 require (
 	github.com/davecgh/go-spew v1.1.1 // indirect
go.sum
@@ -1,6 +1,8 @@
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
 github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
 github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/google/jsonapi v1.0.0 h1:qIGgO5Smu3yJmSs+QlvhQnrscdZfFhiV6S8ryJAglqU=
+github.com/google/jsonapi v1.0.0/go.mod h1:YYHiRPJT8ARXGER8In9VuLv4qvLfDmA9ULQqptbLE4s=
 github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
 github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=