Merge branch 'feature/meta'

This commit is contained in:
Aren Patel 2017-02-16 20:33:20 -08:00
commit 2b01775d0f
8 changed files with 296 additions and 136 deletions

View File

@ -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) [![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. [JSON API - jsonapi.org](http://jsonapi.org) spec in go.
## Installation ## 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 ### Errors
This package also implements support for JSON API compatible `errors` payloads using the following types. This package also implements support for JSON API compatible `errors` payloads using the following types.

View File

@ -43,7 +43,7 @@ func exerciseHandler() {
// list // list
req, _ := http.NewRequest(http.MethodGet, "/blogs", nil) req, _ := http.NewRequest(http.MethodGet, "/blogs", nil)
req.Header.Set("Accept", jsonapi.MediaType) req.Header.Set(headerAccept, jsonapi.MediaType)
w := httptest.NewRecorder() w := httptest.NewRecorder()
@ -60,7 +60,7 @@ func exerciseHandler() {
// show // show
req, _ = http.NewRequest(http.MethodGet, "/blogs?id=1", nil) req, _ = http.NewRequest(http.MethodGet, "/blogs?id=1", nil)
req.Header.Set("Accept", jsonapi.MediaType) req.Header.Set(headerAccept, jsonapi.MediaType)
w = httptest.NewRecorder() w = httptest.NewRecorder()
@ -81,7 +81,7 @@ func exerciseHandler() {
req, _ = http.NewRequest(http.MethodPost, "/blogs", in) req, _ = http.NewRequest(http.MethodPost, "/blogs", in)
req.Header.Set("Accept", jsonapi.MediaType) req.Header.Set(headerAccept, jsonapi.MediaType)
w = httptest.NewRecorder() w = httptest.NewRecorder()
@ -107,7 +107,7 @@ func exerciseHandler() {
req, _ = http.NewRequest(http.MethodPut, "/blogs", in) req, _ = http.NewRequest(http.MethodPut, "/blogs", in)
req.Header.Set("Accept", jsonapi.MediaType) req.Header.Set(headerAccept, jsonapi.MediaType)
w = httptest.NewRecorder() w = httptest.NewRecorder()

View File

@ -3,6 +3,8 @@ package main
import ( import (
"fmt" "fmt"
"time" "time"
"github.com/google/jsonapi"
) )
type Blog struct { type Blog struct {
@ -30,22 +32,43 @@ type Comment struct {
} }
// Blog Links // Blog Links
func (blog Blog) JSONAPILinks() *map[string]interface{} { func (blog Blog) JSONAPILinks() *jsonapi.Links {
return &map[string]interface{}{ return &jsonapi.Links{
"self": fmt.Sprintf("https://example.com/blogs/%d", blog.ID), "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" { if relation == "posts" {
return &map[string]interface{}{ return &jsonapi.Links{
"related": fmt.Sprintf("https://example.com/blogs/%d/posts", blog.ID), "related": fmt.Sprintf("https://example.com/blogs/%d/posts", blog.ID),
} }
} }
if relation == "current_post" { if relation == "current_post" {
return &map[string]interface{}{ return &jsonapi.Links{
"related": fmt.Sprintf("https://example.com/blogs/%d/current_post", blog.ID), "related": fmt.Sprintf("https://example.com/blogs/%d/current_post", blog.ID),
} }
} }
return nil 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
}

157
models_test.go Normal file
View File

@ -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"},
}
}

25
node.go
View File

@ -8,6 +8,7 @@ type OnePayload struct {
Data *Node `json:"data"` Data *Node `json:"data"`
Included []*Node `json:"included,omitempty"` Included []*Node `json:"included,omitempty"`
Links *Links `json:"links,omitempty"` Links *Links `json:"links,omitempty"`
Meta *Meta `json:"meta,omitempty"`
} }
// ManyPayload is used to represent a generic JSON API payload where many // ManyPayload is used to represent a generic JSON API payload where many
@ -16,6 +17,7 @@ type ManyPayload struct {
Data []*Node `json:"data"` Data []*Node `json:"data"`
Included []*Node `json:"included,omitempty"` Included []*Node `json:"included,omitempty"`
Links *Links `json:"links,omitempty"` Links *Links `json:"links,omitempty"`
Meta *Meta `json:"meta,omitempty"`
} }
// Node is used to represent a generic JSON API Resource // Node is used to represent a generic JSON API Resource
@ -26,12 +28,14 @@ type Node struct {
Attributes map[string]interface{} `json:"attributes,omitempty"` Attributes map[string]interface{} `json:"attributes,omitempty"`
Relationships map[string]interface{} `json:"relationships,omitempty"` Relationships map[string]interface{} `json:"relationships,omitempty"`
Links *Links `json:"links,omitempty"` Links *Links `json:"links,omitempty"`
Meta *Meta `json:"meta,omitempty"`
} }
// RelationshipOneNode is used to represent a generic has one JSON API relation // RelationshipOneNode is used to represent a generic has one JSON API relation
type RelationshipOneNode struct { type RelationshipOneNode struct {
Data *Node `json:"data"` Data *Node `json:"data"`
Links *Links `json:"links,omitempty"` Links *Links `json:"links,omitempty"`
Meta *Meta `json:"meta,omitempty"`
} }
// RelationshipManyNode is used to represent a generic has many JSON API // RelationshipManyNode is used to represent a generic has many JSON API
@ -39,6 +43,7 @@ type RelationshipOneNode struct {
type RelationshipManyNode struct { type RelationshipManyNode struct {
Data []*Node `json:"data"` Data []*Node `json:"data"`
Links *Links `json:"links,omitempty"` Links *Links `json:"links,omitempty"`
Meta *Meta `json:"meta,omitempty"`
} }
// Links is used to represent a `links` object. // 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. // Link is used to represent a member of the `links` object.
type Link struct { type Link struct {
Href string `json:"href"` Href string `json:"href"`
Meta map[string]interface{} `json:"meta,omitempty"` Meta Meta `json:"meta,omitempty"`
} }
// Linkable is used to include document links in response data // 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 will be invoked for each relationship with the corresponding relation name (e.g. `comments`)
JSONAPIRelationshipLinks(relation string) *Links 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
}

View File

@ -11,26 +11,6 @@ import (
"time" "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) { func TestUnmarshall_attrStringSlice(t *testing.T) {
out := &Book{} out := &Book{}
tags := []string{"fiction", "sale"} tags := []string{"fiction", "sale"}

View File

@ -373,6 +373,11 @@ func visitModelNode(model interface{}, included *map[string]*Node,
relLinks = linkableModel.JSONAPIRelationshipLinks(args[1]) relLinks = linkableModel.JSONAPIRelationshipLinks(args[1])
} }
var relMeta *Meta
if metableModel, ok := model.(RelationshipMetable); ok {
relMeta = metableModel.JSONAPIRelationshipMeta(args[1])
}
if isSlice { if isSlice {
// to-many relationship // to-many relationship
relationship, err := visitModelNodeRelationships( relationship, err := visitModelNodeRelationships(
@ -385,6 +390,7 @@ func visitModelNode(model interface{}, included *map[string]*Node,
break break
} }
relationship.Links = relLinks relationship.Links = relLinks
relationship.Meta = relMeta
if sideload { if sideload {
shallowNodes := []*Node{} shallowNodes := []*Node{}
@ -396,6 +402,7 @@ func visitModelNode(model interface{}, included *map[string]*Node,
node.Relationships[args[1]] = &RelationshipManyNode{ node.Relationships[args[1]] = &RelationshipManyNode{
Data: shallowNodes, Data: shallowNodes,
Links: relationship.Links, Links: relationship.Links,
Meta: relationship.Meta,
} }
} else { } else {
node.Relationships[args[1]] = relationship node.Relationships[args[1]] = relationship
@ -424,11 +431,13 @@ func visitModelNode(model interface{}, included *map[string]*Node,
node.Relationships[args[1]] = &RelationshipOneNode{ node.Relationships[args[1]] = &RelationshipOneNode{
Data: toShallowNode(relationship), Data: toShallowNode(relationship),
Links: relLinks, Links: relLinks,
Meta: relMeta,
} }
} else { } else {
node.Relationships[args[1]] = &RelationshipOneNode{ node.Relationships[args[1]] = &RelationshipOneNode{
Data: relationship, Data: relationship,
Links: relLinks, Links: relLinks,
Meta: relMeta,
} }
} }
} }
@ -451,6 +460,10 @@ func visitModelNode(model interface{}, included *map[string]*Node,
node.Links = linkableModel.JSONAPILinks() node.Links = linkableModel.JSONAPILinks()
} }
if metableModel, ok := model.(Metable); ok {
node.Meta = metableModel.JSONAPIMeta()
}
return node, nil return node, nil
} }

View File

@ -3,90 +3,12 @@ package jsonapi
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"fmt"
"reflect" "reflect"
"sort" "sort"
"testing" "testing"
"time" "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) { func TestMarshall_attrStringSlice(t *testing.T) {
tags := []string{"fiction", "sale"} tags := []string{"fiction", "sale"}
b := &Book{ID: 1, Tags: tags} 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) { func TestMarshalIDPtr(t *testing.T) {
id, make, model := "123e4567-e89b-12d3-a456-426655440000", "Ford", "Mustang" id, make, model := "123e4567-e89b-12d3-a456-426655440000", "Ford", "Mustang"
car := &Car{ car := &Car{
@ -509,7 +407,7 @@ func TestSupportsLinkable(t *testing.T) {
data := resp.Data data := resp.Data
if data.Links == nil { if data.Links == nil {
t.Fatal("Expected links") t.Fatal("Expected data.links")
} }
links := *data.Links links := *data.Links
@ -546,7 +444,9 @@ func TestSupportsLinkable(t *testing.T) {
if !isMap { if !isMap {
t.Fatal("Expected 'comments' to contain a map") 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 { if !isMap {
t.Fatal("Expected 'counts' to contain a map") 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) { func TestRelations(t *testing.T) {
testModel := testBlog() testModel := testBlog()
@ -594,6 +522,9 @@ func TestRelations(t *testing.T) {
if relations["posts"].(map[string]interface{})["links"] == nil { if relations["posts"].(map[string]interface{})["links"] == nil {
t.Fatalf("Posts relationship links were not materialized") 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 { if relations["current_post"] == nil {
@ -602,6 +533,9 @@ func TestRelations(t *testing.T) {
if relations["current_post"].(map[string]interface{})["links"] == nil { if relations["current_post"].(map[string]interface{})["links"] == nil {
t.Fatalf("Current post relationship links were not materialized") 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 { if len(relations["posts"].(map[string]interface{})["data"].([]interface{})) != 2 {