diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 00000000..bf95d332 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,14 @@ +# Each line is a file pattern followed by one or more owners. +# More on CODEOWNERS files: https://help.github.com/en/github/creating-cloning-and-archiving-repositories/about-code-owners + +# Default owners +* @hashicorp/tf-core-cloud +* @hashicorp/team-ip-compliance + +# Add override rules below. Each line is a file/folder pattern followed by one or more owners. +# Being an owner means those groups or individuals will be added as reviewers to PRs affecting +# those areas of the code. +# Examples: +# /docs/ @docs-team +# *.js @js-team +# *.go @go-team diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..e6527955 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +name: CI +on: + push: + branches: + - main + pull_request: + +jobs: + unit-test: + runs-on: ubuntu-latest + strategy: + matrix: + go: [ '1.21', '1.20', '1.19', '1.18'] + steps: + - uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + + - uses: actions/setup-go@93397bea11091df50f3d7e59dc26a7711a8bcfbe # v4.1.0 + with: + go-version: ${{ matrix.go }} + + - name: test + run: go test -race . -v diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index abc7d1be..00000000 --- a/.travis.yml +++ /dev/null @@ -1,13 +0,0 @@ -language: go -arch: - - amd64 - - ppc64le -go: - - 1.11.x - - 1.12.x - - 1.13.x - - 1.14.x - - 1.15.x - - 1.16.x - - tip -script: go test ./... -v diff --git a/README.md b/README.md index 8dfb9438..8def4a0f 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,19 @@ # jsonapi -[![Build Status](https://travis-ci.org/google/jsonapi.svg?branch=master)](https://travis-ci.org/google/jsonapi) -[![Go Report Card](https://goreportcard.com/badge/github.com/google/jsonapi)](https://goreportcard.com/report/github.com/google/jsonapi) -[![GoDoc](https://godoc.org/github.com/google/jsonapi?status.svg)](http://godoc.org/github.com/google/jsonapi) -[![No Maintenance Intended](http://unmaintained.tech/badge.svg)](http://unmaintained.tech/) +[![Build Status](https://github.com/hashicorp/jsonapi/actions/workflows/ci.yml/badge.svg?main)](https://github.com/hashicorp/jsonapi/actions/workflows/ci.yml?query=branch%3Amain) +[![Go Report Card](https://goreportcard.com/badge/github.com/hashicorp/jsonapi)](https://goreportcard.com/report/github.com/hashicorp/jsonapi) +[![GoDoc](https://godoc.org/github.com/hashicorp/jsonapi?status.svg)](http://godoc.org/github.com/hashicorp/jsonapi) A serializer/deserializer for JSON payloads that comply to the -[JSON API - jsonapi.org](http://jsonapi.org) spec in go. - +[JSON API - jsonapi.org](http://jsonapi.org) v1.1 spec in go. +This package was forked from [google/jsonapi](https://github.com/google/jsonapi) and +adds several enhancements such as [links](#links) and [polymorphic relationships](#polyrelation). ## Installation ``` -go get -u github.com/google/jsonapi +go get -u github.com/hashicorp/jsonapi ``` Or, see [Alternative Installation](#alternative-installation). @@ -77,7 +77,7 @@ all of your data easily. ## Example App -[examples/app.go](https://github.com/google/jsonapi/blob/master/examples/app.go) +[examples/app.go](https://github.com/hashicorp/jsonapi/blob/main/examples/app.go) This program demonstrates the implementation of a create, a show, and a list [http.Handler](http://golang.org/pkg/net/http#Handler). It @@ -91,9 +91,9 @@ To run, * Make sure you have [Go installed](https://golang.org/doc/install) * Create the following directories or similar: `~/go` * Set `GOPATH` to `PWD` in your shell session, `export GOPATH=$PWD` -* `go get github.com/google/jsonapi`. (Append `-u` after `get` if you +* `go get github.com/hashicorp/jsonapi`. (Append `-u` after `get` if you are updating.) -* `cd $GOPATH/src/github.com/google/jsonapi/examples` +* `cd $GOPATH/src/github.com/hashicorp/jsonapi/examples` * `go build && ./examples` ## `jsonapi` Tag Reference @@ -179,6 +179,69 @@ used as the key in the `relationships` hash for the record. The optional third argument is `omitempty` - if present will prevent non existent to-one and to-many from being serialized. + +#### `polyrelation` + +``` +`jsonapi:"polyrelation,,"` +``` + +Polymorphic relations can be represented exactly as relations, except that +an intermediate type is needed within your model struct that provides a choice +for the actual value to be populated within. + +Example: + +```go +type Video struct { + ID int `jsonapi:"primary,videos"` + SourceURL string `jsonapi:"attr,source-url"` + CaptionsURL string `jsonapi:"attr,captions-url"` +} + +type Image struct { + ID int `jsonapi:"primary,images"` + SourceURL string `jsonapi:"attr,src"` + AltText string `jsonapi:"attr,alt"` +} + +type OneOfMedia struct { + Video *Video + Image *Image +} + +type Post struct { + ID int `jsonapi:"primary,posts"` + Title string `jsonapi:"attr,title"` + Body string `jsonapi:"attr,body"` + Gallery []*OneOfMedia `jsonapi:"polyrelation,gallery"` + Hero *OneOfMedia `jsonapi:"polyrelation,hero"` +} +``` + +During decoding, the `polyrelation` annotation instructs jsonapi to assign each relationship +to either `Video` or `Image` within the value of the associated field, provided that the +payload contains either a "videos" or "images" type. This field value must be +a pointer to a special choice type struct (also known as a tagged union, or sum type) containing +other pointer fields to jsonapi models. The actual field assignment depends on that type having +a jsonapi "primary" annotation with a type matching the relationship type found in the response. +All other fields will be remain empty. If no matching types are represented by the choice type, +all fields will be empty. + +During encoding, the very first non-nil field will be used to populate the payload. Others +will be ignored. Therefore, it's critical to set the value of only one field within the choice +struct. When accepting input values on this type of choice type, it would a good idea to enforce +and check that the value is set on only one field. + +#### `links` +``` +`jsonapi:"links,omitempty"` +``` + +A field annotated with `links` will have the links members of the request unmarshaled to it. Note +that this field should _always_ be annotated with `omitempty`, as marshaling of links members is +instead handled by the `Linkable` interface (see `Links` below). + ## Methods Reference **All `Marshal` and `Unmarshal` methods expect pointers to struct @@ -190,7 +253,7 @@ about the rest? ### Create Record Example You can Unmarshal a JSON API payload using -[jsonapi.UnmarshalPayload](http://godoc.org/github.com/google/jsonapi#UnmarshalPayload). +[jsonapi.UnmarshalPayload](http://godoc.org/github.com/hashicorp/jsonapi#UnmarshalPayload). It reads from an [io.Reader](https://golang.org/pkg/io/#Reader) containing a JSON API payload for one record (but can have related records). Then, it materializes a struct that you created and passed in @@ -199,7 +262,7 @@ the top level, in request payloads at the moment. Bulk creates and updates are not supported yet. After saving your record, you can use, -[MarshalOnePayload](http://godoc.org/github.com/google/jsonapi#MarshalOnePayload), +[MarshalOnePayload](http://godoc.org/github.com/hashicorp/jsonapi#MarshalOnePayload), to write the JSON API response to an [io.Writer](https://golang.org/pkg/io/#Writer). @@ -209,7 +272,7 @@ to write the JSON API response to an UnmarshalPayload(in io.Reader, model interface{}) ``` -Visit [godoc](http://godoc.org/github.com/google/jsonapi#UnmarshalPayload) +Visit [godoc](http://godoc.org/github.com/hashicorp/jsonapi#UnmarshalPayload) #### `MarshalPayload` @@ -217,7 +280,7 @@ Visit [godoc](http://godoc.org/github.com/google/jsonapi#UnmarshalPayload) MarshalPayload(w io.Writer, models interface{}) error ``` -Visit [godoc](http://godoc.org/github.com/google/jsonapi#MarshalPayload) +Visit [godoc](http://godoc.org/github.com/hashicorp/jsonapi#MarshalPayload) Writes a JSON API response, with related records sideloaded, into an `included` array. This method encodes a response for either a single record or @@ -253,7 +316,7 @@ func CreateBlog(w http.ResponseWriter, r *http.Request) { UnmarshalManyPayload(in io.Reader, t reflect.Type) ([]interface{}, error) ``` -Visit [godoc](http://godoc.org/github.com/google/jsonapi#UnmarshalManyPayload) +Visit [godoc](http://godoc.org/github.com/hashicorp/jsonapi#UnmarshalManyPayload) Takes an `io.Reader` and a `reflect.Type` representing the uniform type contained within the `"data"` JSON API member. @@ -346,6 +409,72 @@ func (post Post) JSONAPIRelationshipMeta(relation string) *Meta { } ``` +### Nullable attributes + +Certain APIs may interpret the meaning of `null` attribute values as significantly +different from unspecified values (those that do not show up in the request). +The default use of the `omitempty` struct tag does not allow for sending +significant `null`s. + +A type is provided for this purpose if needed: `NullableAttr[T]`. This type +provides an API for sending and receiving significant `null` values for +attribute values of any type. + +In the example below, a payload is presented for a fictitious API that makes use +of significant `null` values. Once enabled, the `UnsettableTime` setting can +only be disabled by updating it to a `null` value. + +The payload struct below makes use of a `NullableAttr` with an inner `time.Time` +to allow this behavior: + +```go +type Settings struct { + ID int `jsonapi:"primary,videos"` + UnsettableTime jsonapi.NullableAttr[time.Time] `jsonapi:"attr,unsettable_time,rfc3339,omitempty"` +} +``` + +To enable the setting as described above, an non-null `time.Time` value is +sent to the API. This is done by using the exported +`NewNullableAttrWithValue[T]()` method: + +```go +s := Settings{ + ID: 1, + UnsettableTime: jsonapi.NewNullableAttrWithValue[time.Time](time.Now()), +} +``` + +To disable the setting, a `null` value needs to be sent to the API. This is done +by using the exported `NewNullNullableAttr[T]()` method: + +```go +s := Settings{ + ID: 1, + UnsettableTime: jsonapi.NewNullNullableAttr[time.Time](), +} +``` + +Once a payload has been marshaled, the attribute value is flattened to a +primitive value: +``` + "unsettable_time": "2021-01-01T02:07:14Z", +``` + +Significant nulls are also included and flattened, even when specifying `omitempty`: +``` + "unsettable_time": null, +``` + +Once a payload is unmarshaled, the target attribute field is hydrated with +the value in the payload and can be retrieved with the `Get()` method: +```go +t, err := s.UnsettableTime.Get() +``` + +All other struct tags used in the attribute definition will be honored when +marshaling and unmarshaling non-null values for the inner type. + ### Custom types Custom types are supported for primitive types, only, as attributes. Examples, @@ -419,7 +548,7 @@ if err := validate(&myStructToValidate); err != nil { MarshalOnePayloadEmbedded(w io.Writer, model interface{}) error ``` -Visit [godoc](http://godoc.org/github.com/google/jsonapi#MarshalOnePayloadEmbedded) +Visit [godoc](http://godoc.org/github.com/hashicorp/jsonapi#MarshalOnePayloadEmbedded) This method is not strictly meant to for use in implementation code, although feel free. It was mainly created for use in tests; in most cases, @@ -459,13 +588,13 @@ I use git subtrees to manage dependencies rather than `go get` so that the src is committed to my repo. ``` -git subtree add --squash --prefix=src/github.com/google/jsonapi https://github.com/google/jsonapi.git master +git subtree add --squash --prefix=src/github.com/hashicorp/jsonapi https://github.com/hashicorp/jsonapi.git main ``` To update, ``` -git subtree pull --squash --prefix=src/github.com/google/jsonapi https://github.com/google/jsonapi.git master +git subtree pull --squash --prefix=src/github.com/hashicorp/jsonapi https://github.com/hashicorp/jsonapi.git main ``` This assumes that I have my repo structured with a `src` dir containing diff --git a/constants.go b/constants.go index 35bbe054..591d1caf 100644 --- a/constants.go +++ b/constants.go @@ -2,15 +2,17 @@ package jsonapi const ( // StructTag annotation strings - annotationJSONAPI = "jsonapi" - annotationPrimary = "primary" - annotationClientID = "client-id" - annotationAttribute = "attr" - annotationRelation = "relation" - annotationOmitEmpty = "omitempty" - annotationISO8601 = "iso8601" - annotationRFC3339 = "rfc3339" - annotationSeperator = "," + annotationJSONAPI = "jsonapi" + annotationPrimary = "primary" + annotationClientID = "client-id" + annotationAttribute = "attr" + annotationRelation = "relation" + annotationPolyRelation = "polyrelation" + annotationLinks = "links" + annotationOmitEmpty = "omitempty" + annotationISO8601 = "iso8601" + annotationRFC3339 = "rfc3339" + annotationSeparator = "," iso8601TimeFormat = "2006-01-02T15:04:05Z" @@ -53,4 +55,8 @@ const ( // QueryParamPageCursor is a JSON API query parameter used with a cursor-based // strategy QueryParamPageCursor = "page[cursor]" + + // KeySelfLink is the key within a top-level links object that denotes the link that + // generated the current response document. + KeySelfLink = "self" ) diff --git a/errors.go b/errors.go index 798fed0a..4eab7427 100644 --- a/errors.go +++ b/errors.go @@ -42,10 +42,26 @@ type ErrorObject struct { // Code is an application-specific error code, expressed as a string value. Code string `json:"code,omitempty"` + // Source is an object containing references to the primary source of the error. + Source *ErrorSource `json:"source,omitempty"` + // Meta is an object containing non-standard meta-information about the error. Meta *map[string]interface{} `json:"meta,omitempty"` } +// ErrorSource is an object containing references to the primary source of the error. +// Only one field should be populated depending on the source of the error. +type ErrorSource struct { + // Pointer is a JSON Pointer (RFC6901) indicating the value in the request document that caused the error. + Pointer string `json:"pointer,omitempty"` + + // Parameter is a string indicating which query or path parameter caused the error. + Parameter string `json:"parameter,omitempty"` + + // Header is a string indicating the name of a single request header which caused the error. + Header string `json:"header,omitempty"` +} + // Error implements the `Error` interface. func (e *ErrorObject) Error() string { return fmt.Sprintf("Error: %s %s\n", e.Title, e.Detail) diff --git a/errors_test.go b/errors_test.go index 683a1d1d..ef07359c 100644 --- a/errors_test.go +++ b/errors_test.go @@ -28,9 +28,9 @@ func TestMarshalErrorsWritesTheExpectedPayload(t *testing.T) { }{ { Title: "TestFieldsAreSerializedAsNeeded", - In: []*ErrorObject{{ID: "0", Title: "Test title.", Detail: "Test detail", Status: "400", Code: "E1100"}}, + In: []*ErrorObject{{ID: "0", Title: "Test title.", Detail: "Test detail", Status: "400", Code: "E1100", Source: &ErrorSource{Pointer: "title"}}}, Out: map[string]interface{}{"errors": []interface{}{ - map[string]interface{}{"id": "0", "title": "Test title.", "detail": "Test detail", "status": "400", "code": "E1100"}, + map[string]interface{}{"id": "0", "title": "Test title.", "detail": "Test detail", "status": "400", "code": "E1100", "source": map[string]interface{}{"pointer": "title"}}, }}, }, { diff --git a/examples/app.go b/examples/app.go index 2b29e0d8..cbd15d47 100644 --- a/examples/app.go +++ b/examples/app.go @@ -10,7 +10,7 @@ import ( "net/http/httptest" "time" - "github.com/google/jsonapi" + "github.com/hashicorp/jsonapi" ) func main() { @@ -96,6 +96,28 @@ func exerciseHandler() { fmt.Println(buf.String()) fmt.Println("============== end raw jsonapi response =============") + // update + blog.UnsettableTime = jsonapi.NewNullableAttrWithValue[time.Time](time.Now()) + in = bytes.NewBuffer(nil) + jsonapi.MarshalOnePayloadEmbedded(in, blog) + + req, _ = http.NewRequest(http.MethodPatch, "/blogs", in) + + req.Header.Set(headerAccept, jsonapi.MediaType) + + w = httptest.NewRecorder() + + fmt.Println("============ start update ===========") + http.DefaultServeMux.ServeHTTP(w, req) + fmt.Println("============ stop update ===========") + + buf = bytes.NewBuffer(nil) + io.Copy(buf, w.Body) + + fmt.Println("============ jsonapi response from update ===========") + fmt.Println(buf.String()) + fmt.Println("============== end raw jsonapi response =============") + // echo blogs := []interface{}{ fixtureBlogCreate(1), diff --git a/examples/fixtures.go b/examples/fixtures.go index 7d0402df..6c87983d 100644 --- a/examples/fixtures.go +++ b/examples/fixtures.go @@ -1,6 +1,8 @@ package main -import "time" +import ( + "time" +) func fixtureBlogCreate(i int) *Blog { return &Blog{ diff --git a/examples/handler.go b/examples/handler.go index 77894c79..f01a3600 100644 --- a/examples/handler.go +++ b/examples/handler.go @@ -1,10 +1,11 @@ package main import ( + "fmt" "net/http" "strconv" - "github.com/google/jsonapi" + "github.com/hashicorp/jsonapi" ) const ( @@ -25,6 +26,8 @@ func (h *ExampleHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { switch r.Method { case http.MethodPost: methodHandler = h.createBlog + case http.MethodPatch: + methodHandler = h.updateBlog case http.MethodPut: methodHandler = h.echoBlogs case http.MethodGet: @@ -61,6 +64,28 @@ func (h *ExampleHandler) createBlog(w http.ResponseWriter, r *http.Request) { } } +func (h *ExampleHandler) updateBlog(w http.ResponseWriter, r *http.Request) { + jsonapiRuntime := jsonapi.NewRuntime().Instrument("blogs.update") + + blog := new(Blog) + + if err := jsonapiRuntime.UnmarshalPayload(r.Body, blog); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + fmt.Println(blog) + + // ...do stuff with your blog... + + w.WriteHeader(http.StatusCreated) + w.Header().Set(headerContentType, jsonapi.MediaType) + + if err := jsonapiRuntime.MarshalPayload(w, blog); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + } +} + func (h *ExampleHandler) echoBlogs(w http.ResponseWriter, r *http.Request) { jsonapiRuntime := jsonapi.NewRuntime().Instrument("blogs.list") // ...fetch your blogs, filter, offset, limit, etc... diff --git a/examples/handler_test.go b/examples/handler_test.go index 34c0bc5d..20adc298 100644 --- a/examples/handler_test.go +++ b/examples/handler_test.go @@ -6,7 +6,7 @@ import ( "net/http/httptest" "testing" - "github.com/google/jsonapi" + "github.com/hashicorp/jsonapi" ) func TestExampleHandler_post(t *testing.T) { diff --git a/examples/models.go b/examples/models.go index 080790e7..83347253 100644 --- a/examples/models.go +++ b/examples/models.go @@ -4,18 +4,19 @@ import ( "fmt" "time" - "github.com/google/jsonapi" + "github.com/hashicorp/jsonapi" ) // Blog is a model representing a blog site type Blog struct { - ID int `jsonapi:"primary,blogs"` - Title string `jsonapi:"attr,title"` - Posts []*Post `jsonapi:"relation,posts"` - CurrentPost *Post `jsonapi:"relation,current_post"` - CurrentPostID int `jsonapi:"attr,current_post_id"` - CreatedAt time.Time `jsonapi:"attr,created_at"` - ViewCount int `jsonapi:"attr,view_count"` + ID int `jsonapi:"primary,blogs"` + Title string `jsonapi:"attr,title"` + Posts []*Post `jsonapi:"relation,posts"` + CurrentPost *Post `jsonapi:"relation,current_post"` + CurrentPostID int `jsonapi:"attr,current_post_id"` + CreatedAt time.Time `jsonapi:"attr,created_at"` + UnsettableTime jsonapi.NullableAttr[time.Time] `jsonapi:"attr,unsettable_time,rfc3339,omitempty"` + ViewCount int `jsonapi:"attr,view_count"` } // Post is a model representing a post on a blog diff --git a/go.mod b/go.mod index d1fb59d6..4f90467e 100644 --- a/go.mod +++ b/go.mod @@ -1 +1,3 @@ -module github.com/google/jsonapi \ No newline at end of file +module github.com/hashicorp/jsonapi + +go 1.18 diff --git a/models_test.go b/models_test.go index f552d4fe..28b56e65 100644 --- a/models_test.go +++ b/models_test.go @@ -35,6 +35,16 @@ type TimestampModel struct { RFC3339P *time.Time `jsonapi:"attr,rfc3339p,rfc3339"` } +type WithNullableAttrs struct { + ID int `jsonapi:"primary,with-nullables"` + Name string `jsonapi:"attr,name"` + IntTime NullableAttr[time.Time] `jsonapi:"attr,int_time,omitempty"` + RFC3339Time NullableAttr[time.Time] `jsonapi:"attr,rfc3339_time,rfc3339,omitempty"` + ISO8601Time NullableAttr[time.Time] `jsonapi:"attr,iso8601_time,iso8601,omitempty"` + Bool NullableAttr[bool] `jsonapi:"attr,bool,omitempty"` + NullableComment NullableRelationship[*Comment] `jsonapi:"relation,nullable_comment,omitempty"` +} + type Car struct { ID *string `jsonapi:"primary,cars"` Make *string `jsonapi:"attr,make,omitempty"` @@ -51,6 +61,8 @@ type Post struct { Body string `jsonapi:"attr,body"` Comments []*Comment `jsonapi:"relation,comments"` LatestComment *Comment `jsonapi:"relation,latest_comment"` + + Links Links `jsonapi:"links,omitempty"` } type Comment struct { @@ -58,6 +70,8 @@ type Comment struct { ClientID string `jsonapi:"client-id"` PostID int `jsonapi:"attr,post_id"` Body string `jsonapi:"attr,body"` + + Links Links `jsonapi:"links,omitempty"` } type Book struct { @@ -71,6 +85,11 @@ type Book struct { Tags []string `jsonapi:"attr,tags"` } +type GenericInterface struct { + ID uint64 `jsonapi:"primary,generic"` + Data interface{} `jsonapi:"attr,interface"` +} + type Blog struct { ID int `jsonapi:"primary,blogs"` ClientID string `jsonapi:"client-id"` @@ -80,6 +99,8 @@ type Blog struct { CurrentPostID int `jsonapi:"attr,current_post_id"` CreatedAt time.Time `jsonapi:"attr,created_at"` ViewCount int `jsonapi:"attr,view_count"` + + Links Links `jsonapi:"links,omitempty"` } func (b *Blog) JSONAPILinks() *Links { @@ -164,10 +185,27 @@ type Company struct { ID string `jsonapi:"primary,companies"` Name string `jsonapi:"attr,name"` Boss Employee `jsonapi:"attr,boss"` + Manager *Employee `jsonapi:"attr,manager"` Teams []Team `jsonapi:"attr,teams"` + People []*People `jsonapi:"attr,people"` FoundedAt time.Time `jsonapi:"attr,founded-at,iso8601"` } +type CompanyOmitEmpty struct { + ID string `jsonapi:"primary,companies"` + Name string `jsonapi:"attr,name,omitempty"` + Boss Employee `jsonapi:"attr,boss,omitempty"` + Manager *Employee `jsonapi:"attr,manager,omitempty"` + Teams []Team `jsonapi:"attr,teams,omitempty"` + People []*People `jsonapi:"attr,people,omitempty"` + FoundedAt time.Time `jsonapi:"attr,founded-at,iso8601,omitempty"` +} + +type People struct { + Name string `jsonapi:"attr,name"` + Age int `jsonapi:"attr,age"` +} + type Team struct { Name string `jsonapi:"attr,name"` Leader *Employee `jsonapi:"attr,leader"` @@ -195,3 +233,27 @@ type CustomAttributeTypes struct { Float CustomFloatType `jsonapi:"attr,float"` String CustomStringType `jsonapi:"attr,string"` } + +type Image struct { + ID string `jsonapi:"primary,images"` + Src string `jsonapi:"attr,src"` +} + +type Video struct { + ID string `jsonapi:"primary,videos"` + Captions string `jsonapi:"attr,captions"` +} + +type OneOfMedia struct { + Image *Image + random int + Video *Video + RandomStuff *string +} + +type BlogPostWithPoly struct { + ID string `jsonapi:"primary,blogs"` + Title string `jsonapi:"attr,title"` + Hero *OneOfMedia `jsonapi:"polyrelation,hero-media,omitempty"` + Media []*OneOfMedia `jsonapi:"polyrelation,media,omitempty"` +} diff --git a/nullable.go b/nullable.go new file mode 100644 index 00000000..b7552f8a --- /dev/null +++ b/nullable.go @@ -0,0 +1,180 @@ +package jsonapi + +import ( + "errors" +) + +// NullableAttr is a generic type, which implements a field that can be one of three states: +// +// - field is not set in the request +// - field is explicitly set to `null` in the request +// - field is explicitly set to a valid value in the request +// +// NullableAttr is intended to be used with JSON marshalling and unmarshalling. +// This is generally useful for PATCH requests, where attributes with zero +// values are intentionally not marshaled into the request payload so that +// existing attribute values are not overwritten. +// +// Internal implementation details: +// +// - map[true]T means a value was provided +// - map[false]T means an explicit null was provided +// - nil or zero map means the field was not provided +// +// If the field is expected to be optional, add the `omitempty` JSON tags. Do NOT use `*NullableAttr`! +// +// Adapted from https://www.jvt.me/posts/2024/01/09/go-json-nullable/ +type NullableAttr[T any] map[bool]T + +// NullableRelationship is a generic type, which implements a field that can be one of three states: +// +// - relationship is not set in the request +// - relationship is explicitly set to `null` in the request +// - relationship is explicitly set to a valid relationship value in the request +// +// NullableRelationship is intended to be used with JSON marshalling and unmarshalling. +// This is generally useful for PATCH requests, where relationships with zero +// values are intentionally not marshaled into the request payload so that +// existing attribute values are not overwritten. +// +// Internal implementation details: +// +// - map[true]T means a value was provided +// - map[false]T means an explicit null was provided +// - nil or zero map means the field was not provided +// +// If the relationship is expected to be optional, add the `omitempty` JSON tags. Do NOT use `*NullableRelationship`! +// +// Slice types are not currently supported for NullableRelationships as the nullable nature can be expressed via empty array +// `polyrelation` JSON tags are NOT currently supported. +// +// NullableRelationships must have an inner type of pointer: +// +// - NullableRelationship[*Comment] - valid +// - NullableRelationship[[]*Comment] - invalid +// - NullableRelationship[Comment] - invalid +type NullableRelationship[T any] map[bool]T + +// NewNullableAttrWithValue is a convenience helper to allow constructing a +// NullableAttr with a given value, for instance to construct a field inside a +// struct without introducing an intermediate variable. +func NewNullableAttrWithValue[T any](t T) NullableAttr[T] { + var n NullableAttr[T] + n.Set(t) + return n +} + +// NewNullNullableAttr is a convenience helper to allow constructing a NullableAttr with +// an explicit `null`, for instance to construct a field inside a struct +// without introducing an intermediate variable +func NewNullNullableAttr[T any]() NullableAttr[T] { + var n NullableAttr[T] + n.SetNull() + return n +} + +// Get retrieves the underlying value, if present, and returns an error if the value was not present +func (t NullableAttr[T]) Get() (T, error) { + var empty T + if t.IsNull() { + return empty, errors.New("value is null") + } + if !t.IsSpecified() { + return empty, errors.New("value is not specified") + } + return t[true], nil +} + +// Set sets the underlying value to a given value +func (t *NullableAttr[T]) Set(value T) { + *t = map[bool]T{true: value} +} + +// Set sets the underlying value to a given value +func (t *NullableAttr[T]) SetInterface(value interface{}) { + t.Set(value.(T)) +} + +// IsNull indicate whether the field was sent, and had a value of `null` +func (t NullableAttr[T]) IsNull() bool { + _, foundNull := t[false] + return foundNull +} + +// SetNull sets the value to an explicit `null` +func (t *NullableAttr[T]) SetNull() { + var empty T + *t = map[bool]T{false: empty} +} + +// IsSpecified indicates whether the field was sent +func (t NullableAttr[T]) IsSpecified() bool { + return len(t) != 0 +} + +// SetUnspecified sets the value to be absent from the serialized payload +func (t *NullableAttr[T]) SetUnspecified() { + *t = map[bool]T{} +} + +// NewNullableAttrWithValue is a convenience helper to allow constructing a +// NullableAttr with a given value, for instance to construct a field inside a +// struct without introducing an intermediate variable. +func NewNullableRelationshipWithValue[T any](t T) NullableRelationship[T] { + var n NullableRelationship[T] + n.Set(t) + return n +} + +// NewNullNullableAttr is a convenience helper to allow constructing a NullableAttr with +// an explicit `null`, for instance to construct a field inside a struct +// without introducing an intermediate variable +func NewNullNullableRelationship[T any]() NullableRelationship[T] { + var n NullableRelationship[T] + n.SetNull() + return n +} + +// Get retrieves the underlying value, if present, and returns an error if the value was not present +func (t NullableRelationship[T]) Get() (T, error) { + var empty T + if t.IsNull() { + return empty, errors.New("value is null") + } + if !t.IsSpecified() { + return empty, errors.New("value is not specified") + } + return t[true], nil +} + +// Set sets the underlying value to a given value +func (t *NullableRelationship[T]) Set(value T) { + *t = map[bool]T{true: value} +} + +// Set sets the underlying value to a given value +func (t *NullableRelationship[T]) SetInterface(value interface{}) { + t.Set(value.(T)) +} + +// IsNull indicates whether the field was sent, and had a value of `null` +func (t NullableRelationship[T]) IsNull() bool { + _, foundNull := t[false] + return foundNull +} + +// SetNull sets the value to an explicit `null` +func (t *NullableRelationship[T]) SetNull() { + var empty T + *t = map[bool]T{false: empty} +} + +// IsSpecified indicates whether the field was sent +func (t NullableRelationship[T]) IsSpecified() bool { + return len(t) != 0 +} + +// SetUnspecified sets the value to be absent from the serialized payload +func (t *NullableRelationship[T]) SetUnspecified() { + *t = map[bool]T{} +} diff --git a/request.go b/request.go index f665857f..9864e782 100644 --- a/request.go +++ b/request.go @@ -32,7 +32,8 @@ var ( ErrUnknownFieldNumberType = errors.New("The struct field was not of a known number type") // ErrInvalidType is returned when the given type is incompatible with the expected type. ErrInvalidType = errors.New("Invalid type provided") // I wish we used punctuation. - + // ErrTypeNotFound is returned when the given type not found on the model. + ErrTypeNotFound = errors.New("no primary type annotation found on model") ) // ErrUnsupportedPtrType is returned when the Struct field was a pointer but @@ -70,24 +71,23 @@ func newErrUnsupportedPtrType(rf reflect.Value, t reflect.Type, structField refl // For example you could pass it, in, req.Body and, model, a BlogPost // struct instance to populate in an http handler, // -// func CreateBlog(w http.ResponseWriter, r *http.Request) { -// blog := new(Blog) -// -// if err := jsonapi.UnmarshalPayload(r.Body, blog); err != nil { -// http.Error(w, err.Error(), 500) -// return -// } +// func CreateBlog(w http.ResponseWriter, r *http.Request) { +// blog := new(Blog) // -// // ...do stuff with your blog... +// if err := jsonapi.UnmarshalPayload(r.Body, blog); err != nil { +// http.Error(w, err.Error(), 500) +// return +// } // -// w.Header().Set("Content-Type", jsonapi.MediaType) -// w.WriteHeader(201) +// // ...do stuff with your blog... // -// if err := jsonapi.MarshalPayload(w, blog); err != nil { -// http.Error(w, err.Error(), 500) -// } -// } +// w.Header().Set("Content-Type", jsonapi.MediaType) +// w.WriteHeader(201) // +// if err := jsonapi.MarshalPayload(w, blog); err != nil { +// http.Error(w, err.Error(), 500) +// } +// } // // Visit https://github.com/google/jsonapi#create for more info. // @@ -142,6 +142,164 @@ func UnmarshalManyPayload(in io.Reader, t reflect.Type) ([]interface{}, error) { return models, nil } +// jsonapiTypeOfModel returns a jsonapi primary type string +// given a struct type that has typical jsonapi struct tags +// +// Example: +// For this type, "posts" is returned. An error is returned if +// no properly-formatted "primary" tag is found for jsonapi +// annotations +// +// type Post struct { +// ID string `jsonapi:"primary,posts"` +// } +func jsonapiTypeOfModel(structModel reflect.Type) (string, error) { + for i := 0; i < structModel.NumField(); i++ { + fieldType := structModel.Field(i) + args, err := getStructTags(fieldType) + + // A jsonapi tag was found, but it was improperly structured + if err != nil { + return "", err + } + + if len(args) < 2 { + continue + } + + if args[0] == annotationPrimary { + return args[1], nil + } + } + + return "", ErrTypeNotFound +} + +// structFieldIndex holds a bit of information about a type found at a struct field index +type structFieldIndex struct { + Type reflect.Type + FieldNum int +} + +// choiceStructMapping reflects on a value that may be a slice +// of choice type structs or a choice type struct. A choice type +// struct is a struct comprised of pointers to other jsonapi models, +// only one of which is populated with a value by the decoder. +// +// The specified type is probed and a map is generated that maps the +// underlying model type (its 'primary' type) to the field number +// within the choice type struct. This data can then be used to correctly +// assign each data relationship node to the correct choice type +// struct field. +// +// For example, if the `choice` type was +// +// type OneOfMedia struct { +// Video *Video +// Image *Image +// } +// +// then the resulting map would be +// +// { +// "videos" => {Video, 0} +// "images" => {Image, 1} +// } +// +// where `"videos"` is the value of the `primary` annotation on the `Video` model +func choiceStructMapping(choice reflect.Type) (result map[string]structFieldIndex) { + result = make(map[string]structFieldIndex) + + for choice.Kind() != reflect.Struct { + choice = choice.Elem() + } + + for i := 0; i < choice.NumField(); i++ { + fieldType := choice.Field(i) + + // Must be a pointer + if fieldType.Type.Kind() != reflect.Ptr { + continue + } + + subtype := fieldType.Type.Elem() + + // Must be a pointer to struct + if subtype.Kind() != reflect.Struct { + continue + } + + if t, err := jsonapiTypeOfModel(subtype); err == nil { + result[t] = structFieldIndex{ + Type: subtype, + FieldNum: i, + } + } + } + + return result +} + +func getStructTags(field reflect.StructField) ([]string, error) { + tag := field.Tag.Get("jsonapi") + if tag == "" { + return []string{}, nil + } + + args := strings.Split(tag, ",") + if len(args) < 1 { + return nil, ErrBadJSONAPIStructTag + } + + annotation := args[0] + + if (annotation == annotationClientID && len(args) != 1) || + (annotation != annotationClientID && len(args) < 2) { + return nil, ErrBadJSONAPIStructTag + } + + return args, nil +} + +// unmarshalNodeMaybeChoice populates a model that may or may not be +// a choice type struct that corresponds to a polyrelation or relation +func unmarshalNodeMaybeChoice(m *reflect.Value, data *Node, annotation string, choiceTypeMapping map[string]structFieldIndex, included *map[string]*Node) error { + // This will hold either the value of the choice type model or the actual + // model, depending on annotation + var actualModel = *m + var choiceElem *structFieldIndex = nil + + if annotation == annotationPolyRelation { + c, ok := choiceTypeMapping[data.Type] + if !ok { + // If there is no valid choice field to assign this type of relation, + // this shouldn't necessarily be an error because a newer version of + // the API could be communicating with an older version of the client + // library, in which case all choice variants would be nil. + return nil + } + choiceElem = &c + actualModel = reflect.New(choiceElem.Type) + } + + if err := unmarshalNode( + fullNode(data, included), + actualModel, + included, + ); err != nil { + return err + } + + if choiceElem != nil { + // actualModel is a pointer to the model type + // m is a pointer to a struct that should hold the actualModel + // at choiceElem.FieldNum + v := m.Elem() + v.Field(choiceElem.FieldNum).Set(actualModel) + } + return nil +} + func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) (err error) { defer func() { if r := recover(); r != nil { @@ -151,31 +309,46 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) modelValue := model.Elem() modelType := modelValue.Type() + polyrelationFields := map[string]reflect.Type{} var er error + // preprocess the model to find polyrelation fields for i := 0; i < modelValue.NumField(); i++ { + fieldValue := modelValue.Field(i) fieldType := modelType.Field(i) - tag := fieldType.Tag.Get("jsonapi") - if tag == "" { + + args, err := getStructTags(fieldType) + if err != nil { + er = err + break + } + + if len(args) < 2 { continue } - fieldValue := modelValue.Field(i) + annotation := args[0] + name := args[1] - args := strings.Split(tag, ",") - if len(args) < 1 { - er = ErrBadJSONAPIStructTag - break + if annotation == annotationPolyRelation { + polyrelationFields[name] = fieldValue.Type() } + } - annotation := args[0] + for i := 0; i < modelValue.NumField(); i++ { + fieldValue := modelValue.Field(i) + fieldType := modelType.Field(i) - if (annotation == annotationClientID && len(args) != 1) || - (annotation != annotationClientID && len(args) < 2) { - er = ErrBadJSONAPIStructTag + args, err := getStructTags(fieldType) + if err != nil { + er = err break } + if len(args) == 0 { + continue + } + annotation := args[0] if annotation == annotationPrimary { // Check the JSON API Type @@ -257,16 +430,26 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) } assign(fieldValue, value) - } else if annotation == annotationRelation { + } else if annotation == annotationRelation || annotation == annotationPolyRelation { isSlice := fieldValue.Type().Kind() == reflect.Slice + // No relations of the given name were provided if data.Relationships == nil || data.Relationships[args[1]] == nil { continue } + // If this is a polymorphic relation, each data relationship needs to be assigned + // to it's appropriate choice field and fieldValue should be a choice + // struct type field. + var choiceMapping map[string]structFieldIndex = nil + if annotation == annotationPolyRelation { + choiceMapping = choiceStructMapping(fieldValue.Type()) + } + if isSlice { // to-many relationship relationship := new(RelationshipManyNode) + sliceType := fieldValue.Type() buf := bytes.NewBuffer(nil) @@ -274,16 +457,18 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) json.NewDecoder(buf).Decode(relationship) data := relationship.Data - models := reflect.New(fieldValue.Type()).Elem() + + // This will hold either the value of the slice of choice type models or + // the slice of models, depending on the annotation + models := reflect.New(sliceType).Elem() for _, n := range data { - m := reflect.New(fieldValue.Type().Elem().Elem()) + // This will hold either the value of the choice type model or the actual + // model, depending on annotation + m := reflect.New(sliceType.Elem().Elem()) - if err := unmarshalNode( - fullNode(n, included), - m, - included, - ); err != nil { + err = unmarshalNodeMaybeChoice(&m, n, annotation, choiceMapping, included) + if err != nil { er = err break } @@ -298,10 +483,30 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) buf := bytes.NewBuffer(nil) - json.NewEncoder(buf).Encode( - data.Relationships[args[1]], - ) - json.NewDecoder(buf).Decode(relationship) + relDataStr := data.Relationships[args[1]] + json.NewEncoder(buf).Encode(relDataStr) + + isExplicitNull := false + relationshipDecodeErr := json.NewDecoder(buf).Decode(relationship) + if relationshipDecodeErr == nil && relationship.Data == nil { + // If the relationship was a valid node and relationship data was null + // this indicates disassociating the relationship + isExplicitNull = true + } else if relationshipDecodeErr != nil { + er = fmt.Errorf("decode err %v\n", relationshipDecodeErr) + } + + // This will hold either the value of the choice type model or the actual + // model, depending on annotation + m := reflect.New(fieldValue.Type().Elem()) + + // Nullable relationships have an extra pointer indirection + // unwind that here + if strings.HasPrefix(fieldType.Type.Name(), "NullableRelationship[") { + if m.Kind() == reflect.Ptr { + m = reflect.New(fieldValue.Type().Elem().Elem()) + } + } /* http://jsonapi.org/format/#document-resource-object-relationships @@ -310,23 +515,76 @@ func unmarshalNode(data *Node, model reflect.Value, included *map[string]*Node) so unmarshal and set fieldValue only if data obj is not null */ if relationship.Data == nil { + // Explicit null supplied for the field value + // If a nullable relationship we set the field value to a map with a single entry + if isExplicitNull { + fieldValue.Set(reflect.MakeMapWithSize(fieldValue.Type(), 1)) + fieldValue.SetMapIndex(reflect.ValueOf(false), m) + } continue } - m := reflect.New(fieldValue.Type().Elem()) - if err := unmarshalNode( - fullNode(relationship.Data, included), - m, - included, - ); err != nil { + // If the field is also a polyrelation field, then prefer the polyrelation. + // Otherwise stop processing this node. + // This is to allow relation and polyrelation fields to coexist, supporting deprecation for consumers + if pFieldType, ok := polyrelationFields[args[1]]; ok && fieldValue.Type() != pFieldType { + continue + } + + + err = unmarshalNodeMaybeChoice(&m, relationship.Data, annotation, choiceMapping, included) + if err != nil { er = err break } - fieldValue.Set(m) + if strings.HasPrefix(fieldType.Type.Name(), "NullableRelationship[") { + fieldValue.Set(reflect.MakeMapWithSize(fieldValue.Type(), 1)) + fieldValue.SetMapIndex(reflect.ValueOf(true), m) + } else { + fieldValue.Set(m) + } + } + } else if annotation == annotationLinks { + if data.Links == nil { + continue + } + + links := make(Links, len(*data.Links)) + + for k, v := range *data.Links { + link := v // default case (including string urls) + + // Unmarshal link objects to Link + if t, ok := v.(map[string]interface{}); ok { + unmarshaledHref := "" + href, ok := t["href"].(string) + if ok { + unmarshaledHref = href + } + + unmarshaledMeta := make(Meta) + if meta, ok := t["meta"].(map[string]interface{}); ok { + for metaK, metaV := range meta { + unmarshaledMeta[metaK] = metaV + } + } + link = Link{ + Href: unmarshaledHref, + Meta: unmarshaledMeta, + } + } + + links[k] = link } + if err != nil { + er = err + break + } + + assign(fieldValue, reflect.ValueOf(links)) } else { er = fmt.Errorf(unsupportedStructTagMsg, annotation) } @@ -390,6 +648,12 @@ func unmarshalAttribute( value = reflect.ValueOf(attribute) fieldType := structField.Type + // Handle NullableAttr[T] + if strings.HasPrefix(fieldValue.Type().Name(), "NullableAttr[") { + value, err = handleNullable(attribute, args, structField, fieldValue) + return + } + // Handle field of type []string if fieldValue.Type() == reflect.TypeOf([]string{}) { value, err = handleStringSlice(attribute) @@ -403,6 +667,10 @@ func unmarshalAttribute( return } + if fieldValue.Type().Kind() == reflect.Interface { + return reflect.ValueOf(attribute), nil + } + // Handle field of type struct if fieldValue.Type().Kind() == reflect.Struct { value, err = handleStruct(attribute, fieldValue) @@ -416,6 +684,12 @@ func unmarshalAttribute( return } + if fieldValue.Type().Kind() == reflect.Slice && + reflect.TypeOf(fieldValue.Interface()).Elem().Kind() == reflect.Ptr { + value, err = handleStructPointerSlice(attribute, args, fieldValue) + return + } + // JSON value was a float (numeric) if value.Kind() == reflect.Float64 { value, err = handleNumeric(attribute, fieldType, fieldValue) @@ -447,6 +721,30 @@ func handleStringSlice(attribute interface{}) (reflect.Value, error) { return reflect.ValueOf(values), nil } +func handleNullable( + attribute interface{}, + args []string, + structField reflect.StructField, + fieldValue reflect.Value) (reflect.Value, error) { + + if a, ok := attribute.(string); ok && a == "null" { + return reflect.ValueOf(nil), nil + } + + innerType := fieldValue.Type().Elem() + zeroValue := reflect.Zero(innerType) + + attrVal, err := unmarshalAttribute(attribute, args, structField, zeroValue) + if err != nil { + return reflect.ValueOf(nil), err + } + + fieldValue.Set(reflect.MakeMapWithSize(fieldValue.Type(), 1)) + fieldValue.SetMapIndex(reflect.ValueOf(true), attrVal) + + return fieldValue, nil +} + func handleTime(attribute interface{}, args []string, fieldValue reflect.Value) (reflect.Value, error) { var isISO8601, isRFC3339 bool v := reflect.ValueOf(attribute) @@ -654,3 +952,22 @@ func handleStructSlice( return models, nil } + +func handleStructPointerSlice( + attribute interface{}, + args []string, + fieldValue reflect.Value) (reflect.Value, error) { + + dataMap := reflect.ValueOf(attribute).Interface().([]interface{}) + models := reflect.New(fieldValue.Type()).Elem() + for _, data := range dataMap { + model := reflect.New(fieldValue.Type().Elem()).Elem() + value, err := handleStruct(data, model) + if err != nil { + continue + } + + models = reflect.Append(models, value) + } + return models, nil +} diff --git a/request_test.go b/request_test.go index 300c7de3..7fa0d3ec 100644 --- a/request_test.go +++ b/request_test.go @@ -8,6 +8,7 @@ import ( "io" "reflect" "sort" + "strconv" "strings" "testing" "time" @@ -46,6 +47,60 @@ func TestUnmarshall_attrStringSlice(t *testing.T) { } } +func TestUnmarshall_attrInterface(t *testing.T) { + tests := []struct { + genericData interface{} + expected reflect.Kind + }{ + { + genericData: "foo", + expected: reflect.String, + }, + { + genericData: true, + expected: reflect.Bool, + }, + { + genericData: float64(5), + expected: reflect.Float64, + }, + { + genericData: []string{"foo", "bar"}, + expected: reflect.Slice, + }, + { + genericData: map[string]string{ + "foo": "bar", + }, + expected: reflect.Map, + }, + } + + for _, tc := range tests { + out := &GenericInterface{} + data := map[string]interface{}{ + "data": map[string]interface{}{ + "type": "generic", + "id": "1", + "attributes": map[string]interface{}{ + "interface": tc.genericData, + }, + }, + } + b, err := json.Marshal(data) + if err != nil { + t.Fatal(err) + } + + if err := UnmarshalPayload(bytes.NewReader(b), out); err != nil { + t.Fatal(err) + } + if reflect.TypeOf(out.Data).Kind() != tc.expected { + t.Fatalf("Expected %v to match interface %v", out.Data, tc.expected) + } + } +} + func TestUnmarshalToStructWithPointerAttr(t *testing.T) { out := new(WithPointer) in := map[string]interface{}{ @@ -246,6 +301,209 @@ func TestStringPointerField(t *testing.T) { } } +func TestUnmarshalNullableTime(t *testing.T) { + aTime := time.Date(2016, 8, 17, 8, 27, 12, 23849, time.UTC) + + out := new(WithNullableAttrs) + + attrs := map[string]interface{}{ + "name": "Name", + "int_time": aTime.Unix(), + "rfc3339_time": aTime.Format(time.RFC3339), + "iso8601_time": aTime.Format(iso8601TimeFormat), + } + + if err := UnmarshalPayload(samplePayloadWithNullableAttrs(attrs), out); err != nil { + t.Fatal(err) + } + + if out.IntTime == nil { + t.Fatal("Was not expecting a nil pointer for out.IntTime") + } + + timeVal, err := out.IntTime.Get() + if err != nil { + t.Fatal(err) + } + + if expected, actual := aTime, timeVal; expected.Equal(actual) { + t.Fatalf("Was expecting int_time to be `%s`, got `%s`", expected, actual) + } + + timeVal, err = out.IntTime.Get() + if err != nil { + t.Fatal(err) + } + + if out.RFC3339Time == nil { + t.Fatal("Was not expecting a nil pointer for out.RFC3339Time") + } + if expected, actual := aTime, timeVal; expected.Equal(actual) { + t.Fatalf("Was expecting descript to be `%s`, got `%s`", expected, actual) + } + + timeVal, err = out.IntTime.Get() + if err != nil { + t.Fatal(err) + } + + if out.ISO8601Time == nil { + t.Fatal("Was not expecting a nil pointer for out.ISO8601Time") + } + if expected, actual := aTime, timeVal; expected.Equal(actual) { + t.Fatalf("Was expecting descript to be `%s`, got `%s`", expected, actual) + } +} + +func TestUnmarshalNullableBool(t *testing.T) { + out := new(WithNullableAttrs) + + aBool := false + + attrs := map[string]interface{}{ + "name": "Name", + "bool": aBool, + } + + if err := UnmarshalPayload(samplePayloadWithNullableAttrs(attrs), out); err != nil { + t.Fatal(err) + } + + if out.Bool == nil { + t.Fatal("Was not expecting a nil pointer for out.Bool") + } + + boolVal, err := out.Bool.Get() + if err != nil { + t.Fatal(err) + } + + if expected, actual := aBool, boolVal; expected != actual { + t.Fatalf("Was expecting bool to be `%t`, got `%t`", expected, actual) + } +} + +func TestUnmarshalNullableRelationshipsNonNullValue(t *testing.T) { + comment := &Comment{ + ID: 5, + Body: "Hello World", + } + + payload := &OnePayload{ + Data: &Node{ + ID: "10", + Type: "with-nullables", + Relationships: map[string]interface{}{ + "nullable_comment": &RelationshipOneNode{ + Data: &Node{ + Type: "comments", + ID: strconv.Itoa(comment.ID), + }, + }, + }, + }, + } + + outBuf := bytes.NewBuffer(nil) + json.NewEncoder(outBuf).Encode(payload) + + out := new(WithNullableAttrs) + + if err := UnmarshalPayload(outBuf, out); err != nil { + t.Fatal(err) + } + + nullableCommentOpt := out.NullableComment + if !nullableCommentOpt.IsSpecified() { + t.Fatal("Expected NullableComment to be specified") + } + + nullableComment, err := nullableCommentOpt.Get() + if err != nil { + t.Fatal(err) + } + + if expected, actual := comment.ID, nullableComment.ID; expected != actual { + t.Fatalf("Was expecting NullableComment to be `%d`, got `%d`", expected, actual) + } +} + +func TestUnmarshalNullableRelationshipsExplicitNullValue(t *testing.T) { + payload := &OnePayload{ + Data: &Node{ + ID: "10", + Type: "with-nullables", + Relationships: map[string]interface{}{ + "nullable_comment": &RelationshipOneNode{ + Data: nil, + }, + }, + }, + } + + outBuf := bytes.NewBuffer(nil) + json.NewEncoder(outBuf).Encode(payload) + + out := new(WithNullableAttrs) + + if err := UnmarshalPayload(outBuf, out); err != nil { + t.Fatal(err) + } + + nullableCommentOpt := out.NullableComment + if !nullableCommentOpt.IsSpecified() || !nullableCommentOpt.IsNull() { + t.Fatal("Expected NullableComment to be specified and explicit null") + } + +} + +func TestUnmarshalNullableRelationshipsNonExistentValue(t *testing.T) { + payload := &OnePayload{ + Data: &Node{ + ID: "10", + Type: "with-nullables", + Relationships: map[string]interface{}{}, + }, + } + + outBuf := bytes.NewBuffer(nil) + json.NewEncoder(outBuf).Encode(payload) + + out := new(WithNullableAttrs) + + if err := UnmarshalPayload(outBuf, out); err != nil { + t.Fatal(err) + } + + nullableCommentOpt := out.NullableComment + if nullableCommentOpt.IsSpecified() || nullableCommentOpt.IsNull() { + t.Fatal("Expected NullableComment to NOT be specified and NOT be explicit null") + } +} + +func TestUnmarshalNullableRelationshipsNoRelationships(t *testing.T) { + payload := &OnePayload{ + Data: &Node{ + ID: "10", + Type: "with-nullables", + }, + } + + outBuf := bytes.NewBuffer(nil) + json.NewEncoder(outBuf).Encode(payload) + + out := new(WithNullableAttrs) + + if err := UnmarshalPayload(outBuf, out); err != nil { + t.Fatal(err) + } + + nullableCommentOpt := out.NullableComment + if nullableCommentOpt.IsSpecified() || nullableCommentOpt.IsNull() { + t.Fatal("Expected NullableComment to NOT be specified and NOT be explicit null") + } +} + func TestMalformedTag(t *testing.T) { out := new(BadModel) err := UnmarshalPayload(samplePayload(), out) @@ -553,6 +811,229 @@ func TestUnmarshalRelationships(t *testing.T) { } } +func Test_UnmarshalPayload_polymorphicRelations(t *testing.T) { + in := bytes.NewReader([]byte(`{ + "data": { + "type": "blogs", + "id": "3", + "attributes": { + "title": "Hello, World" + }, + "relationships": { + "hero-media": { + "data": { + "type": "videos", + "id": "1" + } + }, + "media": { + "data": [ + { + "type": "images", + "id": "1" + }, + { + "type": "videos", + "id": "2" + } + ] + } + } + }, + "included": [ + { + "type": "videos", + "id": "1", + "attributes": { + "captions": "It's Awesome!" + } + }, + { + "type": "images", + "id": "1", + "attributes": { + "src": "/media/clear1x1.gif" + } + }, + { + "type": "videos", + "id": "2", + "attributes": { + "captions": "Oh, I didn't see you there" + } + } + ] + }`)) + out := new(BlogPostWithPoly) + + if err := UnmarshalPayload(in, out); err != nil { + t.Fatal(err) + } + + if out.Title != "Hello, World" { + t.Errorf("expected Title %q but got %q", "Hello, World", out.Title) + } + + if out.Hero.Image != nil { + t.Errorf("expected Hero image to be nil but got %+v", out.Hero.Image) + } + + if out.Hero.Video == nil || out.Hero.Video.Captions != "It's Awesome!" { + t.Errorf("expected Hero to be the expected video relation but got %+v", out.Hero.Video) + } + + // Unmarshals included records + if out.Media[0].Image == nil || out.Media[0].Image.Src != "/media/clear1x1.gif" { + t.Errorf("expected Media 0 to be the expected image relation but got %+v", out.Media[0].Image) + } + + if out.Media[1].Video == nil || out.Media[1].Video.Captions != "Oh, I didn't see you there" { + t.Errorf("expected Media 1 to be the expected video relation but got %+v", out.Media[1].Video) + } +} + +func Test_UnmarshalPayload_polymorphicRelations_no_choice(t *testing.T) { + type pointerToOne struct { + ID string `jsonapi:"primary,blogs"` + Title string `jsonapi:"attr,title"` + Hero *OneOfMedia `jsonapi:"polyrelation,hero-media,omitempty"` + } + + in := bytes.NewReader([]byte(`{ + "data": { + "type": "blogs", + "id": "3", + "attributes": { + "title": "Hello, World" + }, + "relationships": { + "hero-media": { + "data": { + "type": "absolutely-not", + "id": "1", + "attributes": { + "captions": "It's Awesome!" + } + } + } + } + } + }`)) + out := new(pointerToOne) + + if err := UnmarshalPayload(in, out); err != nil { + t.Fatal(err) + } + + if out.Title != "Hello, World" { + t.Errorf("expected Title %q but got %q", "Hello, World", out.Title) + } + + if out.Hero == nil { + t.Fatal("expected Hero to not be nil") + } + + if out.Hero.Image != nil || out.Hero.Video != nil { + t.Fatal("expected both Hero fields to be nil") + } +} + +func Test_UnmarshalPayload_polymorphicRelations_omitted(t *testing.T) { + type pointerToOne struct { + ID string `jsonapi:"primary,blogs"` + Title string `jsonapi:"attr,title"` + Hero *OneOfMedia `jsonapi:"polyrelation,hero-media"` + } + + in := bytes.NewReader([]byte(`{ + "data": { + "type": "blogs", + "id": "3", + "attributes": { + "title": "Hello, World" + } + } + }`)) + out := new(pointerToOne) + + if err := UnmarshalPayload(in, out); err != nil { + t.Fatal(err) + } + + if out.Title != "Hello, World" { + t.Errorf("expected Title %q but got %q", "Hello, World", out.Title) + } + + if out.Hero != nil { + t.Fatalf("expected Hero to be nil, but got %+v", out.Hero) + } +} + +func Test_UnmarshalPayload_polymorphicRelations_deprecatedRelation(t *testing.T) { + type withDeprecatedRelation struct { + ID string `jsonapi:"primary,blogs"` + Title string `jsonapi:"attr,title"` + Media *OneOfMedia `jsonapi:"polyrelation,media"` + Image *Image `jsonapi:"relation,media"` // Deprecated + } + + in := bytes.NewReader([]byte(`{ + "data": [{ + "type": "blogs", + "id": "3", + "attributes": { + "title": "Hello, World" + }, + "relationships": { + "media": { + "data": { + "type": "videos", + "id": "123" + } + } + } + }] + }`)) + + model := reflect.TypeOf(new(withDeprecatedRelation)) + + out, err := UnmarshalManyPayload(in, model) + if err != nil { + t.Fatal(err) + } + + result := out[0].(*withDeprecatedRelation) + + if result.Title != "Hello, World" { + t.Errorf("expected Title %q but got %q", "Hello, World", result.Title) + } + + if result.Media.Video.ID != "123" { + t.Fatalf("expected Video to be \"123\", but got %+v", result.Media.Video) + } +} + +func Test_choiceStructMapping(t *testing.T) { + cases := []struct { + val reflect.Type + }{ + {val: reflect.TypeOf(&OneOfMedia{})}, + {val: reflect.TypeOf([]*OneOfMedia{{}})}, + } + + for _, c := range cases { + result := choiceStructMapping(c.val) + imageField, ok := result["images"] + if !ok || imageField.FieldNum != 0 { + t.Errorf("expected \"images\" to be the first field, but got %d", imageField.FieldNum) + } + videoField, ok := result["videos"] + if !ok || videoField.FieldNum != 2 { + t.Errorf("expected \"videos\" to be the third field, but got %d", videoField.FieldNum) + } + } +} + func TestUnmarshalNullRelationship(t *testing.T) { sample := map[string]interface{}{ "data": map[string]interface{}{ @@ -745,6 +1226,35 @@ func TestUnmarshalNestedRelationshipsEmbedded_withClientIDs(t *testing.T) { } } +func TestUnmarshalLinks(t *testing.T) { + model := new(Blog) + + if err := UnmarshalPayload(samplePayload(), model); err != nil { + t.Fatal(err) + } + + if model.Links == nil { + t.Fatalf("Expected Links field on model to be set") + } + + if e, a := "http://somesite.com/blogs/1", model.Links[KeySelfLink]; e != a { + t.Fatalf("Was expecting links.%s to have a value of %s, got %s", KeySelfLink, e, a) + } + + if e, a := "http://somesite.com/posts/1", model.Posts[0].Links[KeySelfLink]; e != a { + t.Fatalf("Was expecting posts.0.links.%s to have a value of %s, got %s", KeySelfLink, e, a) + } + + expectedLinkObject := Link{Href: "http://somesite.com/posts/2", Meta: Meta{"foo": "bar"}} + if e, a := expectedLinkObject, model.CurrentPost.Links[KeySelfLink]; !reflect.DeepEqual(e, a) { + t.Fatalf("Was expecting posts.0.links.%s to have a value of %s, got %s", KeySelfLink, e, a) + } + + if e, a := "http://somesite.com/comments/1", model.CurrentPost.Comments[0].Links[KeySelfLink]; e != a { + t.Fatalf("Was expecting posts.0.links.%s to have a value of %s, got %s", KeySelfLink, e, a) + } +} + func unmarshalSamplePayload() (*Blog, error) { in := samplePayload() out := new(Blog) @@ -801,6 +1311,32 @@ func TestUnmarshalManyPayload(t *testing.T) { } } +func TestOnePayload_withLinks(t *testing.T) { + rawJSON := []byte("{\"data\": { \"type\": \"posts\", \"id\": \"1\", \"attributes\": { \"body\": \"First\", \"title\": \"Post\" } }, \"links\": { \"self\": \"http://somesite.com/posts/1\" } }") + + in := bytes.NewReader(rawJSON) + + payload := new(OnePayload) + if err := json.NewDecoder(in).Decode(payload); err != nil { + t.Fatal(err) + } + + if payload.Links == nil { + t.Fatal("Was expecting a non nil ptr Link field") + } + + links := *payload.Links + + self, ok := links[KeySelfLink] + if !ok { + t.Fatal("Was expecting a non nil 'self' link field") + } + if e, a := "http://somesite.com/posts/1", self; e != a { + t.Fatalf("Was expecting links.%s to have a value of %s, got %s", KeySelfLink, e, a) + } + +} + func TestManyPayload_withLinks(t *testing.T) { firstPageURL := "http://somesite.com/movies?page[limit]=50&page[offset]=50" prevPageURL := "http://somesite.com/movies?page[limit]=50&page[offset]=0" @@ -1016,6 +1552,9 @@ func samplePayload() io.Reader { "body": "Bar", }, ClientID: "1", + Links: &Links{ + "self": "http://somesite.com/posts/1", + }, }, { Type: "posts", @@ -1024,6 +1563,9 @@ func samplePayload() io.Reader { "body": "Y", }, ClientID: "2", + Links: &Links{ + "self": "http://somesite.com/posts/2", + }, }, }, }, @@ -1044,6 +1586,9 @@ func samplePayload() io.Reader { "body": "Great post!", }, ClientID: "4", + Links: &Links{ + "self": "http://somesite.com/comments/1", + }, }, { Type: "comments", @@ -1051,13 +1596,27 @@ func samplePayload() io.Reader { "body": "Needs some work!", }, ClientID: "5", + Links: &Links{ + "self": "http://somesite.com/comments/2", + }, }, }, }, }, + Links: &Links{ + "self": &Link{ + Href: "http://somesite.com/posts/2", + Meta: Meta{ + "foo": "bar", + }, + }, + }, }, }, }, + Links: &Links{ + "self": "http://somesite.com/blogs/1", + }, }, } @@ -1115,6 +1674,21 @@ func sampleWithPointerPayload(m map[string]interface{}) io.Reader { return out } +func samplePayloadWithNullableAttrs(m map[string]interface{}) io.Reader { + payload := &OnePayload{ + Data: &Node{ + ID: "5", + Type: "with-nullables", + Attributes: m, + }, + } + + out := bytes.NewBuffer(nil) + json.NewEncoder(out).Encode(payload) + + return out +} + func testModel() *Blog { return &Blog{ ID: 5, @@ -1355,7 +1929,6 @@ func TestUnmarshalNestedStruct(t *testing.T) { } func TestUnmarshalNestedStructSlice(t *testing.T) { - fry := map[string]interface{}{ "firstname": "Philip J.", "surname": "Fry", @@ -1416,3 +1989,60 @@ func TestUnmarshalNestedStructSlice(t *testing.T) { out.Teams[0].Members[0].Firstname) } } + +func TestUnmarshalNestedStructPointerSlice(t *testing.T) { + personA := map[string]interface{}{ + "name": "persona", + "age": 25, + } + + personB := map[string]interface{}{ + "name": "personb", + "age": 19, + } + + sample := map[string]interface{}{ + "data": map[string]interface{}{ + "type": "companies", + "id": "123", + "attributes": map[string]interface{}{ + "name": "Planet Express", + "people": []interface{}{ + personA, + personB, + }, + }, + }, + } + + data, err := json.Marshal(sample) + if err != nil { + t.Fatal(err) + } + in := bytes.NewReader(data) + out := new(Company) + + if err := UnmarshalPayload(in, out); err != nil { + t.Fatal(err) + } + + if len(out.People) != 2 { + t.Fatalf("Length of people should be 2, but is instead %d", len(out.People)) + } + + if out.People[0].Name != "persona" { + t.Fatalf("Nested pointer struct not unmarshalled: Expected `persona` but got `%s`", out.People[0].Name) + } + + if out.People[0].Age != 25 { + t.Fatalf("Nested pointer struct not unmarshalled: Expected `25` but got `%d`", out.People[0].Age) + } + + if out.People[1].Name != "personb" { + t.Fatalf("Nested pointer struct not unmarshalled: Expected `personb` but got `%s`", out.People[1].Name) + } + + if out.People[1].Age != 19 { + t.Fatalf("Nested pointer struct not unmarshalled: Expected `19` but got `%d`", out.People[1].Age) + } +} diff --git a/response.go b/response.go index b44e4e97..c749aebe 100644 --- a/response.go +++ b/response.go @@ -26,6 +26,8 @@ var ( // ErrUnexpectedType is returned when marshalling an interface; the interface // had to be a pointer or a slice; otherwise this error is returned. ErrUnexpectedType = errors.New("models should be a struct pointer or slice of struct pointers") + // ErrUnexpectedNil is returned when a slice of relation structs contains nil values + ErrUnexpectedNil = errors.New("slice of struct pointers cannot contain nil") ) // MarshalPayload writes a jsonapi response for one or many records. The @@ -51,17 +53,16 @@ var ( // Many Example: you could pass it, w, your http.ResponseWriter, and, models, a // slice of Blog struct instance pointers to be written to the response body: // -// func ListBlogs(w http.ResponseWriter, r *http.Request) { -// blogs := []*Blog{} +// func ListBlogs(w http.ResponseWriter, r *http.Request) { +// blogs := []*Blog{} // -// w.Header().Set("Content-Type", jsonapi.MediaType) -// w.WriteHeader(http.StatusOK) +// w.Header().Set("Content-Type", jsonapi.MediaType) +// w.WriteHeader(http.StatusOK) // -// if err := jsonapi.MarshalPayload(w, blogs); err != nil { -// http.Error(w, err.Error(), http.StatusInternalServerError) +// if err := jsonapi.MarshalPayload(w, blogs); err != nil { +// http.Error(w, err.Error(), http.StatusInternalServerError) +// } // } -// } -// func MarshalPayload(w io.Writer, models interface{}) error { payload, err := Marshal(models) if err != nil { @@ -192,30 +193,357 @@ func MarshalOnePayloadEmbedded(w io.Writer, model interface{}) error { return json.NewEncoder(w).Encode(payload) } +// selectChoiceTypeStructField returns the first non-nil struct pointer field in the +// specified struct value that has a jsonapi type field defined within it. +// An error is returned if there are no fields matching that definition. +func selectChoiceTypeStructField(structValue reflect.Value) (reflect.Value, error) { + for i := 0; i < structValue.NumField(); i++ { + choiceFieldValue := structValue.Field(i) + choiceTypeField := choiceFieldValue.Type() + + // Must be a pointer + if choiceTypeField.Kind() != reflect.Ptr { + continue + } + + // Must not be nil + if choiceFieldValue.IsNil() { + continue + } + + subtype := choiceTypeField.Elem() + _, err := jsonapiTypeOfModel(subtype) + if err == nil { + return choiceFieldValue, nil + } + } + + return reflect.Value{}, errors.New("no non-nil choice field was found in the specified struct") +} + +func visitModelNodeAttribute(args []string, node *Node, fieldValue reflect.Value) error { + var omitEmpty, iso8601, rfc3339 bool + + if len(args) > 2 { + for _, arg := range args[2:] { + switch arg { + case annotationOmitEmpty: + omitEmpty = true + case annotationISO8601: + iso8601 = true + case annotationRFC3339: + rfc3339 = true + } + } + } + + if node.Attributes == nil { + node.Attributes = make(map[string]interface{}) + } + + // Handle NullableAttr[T] + if strings.HasPrefix(fieldValue.Type().Name(), "NullableAttr[") { + // handle unspecified + if fieldValue.IsNil() { + return nil + } + + // handle null + if fieldValue.MapIndex(reflect.ValueOf(false)).IsValid() { + node.Attributes[args[1]] = json.RawMessage("null") + return nil + } else { + + // handle value + fieldValue = fieldValue.MapIndex(reflect.ValueOf(true)) + } + } + + if fieldValue.Type() == reflect.TypeOf(time.Time{}) { + t := fieldValue.Interface().(time.Time) + + if t.IsZero() { + return nil + } + + if iso8601 { + node.Attributes[args[1]] = t.UTC().Format(iso8601TimeFormat) + } else if rfc3339 { + node.Attributes[args[1]] = t.UTC().Format(time.RFC3339) + } else { + node.Attributes[args[1]] = t.Unix() + } + } else if fieldValue.Type() == reflect.TypeOf(new(time.Time)) { + // A time pointer may be nil + if fieldValue.IsNil() { + if omitEmpty { + return nil + } + + node.Attributes[args[1]] = nil + } else { + tm := fieldValue.Interface().(*time.Time) + + if tm.IsZero() && omitEmpty { + return nil + } + + if iso8601 { + node.Attributes[args[1]] = tm.UTC().Format(iso8601TimeFormat) + } else if rfc3339 { + node.Attributes[args[1]] = tm.UTC().Format(time.RFC3339) + } else { + node.Attributes[args[1]] = tm.Unix() + } + } + } else { + // Dealing with a fieldValue that is not a time + emptyValue := reflect.Zero(fieldValue.Type()) + + // See if we need to omit this field + if omitEmpty && reflect.DeepEqual(fieldValue.Interface(), emptyValue.Interface()) { + return nil + } + + isStruct := fieldValue.Type().Kind() == reflect.Struct + isPointerToStruct := fieldValue.Type().Kind() == reflect.Pointer && fieldValue.Elem().Kind() == reflect.Struct + isSliceOfStruct := fieldValue.Type().Kind() == reflect.Slice && fieldValue.Type().Elem().Kind() == reflect.Struct + isSliceOfPointerToStruct := fieldValue.Type().Kind() == reflect.Slice && fieldValue.Type().Elem().Kind() == reflect.Pointer && fieldValue.Type().Elem().Elem().Kind() == reflect.Struct + + if isSliceOfStruct || isSliceOfPointerToStruct { + if fieldValue.Len() == 0 && omitEmpty { + return nil + } + // Nested slice of object attributes + manyNested, err := visitModelNodeRelationships(fieldValue, nil, false) + if err != nil { + return fmt.Errorf("failed to marshal slice of nested attribute %q: %w", args[1], err) + } + nestedNodes := make([]any, len(manyNested.Data)) + for i, n := range manyNested.Data { + nestedNodes[i] = n.Attributes + } + node.Attributes[args[1]] = nestedNodes + } else if isStruct || isPointerToStruct { + // Nested object attribute + nested, err := visitModelNode(fieldValue.Interface(), nil, false) + if err != nil { + return fmt.Errorf("failed to marshal nested attribute %q: %w", args[1], err) + } + node.Attributes[args[1]] = nested.Attributes + } else { + // Primitive attribute + strAttr, ok := fieldValue.Interface().(string) + if ok { + node.Attributes[args[1]] = strAttr + } else { + node.Attributes[args[1]] = fieldValue.Interface() + } + } + } + + return nil +} + +func visitModelNodeRelation(model any, annotation string, args []string, node *Node, fieldValue reflect.Value, included *map[string]*Node, sideload bool) error { + var omitEmpty bool + + //add support for 'omitempty' struct tag for marshaling as absent + if len(args) > 2 { + omitEmpty = args[2] == annotationOmitEmpty + } + + if node.Relationships == nil { + node.Relationships = make(map[string]interface{}) + } + + // Handle NullableRelationship[T] + if strings.HasPrefix(fieldValue.Type().Name(), "NullableRelationship[") { + + if fieldValue.MapIndex(reflect.ValueOf(false)).IsValid() { + innerTypeIsSlice := fieldValue.MapIndex(reflect.ValueOf(false)).Type().Kind() == reflect.Slice + // handle explicit null + if innerTypeIsSlice { + node.Relationships[args[1]] = json.RawMessage("[]") + } else { + node.Relationships[args[1]] = json.RawMessage("{\"data\":null}") + } + } else if fieldValue.MapIndex(reflect.ValueOf(true)).IsValid() { + // handle value + fieldValue = fieldValue.MapIndex(reflect.ValueOf(true)) + } + } + + isSlice := fieldValue.Type().Kind() == reflect.Slice + if omitEmpty && + (isSlice && fieldValue.Len() < 1 || + (!isSlice && fieldValue.IsNil())) { + return nil + } + + if annotation == annotationPolyRelation { + // for polyrelation, we'll snoop out the actual relation model + // through the choice type value by choosing the first non-nil + // field that has a jsonapi type annotation and overwriting + // `fieldValue` so normal annotation-assisted marshaling + // can continue + if !isSlice { + choiceValue := fieldValue + + // must be a pointer type + if choiceValue.Type().Kind() != reflect.Ptr { + return ErrUnexpectedType + } + + if choiceValue.IsNil() { + fieldValue = reflect.ValueOf(nil) + } + structValue := choiceValue.Elem() + + // Short circuit if field is omitted from model + if !structValue.IsValid() { + return nil + } + + if found, err := selectChoiceTypeStructField(structValue); err == nil { + fieldValue = found + } + } else { + // A slice polyrelation field can be... polymorphic... meaning + // that we might snoop different types within each slice element. + // Each snooped value will added to this collection and then + // the recursion will take care of the rest. The only special case + // is nil. For that, we'll just choose the first + collection := make([]interface{}, 0) + + for i := 0; i < fieldValue.Len(); i++ { + itemValue := fieldValue.Index(i) + // Once again, must be a pointer type + if itemValue.Type().Kind() != reflect.Ptr { + return ErrUnexpectedType + } + + if itemValue.IsNil() { + return ErrUnexpectedNil + } + + structValue := itemValue.Elem() + + if found, err := selectChoiceTypeStructField(structValue); err == nil { + collection = append(collection, found.Interface()) + } + } + + fieldValue = reflect.ValueOf(collection) + } + } + + var relLinks *Links + if linkableModel, ok := model.(RelationshipLinkable); ok { + relLinks = linkableModel.JSONAPIRelationshipLinks(args[1]) + } + + var relMeta *Meta + if metableModel, ok := model.(RelationshipMetable); ok { + relMeta = metableModel.JSONAPIRelationshipMeta(args[1]) + } + + if isSlice { + // to-many relationship + relationship, err := visitModelNodeRelationships( + fieldValue, + included, + sideload, + ) + if err != nil { + return err + } + relationship.Links = relLinks + relationship.Meta = relMeta + + if sideload { + shallowNodes := []*Node{} + for _, n := range relationship.Data { + appendIncluded(included, n) + shallowNodes = append(shallowNodes, toShallowNode(n)) + } + + node.Relationships[args[1]] = &RelationshipManyNode{ + Data: shallowNodes, + Links: relationship.Links, + Meta: relationship.Meta, + } + } else { + node.Relationships[args[1]] = relationship + } + } else { + // to-one relationships + + // Handle null relationship case + if fieldValue.IsNil() { + node.Relationships[args[1]] = &RelationshipOneNode{Data: nil} + return nil + } + + relationship, err := visitModelNode( + fieldValue.Interface(), + included, + sideload, + ) + + if err != nil { + return err + } + + if sideload { + appendIncluded(included, relationship) + node.Relationships[args[1]] = &RelationshipOneNode{ + Data: toShallowNode(relationship), + Links: relLinks, + Meta: relMeta, + } + } else { + node.Relationships[args[1]] = &RelationshipOneNode{ + Data: relationship, + Links: relLinks, + Meta: relMeta, + } + } + } + return nil +} + func visitModelNode(model interface{}, included *map[string]*Node, sideload bool) (*Node, error) { node := new(Node) var er error + var modelValue reflect.Value + var modelType reflect.Type value := reflect.ValueOf(model) - if value.IsNil() { - return nil, nil - } - modelValue := value.Elem() - modelType := value.Type().Elem() + if value.Type().Kind() == reflect.Pointer { + if value.IsNil() { + return nil, nil + } + modelValue = value.Elem() + modelType = value.Type().Elem() + } else { + modelValue = value + modelType = value.Type() + } for i := 0; i < modelValue.NumField(); i++ { + fieldValue := modelValue.Field(i) structField := modelValue.Type().Field(i) tag := structField.Tag.Get(annotationJSONAPI) if tag == "" { continue } - fieldValue := modelValue.Field(i) fieldType := modelType.Field(i) - args := strings.Split(tag, annotationSeperator) + args := strings.Split(tag, annotationSeparator) if len(args) < 1 { er = ErrBadJSONAPIStructTag @@ -283,171 +611,18 @@ func visitModelNode(model interface{}, included *map[string]*Node, node.ClientID = clientID } } else if annotation == annotationAttribute { - var omitEmpty, iso8601, rfc3339 bool - - if len(args) > 2 { - for _, arg := range args[2:] { - switch arg { - case annotationOmitEmpty: - omitEmpty = true - case annotationISO8601: - iso8601 = true - case annotationRFC3339: - rfc3339 = true - } - } - } - - if node.Attributes == nil { - node.Attributes = make(map[string]interface{}) - } - - if fieldValue.Type() == reflect.TypeOf(time.Time{}) { - t := fieldValue.Interface().(time.Time) - - if t.IsZero() { - continue - } - - if iso8601 { - node.Attributes[args[1]] = t.UTC().Format(iso8601TimeFormat) - } else if rfc3339 { - node.Attributes[args[1]] = t.UTC().Format(time.RFC3339) - } else { - node.Attributes[args[1]] = t.Unix() - } - } else if fieldValue.Type() == reflect.TypeOf(new(time.Time)) { - // A time pointer may be nil - if fieldValue.IsNil() { - if omitEmpty { - continue - } - - node.Attributes[args[1]] = nil - } else { - tm := fieldValue.Interface().(*time.Time) - - if tm.IsZero() && omitEmpty { - continue - } - - if iso8601 { - node.Attributes[args[1]] = tm.UTC().Format(iso8601TimeFormat) - } else if rfc3339 { - node.Attributes[args[1]] = tm.UTC().Format(time.RFC3339) - } else { - node.Attributes[args[1]] = tm.Unix() - } - } - } else { - // Dealing with a fieldValue that is not a time - emptyValue := reflect.Zero(fieldValue.Type()) - - // See if we need to omit this field - if omitEmpty && reflect.DeepEqual(fieldValue.Interface(), emptyValue.Interface()) { - continue - } - - strAttr, ok := fieldValue.Interface().(string) - if ok { - node.Attributes[args[1]] = strAttr - } else { - node.Attributes[args[1]] = fieldValue.Interface() - } - } - } else if annotation == annotationRelation { - var omitEmpty bool - - //add support for 'omitempty' struct tag for marshaling as absent - if len(args) > 2 { - omitEmpty = args[2] == annotationOmitEmpty - } - - isSlice := fieldValue.Type().Kind() == reflect.Slice - if omitEmpty && - (isSlice && fieldValue.Len() < 1 || - (!isSlice && fieldValue.IsNil())) { - continue - } - - if node.Relationships == nil { - node.Relationships = make(map[string]interface{}) - } - - var relLinks *Links - if linkableModel, ok := model.(RelationshipLinkable); ok { - relLinks = linkableModel.JSONAPIRelationshipLinks(args[1]) - } - - var relMeta *Meta - if metableModel, ok := model.(RelationshipMetable); ok { - relMeta = metableModel.JSONAPIRelationshipMeta(args[1]) + er = visitModelNodeAttribute(args, node, fieldValue) + if er != nil { + break } - - if isSlice { - // to-many relationship - relationship, err := visitModelNodeRelationships( - fieldValue, - included, - sideload, - ) - if err != nil { - er = err - break - } - relationship.Links = relLinks - relationship.Meta = relMeta - - if sideload { - shallowNodes := []*Node{} - for _, n := range relationship.Data { - appendIncluded(included, n) - shallowNodes = append(shallowNodes, toShallowNode(n)) - } - - node.Relationships[args[1]] = &RelationshipManyNode{ - Data: shallowNodes, - Links: relationship.Links, - Meta: relationship.Meta, - } - } else { - node.Relationships[args[1]] = relationship - } - } else { - // to-one relationships - - // Handle null relationship case - if fieldValue.IsNil() { - node.Relationships[args[1]] = &RelationshipOneNode{Data: nil} - continue - } - - relationship, err := visitModelNode( - fieldValue.Interface(), - included, - sideload, - ) - if err != nil { - er = err - break - } - - if sideload { - appendIncluded(included, relationship) - node.Relationships[args[1]] = &RelationshipOneNode{ - Data: toShallowNode(relationship), - Links: relLinks, - Meta: relMeta, - } - } else { - node.Relationships[args[1]] = &RelationshipOneNode{ - Data: relationship, - Links: relLinks, - Meta: relMeta, - } - } + } else if annotation == annotationRelation || annotation == annotationPolyRelation { + er = visitModelNodeRelation(model, annotation, args, node, fieldValue, included, sideload) + if er != nil { + break } - + } else if annotation == annotationLinks { + // Nothing. Ignore this field, as Links fields are only for unmarshaling requests. + // The Linkable interface methods are used for marshaling data in a response. } else { er = ErrBadJSONAPIStructTag break @@ -473,11 +648,23 @@ func visitModelNode(model interface{}, included *map[string]*Node, return node, nil } +// toShallowNode takes a node and returns a shallow version of the node. +// If the ID is empty, we include attributes into the shallow version. +// +// An example of where this is useful would be if an object +// within a relationship can be created at the same time as +// the root node. +// +// This is not 1.0 jsonapi spec compliant--it's a bespoke variation on +// resource object identifiers discussed in the pending 1.1 spec. func toShallowNode(node *Node) *Node { - return &Node{ - ID: node.ID, - Type: node.Type, + ret := &Node{Type: node.Type} + if node.ID == "" { + ret.Attributes = node.Attributes + } else { + ret.ID = node.ID } + return ret } func visitModelNodeRelationships(models reflect.Value, included *map[string]*Node, @@ -485,7 +672,12 @@ func visitModelNodeRelationships(models reflect.Value, included *map[string]*Nod nodes := []*Node{} for i := 0; i < models.Len(); i++ { - n := models.Index(i).Interface() + model := models.Index(i) + if !model.IsValid() || (model.Kind() == reflect.Pointer && model.IsNil()) { + return nil, ErrUnexpectedNil + } + + n := model.Interface() node, err := visitModelNode(n, included, sideload) if err != nil { diff --git a/response_test.go b/response_test.go index b1d5967a..509b656d 100644 --- a/response_test.go +++ b/response_test.go @@ -6,6 +6,7 @@ import ( "fmt" "reflect" "sort" + "strconv" "testing" "time" ) @@ -38,8 +39,167 @@ func TestMarshalPayload(t *testing.T) { } } -func TestMarshalPayloadWithNulls(t *testing.T) { +func TestMarshalPayloadWithHasOnePolyrelation(t *testing.T) { + blog := &BlogPostWithPoly{ + ID: "1", + Title: "Hello, World", + Hero: &OneOfMedia{ + Image: &Image{ + ID: "2", + }, + }, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, blog); err != nil { + t.Fatal(err) + } + + var jsonData map[string]interface{} + if err := json.Unmarshal(out.Bytes(), &jsonData); err != nil { + t.Fatal(err) + } + + relationships := jsonData["data"].(map[string]interface{})["relationships"].(map[string]interface{}) + if relationships == nil { + t.Fatal("No relationships defined in unmarshaled JSON") + } + heroMedia := relationships["hero-media"].(map[string]interface{})["data"].(map[string]interface{}) + if heroMedia == nil { + t.Fatal("No hero-media relationship defined in unmarshaled JSON") + } + + if heroMedia["id"] != "2" { + t.Fatal("Expected ID \"2\" in unmarshaled JSON") + } + + if heroMedia["type"] != "images" { + t.Fatal("Expected type \"images\" in unmarshaled JSON") + } +} + +func TestMarshalPayloadWithHasManyPolyrelation(t *testing.T) { + blog := &BlogPostWithPoly{ + ID: "1", + Title: "Hello, World", + Media: []*OneOfMedia{ + { + Image: &Image{ + ID: "2", + }, + }, + { + Video: &Video{ + ID: "3", + }, + }, + }, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, blog); err != nil { + t.Fatal(err) + } + + var jsonData map[string]interface{} + if err := json.Unmarshal(out.Bytes(), &jsonData); err != nil { + t.Fatal(err) + } + + relationships := jsonData["data"].(map[string]interface{})["relationships"].(map[string]interface{}) + if relationships == nil { + t.Fatal("No relationships defined in unmarshaled JSON") + } + + heroMedia := relationships["media"].(map[string]interface{}) + if heroMedia == nil { + t.Fatal("No hero-media relationship defined in unmarshaled JSON") + } + heroMediaData := heroMedia["data"].([]interface{}) + + if len(heroMediaData) != 2 { + t.Fatal("Expected 2 items in unmarshaled JSON") + } + + imageData := heroMediaData[0].(map[string]interface{}) + videoData := heroMediaData[1].(map[string]interface{}) + + if imageData["id"] != "2" || imageData["type"] != "images" { + t.Fatal("Expected images ID \"2\" in unmarshaled JSON") + } + + if videoData["id"] != "3" || videoData["type"] != "videos" { + t.Fatal("Expected videos ID \"3\" in unmarshaled JSON") + } +} + +func TestMarshalPayloadWithHasManyPolyrelationWithNils(t *testing.T) { + blog := &BlogPostWithPoly{ + ID: "1", + Title: "Hello, World", + Media: []*OneOfMedia{ + nil, + { + Image: &Image{ + ID: "2", + }, + }, + nil, + }, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, blog); err != ErrUnexpectedNil { + t.Fatal("expected error but got none") + } +} + +func TestMarshalPayloadWithHasOneNilPolyrelation(t *testing.T) { + blog := &BlogPostWithPoly{ + ID: "1", + Title: "Hello, World", + Hero: nil, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, blog); err != nil { + t.Fatalf("expected no error but got %s", err) + } +} + +func TestMarshalPayloadWithHasOneOmittedPolyrelation(t *testing.T) { + blog := &BlogPostWithPoly{ + ID: "1", + Title: "Hello, World", + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, blog); err != nil { + t.Fatalf("expected no error but got %s", err) + } +} + +func TestMarshalPayloadWithHasOneNilRelation(t *testing.T) { + blog := &Blog{ + ID: 1, + Title: "Hello, World", + Posts: []*Post{ + nil, + { + ID: 2, + }, + nil, + }, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, blog); err != ErrUnexpectedNil { + t.Fatal("expected error but got none") + } +} + +func TestMarshalPayloadWithNulls(t *testing.T) { books := []*Book{nil, {ID: 101}, nil} var jsonData map[string]interface{} @@ -183,6 +343,86 @@ func TestWithOmitsEmptyAnnotationOnRelation(t *testing.T) { } } +func TestWithExtraFieldOnRelation(t *testing.T) { + type Book struct { + ID string `jsonapi:"primary,book"` + Title string `jsonapi:"attr,title,omitempty"` + Author string `jsonapi:"attr,author,omitempty"` + } + type Library struct { + ID int `jsonapi:"primary,library"` + CurrentBook *Book `jsonapi:"relation,book,omitempty"` + Books []*Book `jsonapi:"relation,books,omitempty"` + } + + testCases := []struct { + desc string + input Library + expected Library + }{ + { + "to-one success", + Library{ + ID: 999, + CurrentBook: &Book{ + Title: "A Good Book", + }, + }, + Library{ + ID: 999, + CurrentBook: &Book{ + Title: "A Good Book", + }, + }, + }, + { + "to-many success", + Library{ + ID: 999, + Books: []*Book{ + { + Title: "A Good Book", + }, + { + ID: "123", + Title: "Don't come back", + }, + }, + }, + Library{ + ID: 999, + Books: []*Book{ + { + Title: "A Good Book", + }, + { + ID: "123", + }, + }, + }, + }, + } + + for _, tC := range testCases { + t.Run(tC.desc, func(t *testing.T) { + out := bytes.NewBuffer(nil) + if err := MarshalPayloadWithoutIncluded(out, &tC.input); err != nil { + t.Fatal(err) + } + + actual := Library{} + + if err := UnmarshalPayload(out, &actual); err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(actual, tC.expected) { + t.Fatal("Was expecting nested relationships to be equal") + } + }) + } +} + func TestWithOmitsEmptyAnnotationOnRelation_MixedData(t *testing.T) { type BlogOptionalPosts struct { ID int `jsonapi:"primary,blogs"` @@ -443,6 +683,131 @@ func TestSupportsAttributes(t *testing.T) { } } +func TestMarshalObjectAttribute(t *testing.T) { + now := time.Now() + testModel := &Company{ + ID: "5", + Name: "test", + Boss: Employee{ + HiredAt: &now, + }, + Manager: &Employee{ + Firstname: "Dave", + HiredAt: &now, + }, + Teams: []Team{ + {Name: "Team 1"}, + {Name: "Team-2"}, + }, + People: []*People{ + {Name: "Person-1"}, + {Name: "Person-2"}, + }, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, testModel); err != nil { + t.Fatal(err) + } + + resp := new(OnePayload) + if err := json.NewDecoder(out).Decode(resp); err != nil { + t.Fatal(err) + } + + data := resp.Data + + if data.Attributes == nil { + t.Fatalf("Expected attributes") + } + + boss, ok := data.Attributes["boss"].(map[string]interface{}) + if !ok { + t.Fatalf("Expected boss attribute, got %v", data.Attributes) + } + + hiredAt, ok := boss["hired-at"] + if !ok { + t.Fatalf("Expected boss attribute to contain a \"hired-at\" property, got %v", boss) + } + + if hiredAt != now.UTC().Format(iso8601TimeFormat) { + t.Fatalf("Expected hired-at to be %s, got %s", now.UTC().Format(iso8601TimeFormat), hiredAt) + } + + manager, ok := data.Attributes["manager"].(map[string]interface{}) + if !ok { + t.Fatalf("Expected manager attribute, got %v", data.Attributes) + } + + if manager["firstname"] != "Dave" { + t.Fatalf("Expected manager.firstname to be \"Dave\", got %v", manager) + } + + people, ok := data.Attributes["people"].([]interface{}) + if !ok { + t.Fatalf("Expected people attribute, got %v", data.Attributes) + } + if len(people) != 2 { + t.Fatalf("Expected 2 people, got %v", people) + } + + teams, ok := data.Attributes["teams"].([]interface{}) + if !ok { + t.Fatalf("Expected teams attribute, got %v", data.Attributes) + } + if len(teams) != 2 { + t.Fatalf("Expected 2 teams, got %v", teams) + } +} + +func TestMarshalObjectAttributeWithEmptyNested(t *testing.T) { + testModel := &CompanyOmitEmpty{ + ID: "5", + Name: "test", + Boss: Employee{}, + Manager: nil, + Teams: []Team{}, + People: nil, + } + + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, testModel); err != nil { + t.Fatal(err) + } + + resp := new(OnePayload) + if err := json.NewDecoder(out).Decode(resp); err != nil { + t.Fatal(err) + } + + data := resp.Data + + if data.Attributes == nil { + t.Fatalf("Expected attributes") + } + + _, ok := data.Attributes["boss"].(map[string]interface{}) + if ok { + t.Fatalf("Expected omitted boss attribute, got %v", data.Attributes) + } + + _, ok = data.Attributes["manager"].(map[string]interface{}) + if ok { + t.Fatalf("Expected omitted manager attribute, got %v", data.Attributes) + } + + _, ok = data.Attributes["people"].([]interface{}) + if ok { + t.Fatalf("Expected omitted people attribute, got %v", data.Attributes) + } + + _, ok = data.Attributes["teams"].([]interface{}) + if ok { + t.Fatalf("Expected omitted teams attribute, got %v", data.Attributes) + } +} + func TestOmitsZeroTimes(t *testing.T) { testModel := &Blog{ ID: 5, @@ -581,6 +946,254 @@ func TestMarshal_Times(t *testing.T) { } } +func TestNullableRelationship(t *testing.T) { + comment := &Comment{ + ID: 5, + Body: "Hello World", + } + + for _, tc := range []struct { + desc string + input *WithNullableAttrs + verification func(data map[string]interface{}) error + }{ + { + desc: "nullable_comment_unspecified", + input: &WithNullableAttrs{ + ID: 5, + NullableComment: nil, + }, + verification: func(root map[string]interface{}) error { + _, ok := root["data"].(map[string]interface{})["relationships"] + + if got, want := ok, false; got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + { + desc: "nullable_comment_null", + input: &WithNullableAttrs{ + ID: 5, + NullableComment: NewNullNullableRelationship[*Comment](), + }, + verification: func(root map[string]interface{}) error { + commentData, ok := root["data"].(map[string]interface{})["relationships"].(map[string]interface{})["nullable_comment"].(map[string]interface{})["data"] + + if got, want := ok, true; got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + + if commentData != nil { + return fmt.Errorf("Expected nil data for nullable_comment but was '%v'", commentData) + } + return nil + }, + }, + { + desc: "nullable_comment_not_null", + input: &WithNullableAttrs{ + ID: 5, + NullableComment: NewNullableRelationshipWithValue(comment), + }, + verification: func(root map[string]interface{}) error { + relationships := root["data"].(map[string]interface{})["relationships"] + nullableComment := relationships.(map[string]interface{})["nullable_comment"] + idStr := nullableComment.(map[string]interface{})["data"].(map[string]interface{})["id"].(string) + id, _ := strconv.Atoi(idStr) + if got, want := id, comment.ID; got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + } { + t.Run(tc.desc, func(t *testing.T) { + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, tc.input); err != nil { + t.Fatal(err) + } + + // Use the standard JSON library to traverse the genereated JSON payload. + data := map[string]interface{}{} + json.Unmarshal(out.Bytes(), &data) + if tc.verification != nil { + if err := tc.verification(data); err != nil { + t.Fatal(err) + } + } + }) + } +} + +func TestNullableAttr_Time(t *testing.T) { + aTime := time.Date(2016, 8, 17, 8, 27, 12, 23849, time.UTC) + + for _, tc := range []struct { + desc string + input *WithNullableAttrs + verification func(data map[string]interface{}) error + }{ + { + desc: "time_unspecified", + input: &WithNullableAttrs{ + ID: 5, + RFC3339Time: nil, + }, + verification: func(root map[string]interface{}) error { + _, ok := root["data"].(map[string]interface{})["attributes"].(map[string]interface{})["rfc3339_time"] + + if got, want := ok, false; got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + { + desc: "time_null", + input: &WithNullableAttrs{ + ID: 5, + RFC3339Time: NewNullNullableAttr[time.Time](), + }, + verification: func(root map[string]interface{}) error { + _, ok := root["data"].(map[string]interface{})["attributes"].(map[string]interface{})["rfc3339_time"] + + if got, want := ok, true; got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + { + desc: "time_not_null_rfc3339", + input: &WithNullableAttrs{ + ID: 5, + RFC3339Time: NewNullableAttrWithValue[time.Time](aTime), + }, + verification: func(root map[string]interface{}) error { + v := root["data"].(map[string]interface{})["attributes"].(map[string]interface{})["rfc3339_time"].(string) + if got, want := v, aTime.Format(time.RFC3339); got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + { + desc: "time_not_null_iso8601", + input: &WithNullableAttrs{ + ID: 5, + ISO8601Time: NewNullableAttrWithValue[time.Time](aTime), + }, + verification: func(root map[string]interface{}) error { + v := root["data"].(map[string]interface{})["attributes"].(map[string]interface{})["iso8601_time"].(string) + if got, want := v, aTime.Format(iso8601TimeFormat); got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + { + desc: "time_not_null_int", + input: &WithNullableAttrs{ + ID: 5, + IntTime: NewNullableAttrWithValue[time.Time](aTime), + }, + verification: func(root map[string]interface{}) error { + v := root["data"].(map[string]interface{})["attributes"].(map[string]interface{})["int_time"].(float64) + if got, want := int64(v), aTime.Unix(); got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }} { + t.Run(tc.desc, func(t *testing.T) { + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, tc.input); err != nil { + t.Fatal(err) + } + // Use the standard JSON library to traverse the genereated JSON payload. + data := map[string]interface{}{} + json.Unmarshal(out.Bytes(), &data) + if tc.verification != nil { + if err := tc.verification(data); err != nil { + t.Fatal(err) + } + } + }) + } +} + +func TestNullableAttr_Bool(t *testing.T) { + aBool := true + + for _, tc := range []struct { + desc string + input *WithNullableAttrs + verification func(data map[string]interface{}) error + }{ + { + desc: "bool_unspecified", + input: &WithNullableAttrs{ + ID: 5, + Bool: nil, + }, + verification: func(root map[string]interface{}) error { + _, ok := root["data"].(map[string]interface{})["attributes"].(map[string]interface{})["bool"] + + if got, want := ok, false; got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + { + desc: "bool_null", + input: &WithNullableAttrs{ + ID: 5, + Bool: NewNullNullableAttr[bool](), + }, + verification: func(root map[string]interface{}) error { + _, ok := root["data"].(map[string]interface{})["attributes"].(map[string]interface{})["bool"] + + if got, want := ok, true; got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + { + desc: "bool_not_null", + input: &WithNullableAttrs{ + ID: 5, + Bool: NewNullableAttrWithValue[bool](aBool), + }, + verification: func(root map[string]interface{}) error { + v := root["data"].(map[string]interface{})["attributes"].(map[string]interface{})["bool"].(bool) + if got, want := v, aBool; got != want { + return fmt.Errorf("got %v, want %v", got, want) + } + return nil + }, + }, + } { + t.Run(tc.desc, func(t *testing.T) { + out := bytes.NewBuffer(nil) + if err := MarshalPayload(out, tc.input); err != nil { + t.Fatal(err) + } + // Use the standard JSON library to traverse the genereated JSON payload. + data := map[string]interface{}{} + json.Unmarshal(out.Bytes(), &data) + if tc.verification != nil { + if err := tc.verification(data); err != nil { + t.Fatal(err) + } + } + }) + } +} + func TestSupportsLinkable(t *testing.T) { testModel := &Blog{ ID: 5,