Skip to content

Commit

Permalink
Merge pull request #209 from manyminds/sparse-fieldsets
Browse files Browse the repository at this point in the history
sparse fieldsets filtering support
  • Loading branch information
sharpner committed Nov 1, 2015
2 parents 4b5c8ff + 39eea9c commit 02704db
Show file tree
Hide file tree
Showing 2 changed files with 324 additions and 3 deletions.
121 changes: 119 additions & 2 deletions api.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"net/http"
"net/url"
"reflect"
"regexp"
"strconv"
"strings"

Expand All @@ -16,7 +17,12 @@ import (
"github.com/manyminds/api2go/routing"
)

const defaultContentTypHeader = "application/vnd.api+json"
const (
codeInvalidQueryFields = "API2GO_INVALID_FIELD_QUERY_PARAM"
defaultContentTypHeader = "application/vnd.api+json"
)

var queryFieldsRegex = regexp.MustCompile(`^fields\[(\w+)\]$`)

type response struct {
Meta map[string]interface{}
Expand Down Expand Up @@ -994,14 +1000,125 @@ func unmarshalRequest(r *http.Request, marshalers map[string]ContentMarshaler) (

func marshalResponse(resp interface{}, w http.ResponseWriter, status int, r *http.Request, marshalers map[string]ContentMarshaler) error {
marshaler, contentType := selectContentMarshaler(r, marshalers)
result, err := marshaler.Marshal(resp)
filtered, err := filterSparseFields(resp, r)
if err != nil {
return err
}
result, err := marshaler.Marshal(filtered)
if err != nil {
return err
}
writeResult(w, result, status, contentType)
return nil
}

func filterSparseFields(resp interface{}, r *http.Request) (interface{}, error) {
query := r.URL.Query()
queryParams := parseQueryFields(&query)
if len(queryParams) < 1 {
return resp, nil
}

if content, ok := resp.(map[string]interface{}); ok {
wrongFields := map[string][]string{}

// single entry in data
if data, ok := content["data"].(map[string]interface{}); ok {
errors := replaceAttributes(&queryParams, &data)
for t, v := range errors {
wrongFields[t] = v
}
}

// data can be a slice too
if datas, ok := content["data"].([]map[string]interface{}); ok {
for index, data := range datas {
errors := replaceAttributes(&queryParams, &data)
for t, v := range errors {
wrongFields[t] = v
}
datas[index] = data
}
}

// included slice
if included, ok := content["included"].([]map[string]interface{}); ok {
for index, include := range included {
errors := replaceAttributes(&queryParams, &include)
for t, v := range errors {
wrongFields[t] = v
}
included[index] = include
}
}

if len(wrongFields) > 0 {
httpError := NewHTTPError(nil, "Some requested fields were invalid", http.StatusBadRequest)
for k, v := range wrongFields {
for _, field := range v {
httpError.Errors = append(httpError.Errors, Error{
Status: "Bad Request",
Code: codeInvalidQueryFields,
Title: fmt.Sprintf(`Field "%s" does not exist for type "%s"`, field, k),
Detail: "Please make sure you do only request existing fields",
Source: &ErrorSource{
Parameter: fmt.Sprintf("fields[%s]", k),
},
})
}
}
return nil, httpError
}
}
return resp, nil
}

func parseQueryFields(query *url.Values) (result map[string][]string) {
result = map[string][]string{}
for name, param := range *query {
matches := queryFieldsRegex.FindStringSubmatch(name)
if len(matches) > 1 {
match := matches[1]
result[match] = strings.Split(param[0], ",")
}
}

return
}

func filterAttributes(attributes map[string]interface{}, fields []string) (filteredAttributes map[string]interface{}, wrongFields []string) {
wrongFields = []string{}
filteredAttributes = map[string]interface{}{}

for _, field := range fields {
if attribute, ok := attributes[field]; ok {
filteredAttributes[field] = attribute
} else {
wrongFields = append(wrongFields, field)
}
}

return
}

func replaceAttributes(query *map[string][]string, entry *map[string]interface{}) map[string][]string {
fieldType := (*entry)["type"].(string)
fields := (*query)[fieldType]
if len(fields) > 0 {
if attributes, ok := (*entry)["attributes"]; ok {
var wrongFields []string
(*entry)["attributes"], wrongFields = filterAttributes(attributes.(map[string]interface{}), fields)
if len(wrongFields) > 0 {
return map[string][]string{
fieldType: wrongFields,
}
}
}
}

return nil
}

func selectContentMarshaler(r *http.Request, marshalers map[string]ContentMarshaler) (marshaler ContentMarshaler, contentType string) {
if _, found := r.Header["Accept"]; found {
var contentTypes []string
Expand Down
206 changes: 205 additions & 1 deletion api_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,7 @@ func (b Banana) GetID() string {
type User struct {
ID string `jsonapi:"-"`
Name string
Info string
}

func (u User) GetID() string {
Expand Down Expand Up @@ -524,6 +525,7 @@ var _ = Describe("RestHandler", func() {
"type": "users",
"attributes": map[string]interface{}{
"name": "Dieter",
"info": "",
},
},
{
Expand Down Expand Up @@ -650,7 +652,8 @@ var _ = Describe("RestHandler", func() {
"id": "1",
"type": "users",
"attributes": {
"name": "Dieter"
"name": "Dieter",
"info": ""
}
}}`))
})
Expand Down Expand Up @@ -1587,4 +1590,205 @@ var _ = Describe("RestHandler", func() {
Expect(rec.Body.Bytes()).To(ContainSubstring(expected))
})
})

Context("Sparse Fieldsets", func() {
var (
source *fixtureSource
api *API
rec *httptest.ResponseRecorder
)

BeforeEach(func() {
author := User{ID: "666", Name: "Tester", Info: "Is curious about testing"}
source = &fixtureSource{map[string]*Post{
"1": {ID: "1", Title: "Nice Post", Value: null.FloatFrom(13.37), Author: &author},
}, false}
api = NewAPI("")
api.AddResource(Post{}, source)
rec = httptest.NewRecorder()
})

It("only returns requested post fields for single post", func() {
req, err := http.NewRequest("GET", "/posts/1?fields[posts]=title,value", nil)
Expect(err).ToNot(HaveOccurred())
api.Handler().ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(rec.Body.Bytes()).To(MatchJSON(`
{"data": {
"id": "1",
"type": "posts",
"attributes": {
"title": "Nice Post",
"value": 13.37
},
"relationships": {
"author": {
"data": {
"id": "666",
"type": "users"
},
"links": {
"related": "/posts/1/author",
"self": "/posts/1/relationships/author"
}
},
"bananas": {
"data": [],
"links": {
"related": "/posts/1/bananas",
"self": "/posts/1/relationships/bananas"
}
},
"comments": {
"data": [],
"links": {
"related": "/posts/1/comments",
"self": "/posts/1/relationships/comments"
}
}
}
},
"included": [
{
"attributes": {
"info": "Is curious about testing",
"name": "Tester"
},
"id": "666",
"type": "users"
}
]
}`))
})

It("FindOne: only returns requested post field for single post and includes", func() {
req, err := http.NewRequest("GET", "/posts/1?fields[posts]=title&fields[users]=name", nil)
Expect(err).ToNot(HaveOccurred())
api.Handler().ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(rec.Body.Bytes()).To(MatchJSON(`
{"data": {
"id": "1",
"type": "posts",
"attributes": {
"title": "Nice Post"
},
"relationships": {
"author": {
"data": {
"id": "666",
"type": "users"
},
"links": {
"related": "/posts/1/author",
"self": "/posts/1/relationships/author"
}
},
"bananas": {
"data": [],
"links": {
"related": "/posts/1/bananas",
"self": "/posts/1/relationships/bananas"
}
},
"comments": {
"data": [],
"links": {
"related": "/posts/1/comments",
"self": "/posts/1/relationships/comments"
}
}
}
},
"included": [
{
"attributes": {
"name": "Tester"
},
"id": "666",
"type": "users"
}
]
}`))
})

It("FindAll: only returns requested post field for single post and includes", func() {
req, err := http.NewRequest("GET", "/posts?fields[posts]=title&fields[users]=name", nil)
Expect(err).ToNot(HaveOccurred())
api.Handler().ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(http.StatusOK))
Expect(rec.Body.Bytes()).To(MatchJSON(`
{"data": [{
"id": "1",
"type": "posts",
"attributes": {
"title": "Nice Post"
},
"relationships": {
"author": {
"data": {
"id": "666",
"type": "users"
},
"links": {
"related": "/posts/1/author",
"self": "/posts/1/relationships/author"
}
},
"bananas": {
"data": [],
"links": {
"related": "/posts/1/bananas",
"self": "/posts/1/relationships/bananas"
}
},
"comments": {
"data": [],
"links": {
"related": "/posts/1/comments",
"self": "/posts/1/relationships/comments"
}
}
}
}],
"included": [
{
"attributes": {
"name": "Tester"
},
"id": "666",
"type": "users"
}
]
}`))
})

It("Summarize all invalid field query parameters as error", func() {
req, err := http.NewRequest("GET", "/posts?fields[posts]=title,nonexistent&fields[users]=name,title,fluffy,pink", nil)
Expect(err).ToNot(HaveOccurred())
api.Handler().ServeHTTP(rec, req)
Expect(rec.Code).To(Equal(http.StatusBadRequest))
error := HTTPError{}
err = json.Unmarshal(rec.Body.Bytes(), &error)
Expect(err).ToNot(HaveOccurred())

expectedError := func(field, objType string) Error {
return Error{
Status: "Bad Request",
Code: codeInvalidQueryFields,
Title: fmt.Sprintf(`Field "%s" does not exist for type "%s"`, field, objType),
Detail: "Please make sure you do only request existing fields",
Source: &ErrorSource{
Parameter: fmt.Sprintf("fields[%s]", objType),
},
}
}

Expect(error.Errors).To(HaveLen(4))
Expect(error.Errors).To(ContainElement(expectedError("nonexistent", "posts")))
Expect(error.Errors).To(ContainElement(expectedError("title", "users")))
Expect(error.Errors).To(ContainElement(expectedError("fluffy", "users")))
Expect(error.Errors).To(ContainElement(expectedError("pink", "users")))
})
})
})

0 comments on commit 02704db

Please sign in to comment.