diff --git a/README.md b/README.md index b799b91..dc59105 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ [![Build Status](https://travis-ci.org/google/jsonapi.svg?branch=master)](https://travis-ci.org/google/jsonapi) [![GoDoc](https://godoc.org/github.com/google/jsonapi?status.svg)](http://godoc.org/github.com/google/jsonapi) -A serializer/deserializer for json payloads that comply to the +A serializer/deserializer for JSON payloads that comply to the [JSON API - jsonapi.org](http://jsonapi.org) spec in go. ## Installation @@ -365,26 +365,36 @@ func (post Post) JSONAPIRelationshipLinks(relation string) *Links { ``` ### Meta - + If you need to include [meta objects](http://jsonapi.org/format/#document-meta) along with response data, implement the `Metable` interface for document-meta, and `RelationshipMetable` for relationship meta: - + ```go - func (post Post) JSONAPIMeta() *Meta { - return &Meta{ - "details": "sample details here", - } - } - - // Invoked for each relationship defined on the Post struct when marshaled - func (post Post) JSONAPIRelationshipMeta(relation string) *Meta { - if relation == "comments" { - return &Meta{ - "details": "comment meta details here", - } - } - return nil - } - ``` +func (post Post) JSONAPIMeta() *Meta { + return &Meta{ + "details": "sample details here", + } +} + +// Invoked for each relationship defined on the Post struct when marshaled +func (post Post) JSONAPIRelationshipMeta(relation string) *Meta { + if relation == "comments" { + return &Meta{ + "this": map[string]interface{}{ + "can": map[string]interface{}{ + "go": []interface{}{ + "as", + "deep", + map[string]interface{}{ + "as": "required", + }, + }, + }, + }, + } + } + return nil +} +``` ### Errors This package also implements support for JSON API compatible `errors` payloads using the following types. diff --git a/examples/app.go b/examples/app.go index 0e3b2a3..0ba50c3 100644 --- a/examples/app.go +++ b/examples/app.go @@ -43,7 +43,7 @@ func exerciseHandler() { // list req, _ := http.NewRequest(http.MethodGet, "/blogs", nil) - req.Header.Set("Accept", jsonapi.MediaType) + req.Header.Set(headerAccept, jsonapi.MediaType) w := httptest.NewRecorder() @@ -60,7 +60,7 @@ func exerciseHandler() { // show req, _ = http.NewRequest(http.MethodGet, "/blogs?id=1", nil) - req.Header.Set("Accept", jsonapi.MediaType) + req.Header.Set(headerAccept, jsonapi.MediaType) w = httptest.NewRecorder() @@ -81,7 +81,7 @@ func exerciseHandler() { req, _ = http.NewRequest(http.MethodPost, "/blogs", in) - req.Header.Set("Accept", jsonapi.MediaType) + req.Header.Set(headerAccept, jsonapi.MediaType) w = httptest.NewRecorder() @@ -107,7 +107,7 @@ func exerciseHandler() { req, _ = http.NewRequest(http.MethodPut, "/blogs", in) - req.Header.Set("Accept", jsonapi.MediaType) + req.Header.Set(headerAccept, jsonapi.MediaType) w = httptest.NewRecorder() diff --git a/examples/models.go b/examples/models.go index c28a303..399d739 100644 --- a/examples/models.go +++ b/examples/models.go @@ -3,6 +3,8 @@ package main import ( "fmt" "time" + + "github.com/google/jsonapi" ) type Blog struct { @@ -30,22 +32,43 @@ type Comment struct { } // Blog Links -func (blog Blog) JSONAPILinks() *map[string]interface{} { - return &map[string]interface{}{ +func (blog Blog) JSONAPILinks() *jsonapi.Links { + return &jsonapi.Links{ "self": fmt.Sprintf("https://example.com/blogs/%d", blog.ID), } } -func (blog Blog) JSONAPIRelationshipLinks(relation string) *map[string]interface{} { +func (blog Blog) JSONAPIRelationshipLinks(relation string) *jsonapi.Links { if relation == "posts" { - return &map[string]interface{}{ + return &jsonapi.Links{ "related": fmt.Sprintf("https://example.com/blogs/%d/posts", blog.ID), } } if relation == "current_post" { - return &map[string]interface{}{ + return &jsonapi.Links{ "related": fmt.Sprintf("https://example.com/blogs/%d/current_post", blog.ID), } } return nil } + +// Blog Meta +func (blog Blog) JSONAPIMeta() *jsonapi.Meta { + return &jsonapi.Meta{ + "detail": "extra details regarding the blog", + } +} + +func (blog Blog) JSONAPIRelationshipMeta(relation string) *jsonapi.Meta { + if relation == "posts" { + return &jsonapi.Meta{ + "detail": "posts meta information", + } + } + if relation == "current_post" { + return &jsonapi.Meta{ + "detail": "current post meta information", + } + } + return nil +} diff --git a/models_test.go b/models_test.go new file mode 100644 index 0000000..a53dd61 --- /dev/null +++ b/models_test.go @@ -0,0 +1,157 @@ +package jsonapi + +import ( + "fmt" + "time" +) + +type BadModel struct { + ID int `jsonapi:"primary"` +} + +type ModelBadTypes struct { + ID string `jsonapi:"primary,badtypes"` + StringField string `jsonapi:"attr,string_field"` + FloatField float64 `jsonapi:"attr,float_field"` + TimeField time.Time `jsonapi:"attr,time_field"` + TimePtrField *time.Time `jsonapi:"attr,time_ptr_field"` +} + +type WithPointer struct { + ID *uint64 `jsonapi:"primary,with-pointers"` + Name *string `jsonapi:"attr,name"` + IsActive *bool `jsonapi:"attr,is-active"` + IntVal *int `jsonapi:"attr,int-val"` + FloatVal *float32 `jsonapi:"attr,float-val"` +} + +type Timestamp struct { + ID int `jsonapi:"primary,timestamps"` + Time time.Time `jsonapi:"attr,timestamp,iso8601"` + Next *time.Time `jsonapi:"attr,next,iso8601"` +} + +type Car struct { + ID *string `jsonapi:"primary,cars"` + Make *string `jsonapi:"attr,make,omitempty"` + Model *string `jsonapi:"attr,model,omitempty"` + Year *uint `jsonapi:"attr,year,omitempty"` +} + +type Post struct { + Blog + ID uint64 `jsonapi:"primary,posts"` + BlogID int `jsonapi:"attr,blog_id"` + ClientID string `jsonapi:"client-id"` + Title string `jsonapi:"attr,title"` + Body string `jsonapi:"attr,body"` + Comments []*Comment `jsonapi:"relation,comments"` + LatestComment *Comment `jsonapi:"relation,latest_comment"` +} + +type Comment struct { + ID int `jsonapi:"primary,comments"` + ClientID string `jsonapi:"client-id"` + PostID int `jsonapi:"attr,post_id"` + Body string `jsonapi:"attr,body"` +} + +type Book struct { + ID uint64 `jsonapi:"primary,books"` + Author string `jsonapi:"attr,author"` + ISBN string `jsonapi:"attr,isbn"` + Title string `jsonapi:"attr,title,omitempty"` + Description *string `jsonapi:"attr,description"` + Pages *uint `jsonapi:"attr,pages,omitempty"` + PublishedAt time.Time + Tags []string `jsonapi:"attr,tags"` +} + +type Blog struct { + ID int `jsonapi:"primary,blogs"` + ClientID string `jsonapi:"client-id"` + 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"` +} + +func (b *Blog) JSONAPILinks() *Links { + return &Links{ + "self": fmt.Sprintf("https://example.com/api/blogs/%d", b.ID), + "comments": Link{ + Href: fmt.Sprintf("https://example.com/api/blogs/%d/comments", b.ID), + Meta: Meta{ + "counts": map[string]uint{ + "likes": 4, + "comments": 20, + }, + }, + }, + } +} + +func (b *Blog) JSONAPIRelationshipLinks(relation string) *Links { + if relation == "posts" { + return &Links{ + "related": Link{ + Href: fmt.Sprintf("https://example.com/api/blogs/%d/posts", b.ID), + Meta: Meta{ + "count": len(b.Posts), + }, + }, + } + } + if relation == "current_post" { + return &Links{ + "self": fmt.Sprintf("https://example.com/api/posts/%s", "3"), + "related": Link{ + Href: fmt.Sprintf("https://example.com/api/blogs/%d/current_post", b.ID), + }, + } + } + return nil +} + +func (b *Blog) JSONAPIMeta() *Meta { + return &Meta{ + "detail": "extra details regarding the blog", + } +} + +func (b *Blog) JSONAPIRelationshipMeta(relation string) *Meta { + if relation == "posts" { + return &Meta{ + "this": map[string]interface{}{ + "can": map[string]interface{}{ + "go": []interface{}{ + "as", + "deep", + map[string]interface{}{ + "as": "required", + }, + }, + }, + }, + } + } + if relation == "current_post" { + return &Meta{ + "detail": "extra current_post detail", + } + } + return nil +} + +type BadComment struct { + ID uint64 `jsonapi:"primary,bad-comment"` + Body string `jsonapi:"attr,body"` +} + +func (bc *BadComment) JSONAPILinks() *Links { + return &Links{ + "self": []string{"invalid", "should error"}, + } +} diff --git a/node.go b/node.go index 72083ce..1e722e8 100644 --- a/node.go +++ b/node.go @@ -50,10 +50,6 @@ type RelationshipManyNode struct { // http://jsonapi.org/format/#document-links type Links map[string]interface{} -// Meta is used to represent a `meta` object. -// http://jsonapi.org/format/#document-meta -type Meta map[string]interface{} - func (l *Links) validate() (err error) { // Each member of a links object is a “link”. A link MUST be represented as // either: @@ -78,8 +74,8 @@ func (l *Links) validate() (err error) { // Link is used to represent a member of the `links` object. type Link struct { - Href string `json:"href"` - Meta map[string]interface{} `json:"meta,omitempty"` + Href string `json:"href"` + Meta Meta `json:"meta,omitempty"` } // Linkable is used to include document links in response data @@ -95,6 +91,12 @@ type RelationshipLinkable interface { JSONAPIRelationshipLinks(relation string) *Links } +// Meta is used to represent a `meta` object. +// http://jsonapi.org/format/#document-meta +type Meta map[string]interface{} + +// Metable is used to include document meta in response data +// e.g. {"foo": "bar"} type Metable interface { JSONAPIMeta() *Meta } diff --git a/request_test.go b/request_test.go index c429701..a2e40e8 100644 --- a/request_test.go +++ b/request_test.go @@ -11,26 +11,6 @@ import ( "time" ) -type BadModel struct { - ID int `jsonapi:"primary"` -} - -type WithPointer struct { - ID *uint64 `jsonapi:"primary,with-pointers"` - Name *string `jsonapi:"attr,name"` - IsActive *bool `jsonapi:"attr,is-active"` - IntVal *int `jsonapi:"attr,int-val"` - FloatVal *float32 `jsonapi:"attr,float-val"` -} - -type ModelBadTypes struct { - ID string `jsonapi:"primary,badtypes"` - StringField string `jsonapi:"attr,string_field"` - FloatField float64 `jsonapi:"attr,float_field"` - TimeField time.Time `jsonapi:"attr,time_field"` - TimePtrField *time.Time `jsonapi:"attr,time_ptr_field"` -} - func TestUnmarshall_attrStringSlice(t *testing.T) { out := &Book{} tags := []string{"fiction", "sale"} diff --git a/response_test.go b/response_test.go index edceae9..331023e 100644 --- a/response_test.go +++ b/response_test.go @@ -3,110 +3,12 @@ package jsonapi import ( "bytes" "encoding/json" - "fmt" "reflect" "sort" "testing" "time" ) -type Blog struct { - ID int `jsonapi:"primary,blogs"` - ClientID string `jsonapi:"client-id"` - 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"` -} - -func (b *Blog) JSONAPILinks() *Links { - return &Links{ - "self": fmt.Sprintf("https://example.com/api/blogs/%d", b.ID), - "comments": Link{ - Href: fmt.Sprintf("https://example.com/api/blogs/%d/comments", b.ID), - Meta: map[string]interface{}{ - "counts": map[string]uint{ - "likes": 4, - "comments": 20, - }, - }, - }, - } -} - -func (b *Blog) JSONAPIRelationshipLinks(relation string) *Links { - if relation == "posts" { - return &Links{ - "related": Link{ - Href: fmt.Sprintf("https://example.com/api/blogs/%d/posts", b.ID), - Meta: map[string]interface{}{ - "count": len(b.Posts), - }, - }, - } - } - if relation == "current_post" { - return &Links{ - "self": fmt.Sprintf("https://example.com/api/posts/%s", "3"), - "related": Link{ - Href: fmt.Sprintf("https://example.com/api/blogs/%d/current_post", b.ID), - }, - } - } - return nil -} - -func (blog Blog) JSONAPIMeta() *Meta { - return &Meta{ - "detail": "extra details regarding the blog", - } -} - -func (b *Blog) JSONAPIRelationshipMeta(relation string) *Meta { - if relation == "posts" { - return &Meta{ - "detail": "extra posts detail", - } - } - if relation == "current_post" { - return &Meta{ - "detail": "extra current_post detail", - } - } - return nil -} - -type Post struct { - Blog - ID uint64 `jsonapi:"primary,posts"` - BlogID int `jsonapi:"attr,blog_id"` - ClientID string `jsonapi:"client-id"` - Title string `jsonapi:"attr,title"` - Body string `jsonapi:"attr,body"` - Comments []*Comment `jsonapi:"relation,comments"` - LatestComment *Comment `jsonapi:"relation,latest_comment"` -} - -type Comment struct { - ID int `jsonapi:"primary,comments"` - ClientID string `jsonapi:"client-id"` - PostID int `jsonapi:"attr,post_id"` - Body string `jsonapi:"attr,body"` -} - -type Book struct { - ID uint64 `jsonapi:"primary,books"` - Author string `jsonapi:"attr,author"` - ISBN string `jsonapi:"attr,isbn"` - Title string `jsonapi:"attr,title,omitempty"` - Description *string `jsonapi:"attr,description"` - Pages *uint `jsonapi:"attr,pages,omitempty"` - PublishedAt time.Time - Tags []string `jsonapi:"attr,tags"` -} - func TestMarshall_attrStringSlice(t *testing.T) { tags := []string{"fiction", "sale"} b := &Book{ID: 1, Tags: tags} @@ -264,30 +166,6 @@ func TestWithOmitsEmptyAnnotationOnRelation_MixedData(t *testing.T) { } } -type Timestamp struct { - ID int `jsonapi:"primary,timestamps"` - Time time.Time `jsonapi:"attr,timestamp,iso8601"` - Next *time.Time `jsonapi:"attr,next,iso8601"` -} - -type Car struct { - ID *string `jsonapi:"primary,cars"` - Make *string `jsonapi:"attr,make,omitempty"` - Model *string `jsonapi:"attr,model,omitempty"` - Year *uint `jsonapi:"attr,year,omitempty"` -} - -type BadComment struct { - ID uint64 `jsonapi:"primary,bad-comment"` - Body string `jsonapi:"attr,body"` -} - -func (bc *BadComment) JSONAPILinks() *Links { - return &Links{ - "self": []string{"invalid", "should error"}, - } -} - func TestMarshalIDPtr(t *testing.T) { id, make, model := "123e4567-e89b-12d3-a456-426655440000", "Ford", "Mustang" car := &Car{ @@ -529,7 +407,7 @@ func TestSupportsLinkable(t *testing.T) { data := resp.Data if data.Links == nil { - t.Fatal("Expected links") + t.Fatal("Expected data.links") } links := *data.Links @@ -566,7 +444,9 @@ func TestSupportsLinkable(t *testing.T) { if !isMap { t.Fatal("Expected 'comments' to contain a map") } - countsMap, isMap := commentsMetaMap["counts"].(map[string]interface{}) + + commentsMetaObject := Meta(commentsMetaMap) + countsMap, isMap := commentsMetaObject["counts"].(map[string]interface{}) if !isMap { t.Fatal("Expected 'counts' to contain a map") } @@ -577,6 +457,18 @@ func TestSupportsLinkable(t *testing.T) { } } +func TestInvalidLinkable(t *testing.T) { + testModel := &BadComment{ + ID: 5, + Body: "Hello World", + } + + out := bytes.NewBuffer(nil) + if err := MarshalOnePayload(out, testModel); err == nil { + t.Fatal("Was expecting an error") + } +} + func TestSupportsMetable(t *testing.T) { testModel := &Blog{ ID: 5, @@ -595,21 +487,13 @@ func TestSupportsMetable(t *testing.T) { } data := resp.Data - if data.Meta == nil { - t.Fatalf("Expected 'details' meta") - } -} - -func TestInvalidLinkable(t *testing.T) { - testModel := &BadComment{ - ID: 5, - Body: "Hello World", + t.Fatalf("Expected data.meta") } - out := bytes.NewBuffer(nil) - if err := MarshalOnePayload(out, testModel); err == nil { - t.Fatal("Was expecting an error") + meta := Meta(*data.Meta) + if e, a := "extra details regarding the blog", meta["detail"]; e != a { + t.Fatalf("Was expecting meta.detail to be %q, got %q", e, a) } }