diff --git a/README.md b/README.md index 5bc3eaf..367f9d5 100644 --- a/README.md +++ b/README.md @@ -1,13 +1,13 @@ Go JSON API Specification Handler --- -[![GoDoc](https://godoc.org/github.com/derekdowling/go-json-spec-handler?status.png)](https://godoc.org/github.com/derekdowling/go-json-spec-handler) -[![Build Status](https://travis-ci.org/derekdowling/go-json-spec-handler.svg?branch=master)](https://travis-ci.org/derekdowling/go-json-spec-handler) +[![GoDoc](https://img.shields.io/badge/godoc-reference-blue.svg?style=flat-square)](https://godoc.org/github.com/derekdowling/go-json-spec-handler) +[![Travis CI](https://img.shields.io/travis/derekdowling/go-json-spec-handler/master.svg?style=flat-square)](https://travis-ci.org/derekdowling/go-json-spec-handler) [![Go Report Card](http://goreportcard.com/badge/manyminds/api2go)](http://goreportcard.com/report/derekdowling/go-json-spec-handler) [TestCoverage](http://gocover.io/github.com/derekdowling/go-json-spec-handler?version=1.5rc1) -An HTTP Client and Server request/response handler for dealing with [JSON Specification](http://jsonapi.org/) -APIs. Great for Ember.js! +A server (de)serialization handler for creating [JSON API Specification](http://jsonapi.org/) +compatible backends in Go. Works with [Ember-Data](https://github.com/emberjs/data) too! # Packages @@ -55,7 +55,6 @@ func PatchUser(w http.ResponseWriter, r *http.Request) { } ``` - ### jsc - JSON Specification Client HTTP JSON Client for interacting with JSON APIs. Built on top of http.Client @@ -69,6 +68,21 @@ object, response, err := jsc.GetObject("http://your.api/", "user", "1") ``` +### Philosophy Behind JSH + +In sticking with Go's philosophy of modules over frameworks, `jsh` was created +to be a drop in serialization layer focusing only on parsing, validating, and +sending JSON API compatible responses. Currently `jsh` is getting fairly close +to stable. It's undergone a number of large refactors to accomodate new +aspects of the specification as I round out the expected feature set which is +pretty well completed, including support for the HTTP client linked above. + +If you're looking for a good place to start with a new API, I've since created +[jshapi](https://github.com/derekdowling/jsh-api) which builds on top of [Goji 2](https://goji.io/) +and `jsh` in order to handle the routing structure that JSON API requires as +well as a number of other useful tools for testing and mocking APIs as you +develop your own projects. + ### Features Implemented: @@ -83,15 +97,15 @@ object, response, err := jsc.GetObject("http://your.api/", "user", "1") TODO: - - Reserved character checking + - [Reserved character checking](http://jsonapi.org/format/upcoming/#document-member-names-reserved-characters) - Not Implenting: + Not Implementing: * These features aren't handled because they are beyond the scope of what - this library is meant to be. In the future, I might build a framework - utilizing this library to handle these complex features which require - Router and ORM compatibility. + this module is meant to be. See [jshapi](https://github.com/derekdowling/jsh-api) + if these are problems that you'd also like to have solved. + - Routing - Relationship management - Sorting - Pagination @@ -99,9 +113,6 @@ object, response, err := jsc.GetObject("http://your.api/", "user", "1") ## Examples -- [jshapi](https://github.com/derekdowling/jsh-api) abstracts the full - serialization layer for JSON Spec APIs. - There are lots of great examples in the tests themselves that show exactly how jsh works. The [godocs](https://godoc.org/github.com/derekdowling/go-json-spec-handler) as linked above have a number of examples in them as well. diff --git a/client/client.go b/client/client.go index 2d8734e..97f97bb 100644 --- a/client/client.go +++ b/client/client.go @@ -12,16 +12,16 @@ import ( "github.com/derekdowling/go-json-spec-handler" ) -// ParseObject validates the HTTP response and parses out the JSON object from the -// body if possible -func ParseObject(response *http.Response) (*jsh.Object, *jsh.Error) { - return buildParser(response).GetObject() -} +// JSON validates the HTTP response and attempts to parse a JSON API compatible +// Document from the response body before closing it +func JSON(response *http.Response) (*jsh.JSON, *jsh.Error) { + document, err := buildParser(response).JSON(response.Body) + if err != nil { + return nil, err + } -// ParseList validates the HTTP response and parses out the JSON list from the -// body if possible -func ParseList(response *http.Response) (jsh.List, *jsh.Error) { - return buildParser(response).GetList() + document.HTTPStatus = response.StatusCode + return document, nil } // DumpBody is a convenience function that parses the body of the response into a @@ -40,7 +40,6 @@ func buildParser(response *http.Response) *jsh.Parser { return &jsh.Parser{ Method: "", Headers: response.Header, - Payload: response.Body, } } @@ -84,7 +83,7 @@ func objectToPayload(request *http.Request, object *jsh.Object) ([]byte, *jsh.Er // sendPayloadRequest is required for sending JSON payload related requests // because by default the http package does not set Content-Length headers -func sendObjectRequest(request *http.Request, object *jsh.Object) (*jsh.Object, *http.Response, *jsh.Error) { +func doObjectRequest(request *http.Request, object *jsh.Object) (*jsh.JSON, *http.Response, *jsh.Error) { payload, err := objectToPayload(request, object) if err != nil { @@ -94,10 +93,13 @@ func sendObjectRequest(request *http.Request, object *jsh.Object) (*jsh.Object, // prepare payload and corresponding headers request.Body = jsh.CreateReadCloser(payload) request.Header.Add("Content-Type", jsh.ContentType) - request.Header.Set("Content-Length", strconv.Itoa(len(payload))) + + contentLength := strconv.Itoa(len(payload)) + request.ContentLength = int64(len(payload)) + request.Header.Set("Content-Length", contentLength) client := &http.Client{} - response, clientErr := client.Do(request) + httpResponse, clientErr := client.Do(request) if clientErr != nil { return nil, nil, jsh.ISE(fmt.Sprintf( @@ -105,10 +107,10 @@ func sendObjectRequest(request *http.Request, object *jsh.Object) (*jsh.Object, )) } - object, objErr := ParseObject(response) - if objErr != nil { - return nil, response, objErr + document, err := JSON(httpResponse) + if err != nil { + return nil, httpResponse, err } - return object, response, nil + return document, httpResponse, nil } diff --git a/client/client_test.go b/client/client_test.go index e00fcaa..39b6ec0 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -1,6 +1,7 @@ package jsc import ( + "log" "net/url" "testing" @@ -51,12 +52,15 @@ func TestResponseParsing(t *testing.T) { obj, objErr := jsh.NewObject("123", "test", map[string]string{"test": "test"}) So(objErr, ShouldBeNil) response, err := mockObjectResponse(obj) + log.Printf("response = %+v\n", response) So(err, ShouldBeNil) Convey("should parse successfully", func() { - respObj, err := ParseObject(response) + doc, err := JSON(response) + So(err, ShouldBeNil) - So(respObj.ID, ShouldEqual, "123") + So(doc.HasData(), ShouldBeTrue) + So(doc.First().ID, ShouldEqual, "123") }) }) @@ -71,10 +75,11 @@ func TestResponseParsing(t *testing.T) { So(err, ShouldBeNil) Convey("should parse successfully", func() { - list, err := ParseList(response) + json, err := JSON(response) + So(err, ShouldBeNil) - So(list, ShouldNotBeNil) - So(list[0].ID, ShouldEqual, "123") + So(json.HasData(), ShouldBeTrue) + So(json.Data.List[0].ID, ShouldEqual, "123") }) }) }) diff --git a/client/get.go b/client/get.go index c0ced26..50b2eac 100644 --- a/client/get.go +++ b/client/get.go @@ -8,8 +8,8 @@ import ( "github.com/derekdowling/go-json-spec-handler" ) -// GetObject allows a user to make an outbound GET /resourceTypes/:id -func GetObject(urlStr string, resourceType string, id string) (*jsh.Object, *http.Response, *jsh.Error) { +// Fetch performs an outbound GET /resourceTypes/:id request +func Fetch(urlStr string, resourceType string, id string) (*jsh.JSON, *http.Response, *jsh.Error) { if id == "" { return nil, nil, jsh.SpecificationError("ID cannot be empty for GetObject request type") } @@ -21,21 +21,11 @@ func GetObject(urlStr string, resourceType string, id string) (*jsh.Object, *htt setIDPath(u, resourceType, id) - response, err := Get(u.String()) - if err != nil { - return nil, nil, err - } - - object, err := ParseObject(response) - if err != nil { - return nil, response, err - } - - return object, response, nil + return Get(u.String()) } -// GetList prepares an outbound request for /resourceTypes expecting a list return value. -func GetList(urlStr string, resourceType string) (jsh.List, *http.Response, *jsh.Error) { +// List prepares an outbound GET /resourceTypes request +func List(urlStr string, resourceType string) (*jsh.JSON, *http.Response, *jsh.Error) { u, urlErr := url.Parse(urlStr) if urlErr != nil { return nil, nil, jsh.ISE(fmt.Sprintf("Error parsing URL: %s", urlErr.Error())) @@ -43,25 +33,21 @@ func GetList(urlStr string, resourceType string) (jsh.List, *http.Response, *jsh setPath(u, resourceType) - response, err := Get(u.String()) - if err != nil { - return nil, nil, err - } + return Get(u.String()) +} - list, err := ParseList(response) - if err != nil { - return nil, response, err +// Get performs a generic GET request for a given URL and attempts to parse the +// response into a JSON API Format +func Get(urlStr string) (*jsh.JSON, *http.Response, *jsh.Error) { + response, httpErr := http.Get(urlStr) + if httpErr != nil { + return nil, nil, jsh.ISE(fmt.Sprintf("Error performing GET request: %s", httpErr.Error())) } - return list, response, nil -} - -// Get performs a Get request for a given URL and returns a basic Response type -func Get(urlStr string) (*http.Response, *jsh.Error) { - response, err := http.Get(urlStr) + json, err := JSON(response) if err != nil { - return nil, jsh.ISE(fmt.Sprintf("Error performing GET request: %s", err.Error())) + return nil, nil, err } - return response, nil + return json, response, nil } diff --git a/client/get_test.go b/client/get_test.go index 834c539..df94b99 100644 --- a/client/get_test.go +++ b/client/get_test.go @@ -19,26 +19,36 @@ func TestGet(t *testing.T) { Convey("Get Tests", t, func() { Convey("->Get()", func() { - resp, err := Get(baseURL + "/tests/1") + json, resp, err := Get(baseURL + "/tests/1") + So(err, ShouldBeNil) So(resp.StatusCode, ShouldEqual, http.StatusOK) + So(json.HasErrors(), ShouldBeFalse) + So(json.HasData(), ShouldBeTrue) }) Convey("->GetList()", func() { Convey("should handle an object listing request", func() { - list, _, err := GetList(baseURL, "test") + json, resp, err := List(baseURL, "test") + So(err, ShouldBeNil) - So(len(list), ShouldEqual, 1) + So(resp.StatusCode, ShouldEqual, http.StatusOK) + So(json.HasErrors(), ShouldBeFalse) + So(json.HasData(), ShouldBeTrue) }) }) Convey("->GetObject()", func() { Convey("should handle a specific object request", func() { - obj, _, err := GetObject(baseURL, "test", "1") + json, resp, err := Fetch(baseURL, "test", "1") + So(err, ShouldBeNil) - So(obj.ID, ShouldEqual, "1") + So(resp.StatusCode, ShouldEqual, http.StatusOK) + So(json.HasErrors(), ShouldBeFalse) + So(json.HasData(), ShouldBeTrue) + So(json.First().ID, ShouldEqual, "1") }) }) }) diff --git a/client/patch.go b/client/patch.go index bf47336..5393fa9 100644 --- a/client/patch.go +++ b/client/patch.go @@ -11,23 +11,23 @@ import ( // Patch allows a consumer to perform a PATCH /resources/:id request // Example: // -// obj, _ := jsh.NewObject("123", "resource_name", payload) -// resp, _ := jsc.Patch("http://postap.com", obj) -// updatedObj, _ := resp.GetObject() +// obj, _ := jsh.NewObject("123", "user", payload) +// // does PATCH /http://postap.com/api/user/123 +// json, resp, err := jsc.Patch("http://postap.com/api/", obj) +// updatedObj := json.First() // -func Patch(urlStr string, object *jsh.Object) (*jsh.Object, *http.Response, *jsh.Error) { - - u, err := url.Parse(urlStr) +func Patch(baseURL string, object *jsh.Object) (*jsh.JSON, *http.Response, error) { + u, err := url.Parse(baseURL) if err != nil { - return nil, nil, jsh.ISE(fmt.Sprintf("Error parsing URL: %s", err.Error())) + return nil, nil, fmt.Errorf("Error parsing URL: %s", err.Error()) } setIDPath(u, object.Type, object.ID) request, err := http.NewRequest("PATCH", u.String(), nil) if err != nil { - return nil, nil, jsh.ISE(fmt.Sprintf("Error creating PATCH request: %s", err.Error())) + return nil, nil, fmt.Errorf("Error creating PATCH request: %s", err.Error()) } - return sendObjectRequest(request, object) + return doObjectRequest(request, object) } diff --git a/client/patch_test.go b/client/patch_test.go index 5dc143e..00d67b5 100644 --- a/client/patch_test.go +++ b/client/patch_test.go @@ -1,15 +1,32 @@ package jsc import ( + "net/http" + "net/http/httptest" "testing" + "github.com/derekdowling/go-json-spec-handler" + "github.com/derekdowling/jsh-api" . "github.com/smartystreets/goconvey/convey" ) func TestPatch(t *testing.T) { Convey("Patch Tests", t, func() { + resource := jshapi.NewMockResource("test", 1, nil) + server := httptest.NewServer(resource) + baseURL := server.URL - }) + Convey("->Patch()", func() { + object, err := jsh.NewObject("2", "test", nil) + So(err, ShouldBeNil) + + json, resp, patchErr := Patch(baseURL, object) + So(patchErr, ShouldBeNil) + So(resp.StatusCode, ShouldEqual, http.StatusOK) + So(json.HasErrors(), ShouldBeFalse) + So(json.HasData(), ShouldBeTrue) + }) + }) } diff --git a/client/post.go b/client/post.go index b39ea0d..4264880 100644 --- a/client/post.go +++ b/client/post.go @@ -11,12 +11,13 @@ import ( // Post allows a user to make an outbound POST /resources request: // // obj, _ := jsh.NewObject("123", "user", payload) -// object, resp, err := jsh.Post("http://apiserver", obj) -func Post(urlStr string, object *jsh.Object) (*jsh.Object, *http.Response, *jsh.Error) { +// // does POST http://apiserver/user/123 +// json, resp, err := jsh.Post("http://apiserver", obj) +func Post(baseURL string, object *jsh.Object) (*jsh.JSON, *http.Response, error) { - u, err := url.Parse(urlStr) + u, err := url.Parse(baseURL) if err != nil { - return nil, nil, jsh.ISE(fmt.Sprintf("Error parsing URL: %s", err.Error())) + return nil, nil, fmt.Errorf("Error parsing URL: %s", err.Error()) } // ghetto pluralization, fix when it becomes an issue @@ -24,8 +25,8 @@ func Post(urlStr string, object *jsh.Object) (*jsh.Object, *http.Response, *jsh. request, err := http.NewRequest("POST", u.String(), nil) if err != nil { - return nil, nil, jsh.ISE(fmt.Sprintf("Error building POST request: %s", err.Error())) + return nil, nil, fmt.Errorf("Error building POST request: %s", err.Error()) } - return sendObjectRequest(request, object) + return doObjectRequest(request, object) } diff --git a/client/post_test.go b/client/post_test.go index 32f9de3..ab356ba 100644 --- a/client/post_test.go +++ b/client/post_test.go @@ -23,9 +23,10 @@ func TestPost(t *testing.T) { Convey("Post Tests", t, func() { testObject, err := jsh.NewObject("", "test", attrs) - - _, resp, err := Post(baseURL, testObject) So(err, ShouldBeNil) + + _, resp, postErr := Post(baseURL, testObject) + So(postErr, ShouldBeNil) So(resp.StatusCode, ShouldEqual, http.StatusCreated) }) } diff --git a/client/test_util.go b/client/test_util.go index 8abee97..872bb38 100644 --- a/client/test_util.go +++ b/client/test_util.go @@ -23,7 +23,7 @@ func mockObjectResponse(object *jsh.Object) (*http.Response, error) { } recorder := httptest.NewRecorder() - jsh.SendResponse(recorder, req, resp) + jsh.SendJSON(recorder, req, resp) return recorderToResponse(recorder), nil } @@ -37,13 +37,13 @@ func mockListResponse(list jsh.List) (*http.Response, error) { return nil, reqErr } - resp, err := list.Prepare(req, false) + json, err := list.Prepare(req, false) if err != nil { return nil, err } recorder := httptest.NewRecorder() - jsh.SendResponse(recorder, req, resp) + jsh.SendJSON(recorder, req, json) return recorderToResponse(recorder), nil } diff --git a/document.go b/document.go new file mode 100644 index 0000000..3d32fbb --- /dev/null +++ b/document.go @@ -0,0 +1,184 @@ +package jsh + +import ( + "fmt" + "net/http" + "strings" +) + +/* +Document represents a top level JSON formatted Document. +Refer to the JSON API Specification for a full descriptor +of each attribute: http://jsonapi.org/format/#document-structure +*/ +type Document struct { + Data List `json:"data,omitempty"` + Errors []*Error `json:"errors,omitempty"` + Links *Link `json:"links,omitempty"` + Included []*Object `json:"included,omitempty"` + Meta interface{} `json:"meta,omitempty"` + JSONAPI struct { + Version string `json:"version"` + } `json:"jsonapi"` + // Status is an HTTP Status Code + Status int `json:"-"` + // empty is used to signify that the response shouldn't contain a json payload + // in the case that we only want to return an HTTP Status Code in order to bypass + // validation steps. + empty bool + validated bool +} + +/* +New instantiates a new JSON Document object. +*/ +func New() *Document { + json := &Document{} + json.JSONAPI.Version = JSONAPIVersion + + return json +} + +/* +Build creates a Sendable Document with the provided sendable payload, either Data or +errors. Build also assumes you've already validated your data with .Validate() so +it should be used carefully. +*/ +func Build(payload Sendable) *Document { + document := New() + document.validated = true + + object, isObject := payload.(*Object) + if isObject { + document.Data = List{object} + document.Status = object.Status + } + + list, isList := payload.(List) + if isList { + document.Data = list + document.Status = http.StatusOK + } + + err, isError := payload.(*Error) + if isError { + document.Errors = []*Error{err} + document.Status = err.Status + } + + return document +} + +/* +Validate checks JSON Spec for the top level JSON document +*/ +func (d *Document) Validate(r *http.Request, response bool) *Error { + + if d.Status < 100 || d.Status > 600 { + return ISE("Response HTTP Status is outside of valid range") + } + + // if empty is set, skip all validations below + if d.empty { + return nil + } + + if !d.HasErrors() && !d.HasData() { + return ISE("Both `errors` and `data` cannot be blank for a JSON response") + } + if d.HasErrors() && d.HasData() { + return ISE("Both `errors` and `data` cannot be set for a JSON response") + } + if d.HasData() && d.Included != nil { + return ISE("'included' should only be set for a response if 'data' is as well") + } + + // if fields have already been validated, skip this part + if d.validated { + return nil + } + + err := d.Data.Validate(r, response) + if err != nil { + return err + } + + for _, docErr := range d.Errors { + err := docErr.Validate(r, response) + if err != nil { + return err + } + } + + return nil +} + +// AddObject adds another object to the JSON Document after validating it. +func (d *Document) AddObject(object *Object) *Error { + + if d.HasErrors() { + return ISE("Cannot add data to a document already possessing errors") + } + + if d.Status == 0 { + d.Status = object.Status + } + + if d.Data == nil { + d.Data = List{object} + } else { + d.Data = append(d.Data, object) + } + + return nil +} + +// AddError adds an error to the JSON Object by transfering it's Error objects. +func (d *Document) AddError(newErr *Error) *Error { + + if d.HasData() { + return ISE("Cannot add an error to a document already possessing data") + } + + if newErr.Status == 0 { + return SpecificationError("Status code must be set for an error") + } + + if d.Status == 0 { + d.Status = newErr.Status + } + + if d.Errors == nil { + d.Errors = []*Error{newErr} + } else { + d.Errors = append(d.Errors, newErr) + } + + return nil +} + +/* +First is just a convenience function that returns the first data object from the +array +*/ +func (d *Document) First() *Object { + return d.Data[0] +} + +// HasData will return true if the JSON document's Data field is set +func (d *Document) HasData() bool { + return d.Data != nil && len(d.Data) > 0 +} + +// HasErrors will return true if the Errors attribute is not nil. +func (d *Document) HasErrors() bool { + return d.Errors != nil && len(d.Errors) > 0 +} + +func (d *Document) Error() string { + errStr := "Errors:" + for _, err := range d.Errors { + errStr = strings.Join([]string{errStr, fmt.Sprintf("%s;", err.Error())}, "\n") + } + return errStr +} diff --git a/document_test.go b/document_test.go new file mode 100644 index 0000000..152e1e9 --- /dev/null +++ b/document_test.go @@ -0,0 +1,100 @@ +package jsh + +import ( + "net/http" + "testing" + + . "github.com/smartystreets/goconvey/convey" +) + +func TestDocument(t *testing.T) { + + Convey("Document Tests", t, func() { + + doc := New() + + Convey("->New()", func() { + So(doc.JSONAPI.Version, ShouldEqual, JSONAPIVersion) + }) + + Convey("->HasErrors()", func() { + err := &Error{Status: 400} + addErr := doc.AddError(err) + So(addErr, ShouldBeNil) + + So(doc.HasErrors(), ShouldBeTrue) + }) + + Convey("->HasData()", func() { + obj, err := NewObject("1", "user", nil) + So(err, ShouldBeNil) + + doc.Data = append(doc.Data, obj) + So(doc.HasData(), ShouldBeTrue) + }) + + Convey("->AddObject()", func() { + obj, err := NewObject("1", "user", nil) + So(err, ShouldBeNil) + + err = doc.AddObject(obj) + So(err, ShouldBeNil) + So(len(doc.Data), ShouldEqual, 1) + }) + + Convey("->AddError()", func() { + testError := &Error{Status: 400} + + Convey("should successfully add a valid error", func() { + err := doc.AddError(testError) + So(err, ShouldBeNil) + So(len(doc.Errors), ShouldEqual, 1) + }) + + Convey("should error if validation fails while adding an error", func() { + badError := &Error{ + Title: "Invalid", + Detail: "So badly", + } + + err := doc.AddError(badError) + So(err, ShouldNotBeNil) + So(doc.Errors, ShouldBeEmpty) + }) + }) + + Convey("->Build()", func() { + + testObject := &Object{ + ID: "1", + Type: "Test", + Status: http.StatusAccepted, + } + + Convey("should accept an object", func() { + doc := Build(testObject) + + So(doc.Data, ShouldResemble, List{testObject}) + So(doc.Status, ShouldEqual, http.StatusAccepted) + }) + + Convey("should accept a list", func() { + list := List{testObject} + doc := Build(list) + + So(doc.Data, ShouldResemble, list) + So(doc.Status, ShouldEqual, http.StatusOK) + }) + + Convey("should accept an error", func() { + err := &Error{Status: 500} + doc := Build(err) + + So(doc.Errors, ShouldNotBeEmpty) + So(doc.Status, ShouldEqual, err.Status) + }) + }) + + }) + +} diff --git a/error.go b/error.go index dae794f..ed811a4 100644 --- a/error.go +++ b/error.go @@ -6,27 +6,30 @@ import ( "strings" ) -// DefaultError can be customized in order to provide a more customized error -// Detail message when an Internal Server Error occurs. Optionally, you can modify -// a returned jsh.Error before sending it as a response as well. +/* +DefaultError can be customized in order to provide a more customized error +Detail message when an Internal Server Error occurs. Optionally, you can modify +a returned jsh.Error before sending it as a response as well. +*/ var DefaultErrorDetail = "Request failed, something went wrong." // DefaultTitle can be customized to provide a more customized ISE Title var DefaultErrorTitle = "Internal Server Error" -// ErrorObject consists of a number of contextual attributes to make conveying -// certain error type simpler as per the JSON API specification: -// http://jsonapi.org/format/#error-objects -// -// error := &jsh.Error{ -// Title: "Authentication Failure", -// Detail: "Category 4 Username Failure", -// Status: 401 -// } -// -// jsh.Send(w, r, error) -// -type ErrorObject struct { +/* +Error consists of a number of contextual attributes to make conveying +certain error type simpler as per the JSON API specification: +http://jsonapi.org/format/#error-objects + + error := &jsh.Error{ + Title: "Authentication Failure", + Detail: "Category 4 Username Failure", + Status: 401 + } + + jsh.Send(w, r, error) +*/ +type Error struct { Title string `json:"title"` Detail string `json:"detail"` Status int `json:"status"` @@ -36,151 +39,92 @@ type ErrorObject struct { ISE string `json:"-"` } -// Error is a safe for public consumption error message -func (e *ErrorObject) Error() string { - msg := fmt.Sprintf("%s: %s", e.Title, e.Detail) - if e.Source.Pointer != "" { - msg += fmt.Sprintf("Source.Pointer: %s", e.Source.Pointer) - } - return msg -} - -// Internal is a convenience function that prints out the full error including the -// ISE which is useful when debugging, NOT to be used for returning errors to user, -// use e.Error() for that -func (e *ErrorObject) Internal() string { - return fmt.Sprintf("%s ISE: %s", e.Error(), e.ISE) -} - -// Error is a Sendable type consistenting of one or more error messages. Error -// implements Sendable and as such, when encountered, can simply be sent via -// jsh: -// -// object, err := ParseObject(request) -// if err != nil { -// err := jsh.Send(err, w, request) -// } -type Error struct { - Objects []*ErrorObject -} - -// Error allows ErrorList to conform to the default Go error interface +/* +Error will print an internal server error if set, or default back to the SafeError() +format if not. As usual, err.Error() should not be considered safe for presentation +to the end user, use err.SafeError() instead. +*/ func (e *Error) Error() string { - err := "Errors: " - for _, m := range e.Objects { - err = strings.Join([]string{err, fmt.Sprintf("%s;", m.Error())}, "\n") - } - return err -} - -// Status returns the HTTP Code of the first Error Object, or 0 if none -func (e *Error) Status() int { - if len(e.Objects) > 0 { - return e.Objects[0].Status - } - - return 0 -} - -// Internal prints a formatted error list including ISE's, useful for debugging -func (e *Error) Internal() string { - err := "Errors:" - for _, m := range e.Objects { - err = strings.Join([]string{err, fmt.Sprintf("%s;", m.Internal())}, "\n") - } - return err -} - -// Add first validates the error, and then appends it to the ErrorList -func (e *Error) Add(object *ErrorObject) *Error { - err := validateError(object) - if err != nil { - return err + if e.ISE != "" { + return fmt.Sprintf("HTTP %d - %s", e.Status, e.ISE) } - e.Objects = append(e.Objects, object) - return nil + return e.SafeError() } -// Prepare first validates the errors, and then returns an appropriate response -func (e *Error) Prepare(req *http.Request, response bool) (*Response, *Error) { - if len(e.Objects) == 0 { - return nil, ISE("No errors provided for attempted error response.") +/* +SafeError is a formatted error string that does not include an associated internal +server error such that it is safe to return this error message to an end user. +*/ +func (e *Error) SafeError() string { + msg := fmt.Sprintf("HTTP %d: %s - %s", e.Status, e.Title, e.Detail) + if e.Source.Pointer != "" { + msg += fmt.Sprintf("Source.Pointer: %s", e.Source.Pointer) } - return &Response{Errors: e.Objects, HTTPStatus: e.Objects[0].Status}, nil + return msg } -// validateError ensures that the error is ready for a response in it's current state -func validateError(err *ErrorObject) *Error { +/* +Validate ensures that the an error meets all JSON API criteria. +*/ +func (e *Error) Validate(r *http.Request, response bool) *Error { - if err.Status < 400 || err.Status > 600 { - return ISE(fmt.Sprintf("Invalid HTTP Status for error %+v\n", err)) - } else if err.Status == 422 && err.Source.Pointer == "" { + if e.Status < 400 || e.Status > 600 { + return ISE(fmt.Sprintf("Invalid HTTP Status for error %+v\n", e)) + } else if e.Status == 422 && e.Source.Pointer == "" { return ISE(fmt.Sprintf("Source Pointer must be set for 422 Status errors")) } return nil } -// NewError is a convenience function that makes creating a Sendable Error from a -// Error Object simple. Because ErrorObjects are validated agains the JSON API -// Specification before being added, there is a chance that a ISE error might be -// returned in your new error's place. -func NewError(object *ErrorObject) *Error { - newError := &Error{} - - err := newError.Add(object) - if err != nil { - return err - } - - return newError -} - -// ISE is a convenience function for creating a ready-to-go Internal Service Error -// response. The message you pass in is set to the ErrorObject.ISE attribute so you -// can gracefully log ISE's internally before sending them +/* +ISE is a convenience function for creating a ready-to-go Internal Service Error +response. The message you pass in is set to the ErrorObject.ISE attribute so you +can gracefully log ISE's internally before sending them. +*/ func ISE(internalMessage string) *Error { - return NewError(&ErrorObject{ + return &Error{ Title: DefaultErrorTitle, Detail: DefaultErrorDetail, Status: http.StatusInternalServerError, ISE: internalMessage, - }) + } } -// InputError creates a properly formatted Status 422 error with an appropriate -// user facing message, and a Status Pointer to the first attribute that -func InputError(attribute string, detail string) *Error { - message := &ErrorObject{ +/* +InputError creates a properly formatted HTTP Status 422 error with an appropriate +user safe message. The parameter "attribute" will format err.Source.Pointer to be +"/data/attributes/". +*/ +func InputError(msg string, attribute string) *Error { + err := &Error{ Title: "Invalid Attribute", - Detail: detail, + Detail: msg, Status: 422, } // Assign this after the fact, easier to do - message.Source.Pointer = fmt.Sprintf("/data/attributes/%s", strings.ToLower(attribute)) + err.Source.Pointer = fmt.Sprintf("/data/attributes/%s", strings.ToLower(attribute)) - err := &Error{} - err.Add(message) return err } // SpecificationError is used whenever the Client violates the JSON API Spec func SpecificationError(detail string) *Error { - return NewError(&ErrorObject{ + return &Error{ Title: "JSON API Specification Error", Detail: detail, Status: http.StatusNotAcceptable, - }) + } } // NotFound returns a 404 formatted error func NotFound(resourceType string, id string) *Error { - return NewError(&ErrorObject{ + return &Error{ Title: "Not Found", Detail: fmt.Sprintf("No resource of type '%s' exists for ID: %s", resourceType, id), Status: http.StatusNotFound, - }) + } } diff --git a/error_test.go b/error_test.go index 18cebe3..74949a1 100644 --- a/error_test.go +++ b/error_test.go @@ -13,19 +13,19 @@ func TestError(t *testing.T) { Convey("Error Tests", t, func() { - writer := httptest.NewRecorder() request := &http.Request{} + writer := httptest.NewRecorder() - testErrorObject := &ErrorObject{ + testErrorObject := &Error{ Status: http.StatusBadRequest, Title: "Fail", Detail: "So badly", } - Convey("->validateError()", func() { + Convey("->Validate()", func() { Convey("should not fail for a valid Error", func() { - err := validateError(testErrorObject) + err := testErrorObject.Validate(request, true) So(err, ShouldBeNil) }) @@ -35,78 +35,32 @@ func TestError(t *testing.T) { Convey("should accept a properly formatted 422 error", func() { testErrorObject.Source.Pointer = "/data/attributes/test" - err := validateError(testErrorObject) + err := testErrorObject.Validate(request, true) So(err, ShouldBeNil) }) Convey("should error if Source.Pointer isn't set", func() { - err := validateError(testErrorObject) + err := testErrorObject.Validate(request, true) So(err, ShouldNotBeNil) }) }) Convey("should fail for an out of range HTTP error status", func() { testErrorObject.Status = http.StatusOK - err := validateError(testErrorObject) + err := testErrorObject.Validate(request, true) So(err, ShouldNotBeNil) }) }) - Convey("->Status()", func() { - err := &Error{} - - Convey("should return 0 if no error objects are present", func() { - code := err.Status() - So(code, ShouldEqual, 0) - }) - - Convey("should return the first code if error has objects", func() { - addErr := err.Add(&ErrorObject{ - Status: 400, - }) - So(addErr, ShouldBeNil) - - addErr = err.Add(&ErrorObject{ - Status: 500, - }) - So(addErr, ShouldBeNil) - - code := err.Status() - So(code, ShouldEqual, 400) - }) - }) - - Convey("->Add()", func() { - - testError := &Error{} - - Convey("should successfully add a valid error", func() { - err := testError.Add(testErrorObject) - So(err, ShouldBeNil) - So(len(testError.Objects), ShouldEqual, 1) - }) - - Convey("should error if validation fails while adding an error", func() { - badError := &ErrorObject{ - Title: "Invalid", - Detail: "So badly", - } - - err := testError.Add(badError) - So(err.Objects[0].Status, ShouldEqual, 500) - So(testError.Objects, ShouldBeEmpty) - }) - }) - Convey("->Send()", func() { - testError := NewError(&ErrorObject{ + testError := &Error{ Status: http.StatusForbidden, Title: "Forbidden", Detail: "Can't Go Here", - }) + } - Convey("should send a properly formatted JSON error list", func() { + Convey("should send a properly formatted JSON error", func() { err := Send(writer, request, testError) So(err, ShouldBeNil) So(writer.Code, ShouldEqual, http.StatusForbidden) diff --git a/jsh.go b/jsh.go index 9a0cd5d..8494736 100644 --- a/jsh.go +++ b/jsh.go @@ -6,3 +6,8 @@ // // For a full http.Handler API builder see jshapi: https://godoc.org/github.com/derekdowling/jsh-api package jsh + +const ( + // ContentType is the data encoding of choice for HTTP Request and Response Headers + ContentType = "application/vnd.api+json" +) diff --git a/list.go b/list.go index b62fbdd..0e1186b 100644 --- a/list.go +++ b/list.go @@ -1,11 +1,52 @@ package jsh -import "net/http" +import ( + "encoding/json" + "fmt" + "net/http" +) // List is just a wrapper around an object array that implements Sendable type List []*Object -// Prepare returns a success status response -func (list List) Prepare(r *http.Request, response bool) (*Response, *Error) { - return &Response{Data: list, HTTPStatus: http.StatusOK}, nil +/* +Validate ensures that List is JSON API compatible. +*/ +func (list List) Validate(r *http.Request, response bool) *Error { + for _, object := range list { + err := object.Validate(r, response) + if err != nil { + return err + } + } + + return nil +} + +/* +UnmarshalJSON allows us to manually decode a list via the json.Unmarshaler +interface. +*/ +func (list *List) UnmarshalJSON(rawData []byte) error { + // Create a sub-type here so when we call Unmarshal below, we don't recursively + // call this function over and over + type MarshalList List + + // if our "List" is a single object, modify the JSON to make it into a list + // by wrapping with "[ ]" + if rawData[0] == '{' { + rawData = []byte(fmt.Sprintf("[%s]", rawData)) + } + + newList := MarshalList{} + + err := json.Unmarshal(rawData, &newList) + if err != nil { + return err + } + + convertedList := List(newList) + *list = convertedList + + return nil } diff --git a/list_test.go b/list_test.go index 06ff163..6068ecc 100644 --- a/list_test.go +++ b/list_test.go @@ -2,6 +2,7 @@ package jsh import ( "encoding/json" + "log" "net/http" "net/http/httptest" "strconv" @@ -21,12 +22,11 @@ func TestList(t *testing.T) { } testList := List{testObject} - req := &http.Request{} + req := &http.Request{Method: "GET"} - Convey("->Prepare()", func() { - response, err := testList.Prepare(req, true) + Convey("->Validate()", func() { + err := testList.Validate(req, true) So(err, ShouldBeNil) - So(response.HTTPStatus, ShouldEqual, http.StatusOK) }) Convey("->Send(list)", func() { @@ -51,5 +51,28 @@ func TestList(t *testing.T) { So(len(responseList), ShouldEqual, 1) }) }) + + Convey("->UnmarshalJSON()", func() { + + Convey("should handle a data object", func() { + jObj := `{"data": {"type": "user", "id": "sweetID123", "attributes": {"ID":"123"}}}` + + l := List{} + err := l.UnmarshalJSON([]byte(jObj)) + log.Printf("l = %+v\n", l) + So(err, ShouldBeNil) + So(l, ShouldNotBeEmpty) + }) + + Convey("should handle a data list", func() { + jList := `{"data": [{"type": "user", "id": "sweetID123", "attributes": {"ID":"123"}}]}` + + l := List{} + err := l.UnmarshalJSON([]byte(jList)) + log.Printf("l = %+v\n", l) + So(err, ShouldBeNil) + So(l, ShouldNotBeEmpty) + }) + }) }) } diff --git a/object.go b/object.go index 8342401..c108fa2 100644 --- a/object.go +++ b/object.go @@ -15,6 +15,9 @@ type Object struct { Attributes json.RawMessage `json:"attributes,omitempty"` Links map[string]*Link `json:"links,omitempty"` Relationships map[string]*Object `json:"relationships,omitempty"` + // Status is the HTTP Status Code that should be associated with the object + // when it is sent. + Status int `json:"-"` } // NewObject prepares a new JSON Object for an API response. Whatever is provided @@ -36,53 +39,57 @@ func NewObject(id string, resourceType string, attributes interface{}) (*Object, return object, nil } -// Unmarshal puts an Object's Attributes into a more useful target resourceType defined -// by the user. A correct object resourceType specified must also be provided otherwise -// an error is returned to prevent hard to track down situations. -// -// Optionally, used https://github.com/go-validator/validator for request input validation. -// Simply define your struct with valid input tags: -// -// struct { -// Username string `json:"username" valid:"required,alphanum"` -// } -// -// -// As the final action, the Unmarshal function will run govalidator on the unmarshal -// result. If the validator fails, a Sendable error response of HTTP Status 422 will -// be returned containing each validation error with a populated Error.Source.Pointer -// specifying each struct attribute that failed. In this case, all you need to do is: -// -// errors := obj.Unmarshal("mytype", &myType) -// if errors != nil { -// // log errors via error.ISE -// jsh.Send(w, r, errors) -// } -func (o *Object) Unmarshal(resourceType string, target interface{}) *Error { +/* +Unmarshal puts an Object's Attributes into a more useful target resourceType defined +by the user. A correct object resourceType specified must also be provided otherwise +an error is returned to prevent hard to track down situations. + +Optionally, used https://github.com/go-validator/validator for request input validation. +Simply define your struct with valid input tags: + + struct { + Username string `json:"username" valid:"required,alphanum"` + } + + +As the final action, the Unmarshal function will run govalidator on the unmarshal +result. If the validator fails, a Sendable error response of HTTP Status 422 will +be returned containing each validation error with a populated Error.Source.Pointer +specifying each struct attribute that failed. In this case, all you need to do is: + + errors := obj.Unmarshal("mytype", &myType) + if errors != nil { + // log errors via error.ISE + jsh.Send(w, r, errors) + } +*/ +func (o *Object) Unmarshal(resourceType string, target interface{}) []*Error { if resourceType != o.Type { - return ISE(fmt.Sprintf( + return []*Error{ISE(fmt.Sprintf( "Expected type %s, when converting actual type: %s", resourceType, o.Type, - )) + ))} } jsonErr := json.Unmarshal(o.Attributes, target) if jsonErr != nil { - return ISE(fmt.Sprintf( + return []*Error{ISE(fmt.Sprintf( "For type '%s' unable to marshal: %s\nError:%s", resourceType, string(o.Attributes), jsonErr.Error(), - )) + ))} } return validateInput(target) } -// Marshal allows you to load a modified payload back into an object to preserve -// all of the data it has +/* +Marshal allows you to load a modified payload back into an object to preserve +all of the data it has. +*/ func (o *Object) Marshal(attributes interface{}) *Error { raw, err := json.MarshalIndent(attributes, "", " ") if err != nil { @@ -93,41 +100,64 @@ func (o *Object) Marshal(attributes interface{}) *Error { return nil } -// Prepare creates a new JSON single object response with an appropriate HTTP status -// to match the request method type. -func (o *Object) Prepare(r *http.Request, response bool) (*Response, *Error) { +/* +Validate ensures that an object is JSON API compatible. Has a side effect of also +setting the Object's Status attribute to be used as the Response HTTP Code if one +has not already been set. +*/ +func (o *Object) Validate(r *http.Request, response bool) *Error { if o.ID == "" { // don't error if the client is attempting to performing a POST request, in // which case, ID shouldn't actually be set if !response && r.Method != "POST" { - return nil, SpecificationError("ID must be set for Object response") + return SpecificationError("ID must be set for Object response") } } if o.Type == "" { - return nil, SpecificationError("Type must be set for Object response") + return SpecificationError("Type must be set for Object response") } - var status int switch r.Method { case "POST": - status = http.StatusCreated + acceptable := map[int]bool{201: true, 202: true, 204: true} + + if o.Status != 0 { + if _, validCode := acceptable[o.Status]; !validCode { + return SpecificationError("POST Status must be one of 201, 202, or 204.") + } + break + } + + o.Status = http.StatusCreated + break case "PATCH": - status = http.StatusOK + acceptable := map[int]bool{200: true, 202: true, 204: true} + + if o.Status != 0 { + if _, validCode := acceptable[o.Status]; !validCode { + return SpecificationError("PATCH Status must be one of 200, 202, or 204.") + } + break + } + + o.Status = http.StatusOK + break case "GET": - status = http.StatusOK + o.Status = http.StatusOK + break // If we hit this it means someone is attempting to use an unsupported HTTP // method. Return a 406 error instead default: return SpecificationError(fmt.Sprintf( "The JSON Specification does not accept '%s' requests.", r.Method, - )).Prepare(r, response) + )) } - return &Response{HTTPStatus: status, Data: o}, nil + return nil } // String prints a formatted string representation of the object @@ -142,7 +172,7 @@ func (o *Object) String() string { // validateInput runs go-validator on each attribute on the struct and returns all // errors that it picks up -func validateInput(target interface{}) *Error { +func validateInput(target interface{}) []*Error { _, validationError := govalidator.ValidateStruct(target) if validationError != nil { @@ -150,19 +180,17 @@ func validateInput(target interface{}) *Error { errorList, isType := validationError.(govalidator.Errors) if isType { - err := &Error{} + errors := []*Error{} for _, singleErr := range errorList.Errors() { // parse out validation error goValidErr, _ := singleErr.(govalidator.Error) - inputErr := InputError(goValidErr.Name, goValidErr.Err.Error()) + inputErr := InputError(goValidErr.Err.Error(), goValidErr.Name) - // gross way to do this, but will require a refactor probably - // to achieve something more elegant - err.Add(inputErr.Objects[0]) + errors = append(errors, inputErr) } - return err + return errors } } diff --git a/object_test.go b/object_test.go index 2bc5ba6..ed991a8 100644 --- a/object_test.go +++ b/object_test.go @@ -75,7 +75,7 @@ func TestObject(t *testing.T) { err := testObject.Unmarshal("testObject", &testValidation) So(err, ShouldNotBeNil) - So(err.Objects[0].Source.Pointer, ShouldEqual, "/data/attributes/foo") + So(err[0].Source.Pointer, ShouldEqual, "/data/attributes/foo") }) Convey("should return a 422 Error correctly for multiple validation failures", func() { @@ -94,8 +94,8 @@ func TestObject(t *testing.T) { err := testManyObject.Unmarshal("testObject", &testManyValidations) So(err, ShouldNotBeNil) - So(err.Objects[0].Source.Pointer, ShouldEqual, "/data/attributes/foo") - So(err.Objects[1].Source.Pointer, ShouldEqual, "/data/attributes/baz") + So(err[0].Source.Pointer, ShouldEqual, "/data/attributes/foo") + So(err[1].Source.Pointer, ShouldEqual, "/data/attributes/baz") }) }) }) @@ -113,34 +113,34 @@ func TestObject(t *testing.T) { }) }) - Convey("->Prepare()", func() { + Convey("->JSON()", func() { Convey("should handle a POST response correctly", func() { request.Method = "POST" - resp, err := testObject.Prepare(request, true) + err := testObject.Validate(request, true) So(err, ShouldBeNil) - So(resp.HTTPStatus, ShouldEqual, http.StatusCreated) + So(testObject.Status, ShouldEqual, http.StatusCreated) }) Convey("should handle a GET response correctly", func() { request.Method = "GET" - resp, err := testObject.Prepare(request, true) + err := testObject.Validate(request, true) So(err, ShouldBeNil) - So(resp.HTTPStatus, ShouldEqual, http.StatusOK) + So(testObject.Status, ShouldEqual, http.StatusOK) }) Convey("should handle a PATCH response correctly", func() { request.Method = "PATCH" - resp, err := testObject.Prepare(request, true) + err := testObject.Validate(request, true) So(err, ShouldBeNil) - So(resp.HTTPStatus, ShouldEqual, http.StatusOK) + So(testObject.Status, ShouldEqual, http.StatusOK) }) Convey("should return a formatted Error for an unsupported method Type", func() { request.Method = "PUT" - resp, err := testObject.Prepare(request, true) - So(err, ShouldBeNil) - So(resp.HTTPStatus, ShouldEqual, http.StatusNotAcceptable) + err := testObject.Validate(request, true) + So(err, ShouldNotBeNil) + So(err.Status, ShouldEqual, http.StatusNotAcceptable) }) }) diff --git a/parser.go b/parser.go index 69cef14..4752fc1 100644 --- a/parser.go +++ b/parser.go @@ -4,66 +4,78 @@ import ( "encoding/json" "fmt" "io" - "io/ioutil" "log" "net/http" ) -const ( - // ContentType is the data encoding of choice for HTTP Request and Response Headers - ContentType = "application/vnd.api+json" -) +/* +ParseObject validates the HTTP request and returns a JSON object for a given +io.ReadCloser containing a raw JSON payload. Here's an example of how to use it +as part of your full flow. + + func Handler(w http.ResponseWriter, r *http.Request) { + obj, error := jsh.ParseObject(r) + if error != nil { + // log your error + err := jsh.Send(w, r, error) + return + } -// ParseObject validates the HTTP request and returns a JSON object for a given -// io.ReadCloser containing a raw JSON payload. Here's an example of how to use it -// as part of your full flow. -// -// func Handler(w http.ResponseWriter, r *http.Request) { -// obj, error := jsh.ParseObject(r) -// if error != nil { -// // log your error -// err := jsh.Send(w, r, error) -// return -// } -// -// yourType := &YourType{} -// -// err := object.Unmarshal("yourtype", &yourType) -// if err != nil { -// err := jsh.Send(w, r, err) -// return -// } -// -// yourType.ID = obj.ID -// // do business logic -// -// err := object.Marshal(yourType) -// if err != nil { -// // log error -// err := jsh.Send(w, r, err) -// return -// } -// -// err := jsh.Send(w, r, object) -// } -func ParseObject(r *http.Request) (*Object, *Error) { + yourType := &YourType{} + + err := object.Unmarshal("yourtype", &yourType) + if err != nil { + err := jsh.Send(w, r, err) + return + } - object, err := buildParser(r).GetObject() + yourType.ID = obj.ID + // do business logic + + err := object.Marshal(yourType) + if err != nil { + // log error + err := jsh.Send(w, r, err) + return + } + + err := jsh.Send(w, r, object) + } +*/ +func ParseObject(r *http.Request) (*Object, *Error) { + document, err := ParseJSON(r) if err != nil { return nil, err } + if !document.HasData() { + return nil, nil + } + + object := document.First() if r.Method != "POST" && object.ID == "" { - return nil, InputError("id", "Missing mandatory object attribute") + return nil, InputError("Missing mandatory object attribute", "id") } return object, nil } -// ParseList validates the HTTP request and returns a resulting list of objects -// parsed from the request Body. Use just like ParseObject. +/* +ParseList validates the HTTP request and returns a resulting list of objects +parsed from the request Body. Use just like ParseObject. +*/ func ParseList(r *http.Request) (List, *Error) { - return buildParser(r).GetList() + document, err := ParseJSON(r) + if err != nil { + return nil, err + } + + return document.Data, nil +} + +// ParseJSON is a convenience function that returns a top level jsh.JSON document +func ParseJSON(r *http.Request) (*Document, *Error) { + return NewParser(r).Document(r.Body) } // Parser is an abstraction layer to support parsing JSON payload from many types @@ -71,102 +83,55 @@ func ParseList(r *http.Request) (List, *Error) { type Parser struct { Method string Headers http.Header - Payload io.ReadCloser } -// BuildParser creates a parser from an http.Request -func buildParser(request *http.Request) *Parser { +// NewParser creates a parser from an http.Request +func NewParser(request *http.Request) *Parser { return &Parser{ Method: request.Method, Headers: request.Header, - Payload: request.Body, } } -// GetObject returns a single JSON data object from the parser -func (p *Parser) GetObject() (*Object, *Error) { - byteData, loadErr := prepareJSON(p.Headers, p.Payload) - if loadErr != nil { - return nil, loadErr - } +/* +Document returns a single JSON data object from the parser. +*/ +func (p *Parser) Document(payload io.ReadCloser) (*Document, *Error) { + defer closeReader(payload) - data := struct { - Object *Object `json:"data"` - }{} - - err := json.Unmarshal(byteData, &data) + err := validateHeaders(p.Headers) if err != nil { - return nil, ISE(fmt.Sprintf("Unable to parse json: \n%s\nError:%s", - string(byteData), - err.Error(), - )) - } - - object := data.Object - - inputErr := validateInput(object) - if inputErr != nil { - return nil, inputErr - } - - return object, nil -} - -// GetList returns a JSON data list from the parser -func (p *Parser) GetList() (List, *Error) { - byteData, loadErr := prepareJSON(p.Headers, p.Payload) - if loadErr != nil { - return nil, loadErr + return nil, err } - data := struct { - List List `json:"data"` - }{List{}} - - err := json.Unmarshal(byteData, &data) - if err != nil { - return nil, ISE(fmt.Sprintf("Unable to parse json: \n%s\nError:%s", - string(byteData), - err.Error(), - )) + document := &Document{Data: List{}} + decodeErr := json.NewDecoder(payload).Decode(document) + if decodeErr != nil { + return nil, ISE(fmt.Sprintf("Error parsing JSON Document: %s", decodeErr.Error())) } - for _, object := range data.List { - err := validateInput(object) - if err != nil { - return nil, err - } - - if object.ID == "" { - return nil, InputError("id", "Object without ID present in list") + if document.HasData() { + for _, object := range document.Data { + inputErr := validateInput(object) + if inputErr != nil { + return nil, inputErr[0] + } + + // if we have a list, then all resource objects should have IDs, will + // cross the bridge of bulk creation if and when there is a use case + if len(document.Data) > 1 && object.ID == "" { + return nil, InputError("Object without ID present in list", "id") + } } } - return data.List, nil -} - -// prepareJSON ensures that the provide headers are JSON API compatible and then -// reads and closes the closer -func prepareJSON(headers http.Header, closer io.ReadCloser) ([]byte, *Error) { - defer closeReader(closer) - - validationErr := validateHeaders(headers) - if validationErr != nil { - return nil, validationErr - } - - byteData, err := ioutil.ReadAll(closer) - if err != nil { - return nil, ISE(fmt.Sprintf("Error attempting to read request body: %s", err)) - } - - return byteData, nil + return document, nil } func closeReader(reader io.ReadCloser) { err := reader.Close() if err != nil { - log.Println("Unabled to close request Body") + log.Println("Unable to close request Body") } } diff --git a/parser_test.go b/parser_test.go index 118a716..5346318 100644 --- a/parser_test.go +++ b/parser_test.go @@ -19,17 +19,7 @@ func TestParsing(t *testing.T) { err := validateHeaders(req.Header) So(err, ShouldNotBeNil) - So(err.Objects[0].Status, ShouldEqual, http.StatusNotAcceptable) - }) - - Convey("->prepareJSON()", func() { - req, err := http.NewRequest("GET", "", CreateReadCloser([]byte("1234"))) - So(err, ShouldBeNil) - req.Header.Set("Content-Type", ContentType) - - bytes, err := prepareJSON(req.Header, req.Body) - So(err, ShouldBeNil) - So(string(bytes), ShouldEqual, "1234") + So(err.Status, ShouldEqual, http.StatusNotAcceptable) }) Convey("->ParseObject()", func() { @@ -37,11 +27,11 @@ func TestParsing(t *testing.T) { Convey("should parse a valid object", func() { objectJSON := `{"data": {"type": "user", "id": "sweetID123", "attributes": {"ID":"123"}}}` - req, reqErr := testRequest([]byte(objectJSON)) So(reqErr, ShouldBeNil) object, err := ParseObject(req) + So(err, ShouldBeNil) So(object, ShouldNotBeEmpty) So(object.Type, ShouldEqual, "user") @@ -57,8 +47,8 @@ func TestParsing(t *testing.T) { _, err := ParseObject(req) So(err, ShouldNotBeNil) - So(err.Objects[0].Status, ShouldEqual, 422) - So(err.Objects[0].Source.Pointer, ShouldEqual, "/data/attributes/type") + So(err.Status, ShouldEqual, 422) + So(err.Source.Pointer, ShouldEqual, "/data/attributes/type") }) Convey("should accept empty ID only for POST", func() { @@ -86,9 +76,9 @@ func TestParsing(t *testing.T) { listJSON := `{"data": [ - {"type": "user", "id": "sweetID123", "attributes": {"ID":"123"}}, - {"type": "user", "id": "sweetID456", "attributes": {"ID":"456"}} -]}` + {"type": "user", "id": "sweetID123", "attributes": {"ID":"123"}}, + {"type": "user", "id": "sweetID456", "attributes": {"ID":"456"}} + ]}` req, reqErr := testRequest([]byte(listJSON)) So(reqErr, ShouldBeNil) @@ -105,17 +95,17 @@ func TestParsing(t *testing.T) { Convey("should error for an invalid list", func() { listJSON := `{"data": [ - {"type": "user", "id": "sweetID123", "attributes": {"ID":"123"}}, - {"type": "user", "attributes": {"ID":"456"}} -]}` + {"type": "user", "id": "sweetID123", "attributes": {"ID":"123"}}, + {"type": "user", "attributes": {"ID":"456"}} + ]}` req, reqErr := testRequest([]byte(listJSON)) So(reqErr, ShouldBeNil) _, err := ParseList(req) So(err, ShouldNotBeNil) - So(err.Objects[0].Status, ShouldEqual, 422) - So(err.Objects[0].Source.Pointer, ShouldEqual, "/data/attributes/id") + So(err.Status, ShouldEqual, 422) + So(err.Source.Pointer, ShouldEqual, "/data/attributes/id") }) }) }) diff --git a/response.go b/response.go index b7f7969..f9f4e17 100644 --- a/response.go +++ b/response.go @@ -14,89 +14,43 @@ const JSONAPIVersion = "1.1" // Sendable implements functions that allows different response types // to produce a sendable JSON Response format type Sendable interface { - // Prepare allows a "raw" response type to perform specification assertions, - // and format any data before it is actually send - Prepare(r *http.Request, response bool) (*Response, *Error) -} - -// Response represents the top level json format of incoming requests -// and outgoing responses. Refer to the JSON API Specification for a full descriptor -// of each attribute: http://jsonapi.org/format/#document-structure -type Response struct { - Data interface{} `json:"data,omitempty"` - Errors interface{} `json:"errors,omitempty"` - Meta interface{} `json:"meta,omitempty"` - Links *Link `json:"links,omitempty"` - Included *List `json:"included,omitempty"` - JSONAPI struct { - Version string `json:"version"` - } `json:"jsonapi"` - // Custom HTTPStatus attribute to make it simpler to define expected responses - // for a given context - HTTPStatus int `json:"-"` - // empty is used to signify that the response shouldn't contain a json payload - // in the case that we only want to return an HTTP Status Code in order to bypass - // validation steps - empty bool -} - -// Validate checks JSON Spec for the top level JSON document -func (r *Response) Validate() *Error { - - if !r.empty { - if r.Errors == nil && r.Data == nil { - return ISE("Both `errors` and `data` cannot be blank for a JSON response") - } - if r.Errors != nil && r.Data != nil { - return ISE("Both `errors` and `data` cannot be set for a JSON response") - } - if r.Data == nil && r.Included != nil { - return ISE("'included' should only be set for a response if 'data' is as well") - } - } - if r.HTTPStatus < 100 || r.HTTPStatus > 600 { - return ISE("Response HTTP Status is outside of valid range") - } - - // probably not the best place for this, but... - r.JSONAPI.Version = JSONAPIVersion - - return nil + Validate(r *http.Request, response bool) *Error } // Send will return a JSON payload to the requestor. If the payload response validation // fails, it will send an appropriate error to the requestor and will return the error func Send(w http.ResponseWriter, r *http.Request, payload Sendable) *Error { - response, err := payload.Prepare(r, true) - if err != nil { + validationErr := payload.Validate(r, true) + if validationErr != nil { // use the prepared error as the new response, unless something went horribly // wrong - response, err = err.Prepare(r, true) + err := validationErr.Validate(r, true) if err != nil { http.Error(w, DefaultErrorTitle, http.StatusInternalServerError) return err } + + payload = validationErr } - return SendResponse(w, r, response) + return SendDocument(w, r, Build(payload)) } -// SendResponse handles sending a fully packaged JSON Response which is useful if you -// require manual, or custom, validation when building a Response: -// -// response := &jsh.Response{ -// HTTPStatus: http.StatusAccepted, -// } -// -// The function will always send but will return the last error it encountered -// to help with debugging in the event of an ISE. -func SendResponse(w http.ResponseWriter, r *http.Request, response *Response) *Error { +/* +SendDocument handles sending a fully prepared JSON Document. This is useful if you +require custom validation or additional build steps before sending. - err := response.Validate() - if err != nil { - errResp, prepErr := err.Prepare(r, true) +SendJSON is designed to always send a response, but will also return the last +error it encountered to help with debugging in the event of an Internal Server +Error. +*/ +func SendDocument(w http.ResponseWriter, r *http.Request, document *Document) *Error { + + validationErr := document.Validate(r, true) + if validationErr != nil { + prepErr := validationErr.Validate(r, true) // If we ever hit this, something seriously wrong has happened if prepErr != nil { @@ -105,10 +59,10 @@ func SendResponse(w http.ResponseWriter, r *http.Request, response *Response) *E } // if we didn't error out, make this the new response - response = errResp + document = Build(validationErr) } - content, jsonErr := json.MarshalIndent(response, "", " ") + content, jsonErr := json.MarshalIndent(document, "", " ") if jsonErr != nil { http.Error(w, DefaultErrorTitle, http.StatusInternalServerError) return ISE(fmt.Sprintf("Unable to marshal JSON payload: %s", jsonErr.Error())) @@ -116,27 +70,19 @@ func SendResponse(w http.ResponseWriter, r *http.Request, response *Response) *E w.Header().Add("Content-Type", ContentType) w.Header().Set("Content-Length", strconv.Itoa(len(content))) - w.WriteHeader(response.HTTPStatus) + w.WriteHeader(document.Status) w.Write(content) - if err != nil { - return err - } - - return nil + return validationErr } -// OkResponse fulfills the Sendable interface for a simple success response -type OkResponse struct{} - // Ok makes it simple to return a 200 OK response via jsh: // -// jsh.Send(w, r, jsh.Ok()) -func Ok() *OkResponse { - return &OkResponse{} -} +// jsh.SendDocument(w, r, jsh.Ok()) +func Ok() *Document { + doc := New() + doc.Status = http.StatusOK + doc.empty = true -// Prepare turns OkResponse into the normalized Response type -func (o *OkResponse) Prepare(r *http.Request, response bool) (*Response, *Error) { - return &Response{HTTPStatus: http.StatusOK, empty: true}, nil + return doc } diff --git a/response_test.go b/response_test.go index 18c4289..3f9093d 100644 --- a/response_test.go +++ b/response_test.go @@ -44,8 +44,8 @@ func TestSend(t *testing.T) { }) Convey("->Ok()", func() { - ok := Ok() - err := Send(writer, request, ok) + doc := Ok() + err := SendDocument(writer, request, doc) So(err, ShouldBeNil) So(writer.Code, ShouldEqual, http.StatusOK) })