diff --git a/README.md b/README.md index d5e673a..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 @@ -364,6 +364,38 @@ 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{ + "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 3a0c02e..1e722e8 100644 --- a/node.go +++ b/node.go @@ -8,6 +8,7 @@ type OnePayload struct { Data *Node `json:"data"` Included []*Node `json:"included,omitempty"` Links *Links `json:"links,omitempty"` + Meta *Meta `json:"meta,omitempty"` } // ManyPayload is used to represent a generic JSON API payload where many @@ -16,6 +17,7 @@ type ManyPayload struct { Data []*Node `json:"data"` Included []*Node `json:"included,omitempty"` Links *Links `json:"links,omitempty"` + Meta *Meta `json:"meta,omitempty"` } // Node is used to represent a generic JSON API Resource @@ -26,12 +28,14 @@ type Node struct { Attributes map[string]interface{} `json:"attributes,omitempty"` Relationships map[string]interface{} `json:"relationships,omitempty"` Links *Links `json:"links,omitempty"` + Meta *Meta `json:"meta,omitempty"` } // RelationshipOneNode is used to represent a generic has one JSON API relation type RelationshipOneNode struct { Data *Node `json:"data"` Links *Links `json:"links,omitempty"` + Meta *Meta `json:"meta,omitempty"` } // RelationshipManyNode is used to represent a generic has many JSON API @@ -39,6 +43,7 @@ type RelationshipOneNode struct { type RelationshipManyNode struct { Data []*Node `json:"data"` Links *Links `json:"links,omitempty"` + Meta *Meta `json:"meta,omitempty"` } // Links is used to represent a `links` object. @@ -69,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 @@ -85,3 +90,19 @@ type RelationshipLinkable interface { // JSONAPIRelationshipLinks will be invoked for each relationship with the corresponding relation name (e.g. `comments`) 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 +} + +// RelationshipMetable is used to include relationship meta in response data +type RelationshipMetable interface { + // JSONRelationshipMeta will be invoked for each relationship with the corresponding relation name (e.g. `comments`) + JSONAPIRelationshipMeta(relation string) *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.go b/response.go index c44cd3b..fcee64b 100644 --- a/response.go +++ b/response.go @@ -373,6 +373,11 @@ func visitModelNode(model interface{}, included *map[string]*Node, 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( @@ -385,6 +390,7 @@ func visitModelNode(model interface{}, included *map[string]*Node, break } relationship.Links = relLinks + relationship.Meta = relMeta if sideload { shallowNodes := []*Node{} @@ -396,6 +402,7 @@ func visitModelNode(model interface{}, included *map[string]*Node, node.Relationships[args[1]] = &RelationshipManyNode{ Data: shallowNodes, Links: relationship.Links, + Meta: relationship.Meta, } } else { node.Relationships[args[1]] = relationship @@ -424,11 +431,13 @@ func visitModelNode(model interface{}, included *map[string]*Node, node.Relationships[args[1]] = &RelationshipOneNode{ Data: toShallowNode(relationship), Links: relLinks, + Meta: relMeta, } } else { node.Relationships[args[1]] = &RelationshipOneNode{ Data: relationship, Links: relLinks, + Meta: relMeta, } } } @@ -451,6 +460,10 @@ func visitModelNode(model interface{}, included *map[string]*Node, node.Links = linkableModel.JSONAPILinks() } + if metableModel, ok := model.(Metable); ok { + node.Meta = metableModel.JSONAPIMeta() + } + return node, nil } diff --git a/response_test.go b/response_test.go index 756fe87..331023e 100644 --- a/response_test.go +++ b/response_test.go @@ -3,90 +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 -} - -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} @@ -244,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{ @@ -509,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 @@ -546,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") } @@ -569,6 +469,34 @@ func TestInvalidLinkable(t *testing.T) { } } +func TestSupportsMetable(t *testing.T) { + testModel := &Blog{ + ID: 5, + Title: "Title 1", + CreatedAt: time.Now(), + } + + out := bytes.NewBuffer(nil) + if err := MarshalOnePayload(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.Meta == nil { + t.Fatalf("Expected data.meta") + } + + 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) + } +} + func TestRelations(t *testing.T) { testModel := testBlog() @@ -594,6 +522,9 @@ func TestRelations(t *testing.T) { if relations["posts"].(map[string]interface{})["links"] == nil { t.Fatalf("Posts relationship links were not materialized") } + if relations["posts"].(map[string]interface{})["meta"] == nil { + t.Fatalf("Posts relationship meta were not materialized") + } } if relations["current_post"] == nil { @@ -602,6 +533,9 @@ func TestRelations(t *testing.T) { if relations["current_post"].(map[string]interface{})["links"] == nil { t.Fatalf("Current post relationship links were not materialized") } + if relations["current_post"].(map[string]interface{})["meta"] == nil { + t.Fatalf("Current post relationship meta were not materialized") + } } if len(relations["posts"].(map[string]interface{})["data"].([]interface{})) != 2 {