Commit 841f107
Changed files (15)
.github
workflows
pkg
.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=