Skip to content

Commit

Permalink
Flushing out response objects, simplifying API, more spec checks
Browse files Browse the repository at this point in the history
  • Loading branch information
Derek Dowling committed Nov 14, 2015
1 parent 4d142c2 commit ffaf161
Show file tree
Hide file tree
Showing 11 changed files with 517 additions and 218 deletions.
33 changes: 28 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,17 +1,40 @@
Go JSON API
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)

Go API helpers for achieving a [JSON API Specification](http://jsonapi.org/)
Go JSON API helpers for achieving a [JSON API Specification](http://jsonapi.org/)
compatible backend.

## Mission
## Features

To make writing an API that matches the JSON Specification easy in Go.
Implemented:

- Handles both single object and array based JSON requests and responses
- Input validation with HTTP 422 Status support via [go-validator](https://github.com/go-validator/validator)
- Client request validation with HTTP 406 Status responses
- Links, Relationship, Meta fields
- Prepackaged error responses, easy to use Internal Service Error builder
- Smart responses with correct HTTP Statuses based on Request Method and HTTP Headers

TODO:

- Reserved character checking

Not Implenting:

* 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.

- Relationship management
- Sorting
- Pagination
- Filtering

## Installation

```
$ go get github.com/derekdowling/go-jsonapi
$ go get github.com/derekdowling/go-json-spec-handler
```
105 changes: 102 additions & 3 deletions error.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,112 @@
package jsh

// Error represents a JSON Specification Error.
// Error.Source.Pointer is used in 422 status responses to indicate validation
// errors on a JSON Object attribute.
import (
"fmt"
"net/http"
)

// 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"

// Error represents a JSON Specification Error. Error.Source.Pointer is used in 422
// status responses to indicate validation errors on a JSON Object attribute.
//
// ISE (internal server error) captures the server error internally to help with
// logging/troubleshooting, but is never returned in a response.
//
// Once a jsh.Error is returned, and you have logged/handled it accordingly, you
// can simply return it using jsh.SendError() or jsh.SendErrors()
type Error struct {
Title string `json:"title"`
Detail string `json:"detail"`
Status int `json:"status"`
Source struct {
Pointer string `json:"pointer"`
} `json:"source"`
ISE string `json:"-"`
}

// Prepare returns a response containing a prepared error list since the JSON
// API specification requires that errors are returned as a list
func (e *Error) Prepare(req *http.Request) (*Response, *Error) {
list := &ErrorList{Errors: []*Error{e}}
return list.Prepare(req)
}

// ErrorList is just a wrapped error array that implements Sendable
type ErrorList struct {
Errors []*Error
}

// Add first validates the error, and then appends it to the ErrorList
func (e *ErrorList) Add(newError *Error) error {
err := validateError(newError)
if err != nil {
return err
}

e.Errors = append(e.Errors, newError)
return nil
}

// Prepare first validates the errors, and then returns an appropriate response
func (e *ErrorList) Prepare(req *http.Request) (*Response, *Error) {
if len(e.Errors) == 0 {
return nil, ISE("No errors provided for attempted error response.")
}

return &Response{Errors: e.Errors, HTTPStatus: e.Errors[0].Status}, nil
}

// validateError ensures that the error is ready for a response in it's current state
func validateError(err *Error) error {

if err.Status < 400 || err.Status > 600 {
return fmt.Errorf("Invalid HTTP Status for error %+v\n", err)
} else if err.Status == 422 && err.Source.Pointer == "" {
return fmt.Errorf("Source Pointer must be set for 422 Status errors")
}

return nil
}

// ISE is a convenience function for creating a ready-to-go Internal Service Error
// response. As previously mentioned, the Error.ISE field is for logging only, and
// won't be returned to the end user.
func ISE(err string) *Error {
return &Error{
Title: DefaultErrorTitle,
Detail: DefaultErrorDetail,
Status: http.StatusInternalServerError,
ISE: err,
}
}

// 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 {
err := &Error{
Title: "Invalid Attribute",
Detail: detail,
Status: 422,
}

// Assign this after the fact, easier to do
err.Source.Pointer = fmt.Sprintf("data/attributes/%s", attribute)

return err
}

// SpecificationError is used whenever the Client violates the JSON API Spec
func SpecificationError(detail string) *Error {
return &Error{
Title: "API Specification Error",
Detail: detail,
Status: http.StatusNotAcceptable,
}
}
87 changes: 87 additions & 0 deletions error_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package jsh

import (
"log"
"net/http"
"net/http/httptest"
"strconv"
"testing"

. "github.com/smartystreets/goconvey/convey"
)

func TestError(t *testing.T) {

Convey("Error Tests", t, func() {

writer := httptest.NewRecorder()
request := &http.Request{}

testError := &Error{
Status: http.StatusBadRequest,
Title: "Fail",
Detail: "So badly",
}

Convey("->validateError()", func() {

Convey("should not fail for a valid Error", func() {
validErr := ISE("Valid Error")
err := validateError(validErr)
So(err, ShouldBeNil)
})

Convey("422 Status Formatting", func() {

testError.Status = 422

Convey("should accept a properly formatted 422 error", func() {
testError.Source.Pointer = "data/attributes/test"
err := validateError(testError)
So(err, ShouldBeNil)
})

Convey("should error if Source.Pointer isn't set", func() {
err := validateError(testError)
So(err, ShouldNotBeNil)
})
})

Convey("should fail for an out of range HTTP error status", func() {
testError.Status = http.StatusOK
err := validateError(testError)
So(err, ShouldNotBeNil)
})
})

Convey("->Send()", func() {

testErrors := &ErrorList{Errors: []*Error{&Error{
Status: http.StatusForbidden,
Title: "Forbidden",
Detail: "Can't Go Here",
}, testError}}

Convey("->Send(Error)", func() {
err := Send(request, writer, testError)
So(err, ShouldBeNil)
log.Printf("err = %+v\n", err)
log.Printf("writer = %+v\n", writer)
So(writer.Code, ShouldEqual, http.StatusBadRequest)
})

Convey("should send a properly formatted JSON Error", func() {
err := Send(request, writer, testErrors)

So(err, ShouldBeNil)
So(writer.Code, ShouldEqual, http.StatusForbidden)

contentLength, convErr := strconv.Atoi(writer.HeaderMap.Get("Content-Length"))
So(convErr, ShouldBeNil)
So(contentLength, ShouldBeGreaterThan, 0)
So(writer.HeaderMap.Get("Content-Type"), ShouldEqual, ContentType)
})

})
})
}
13 changes: 13 additions & 0 deletions link.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package jsh

// Links is a top-level document field
type Links struct {
Self *Link `json:"self,omitempty"`
Related *Link `json:"related,omitempty"`
}

// Link is a JSON format type
type Link struct {
HREF string `json:"href,omitempty"`
Meta map[string]interface{} `json:"meta,omitempty"`
}
13 changes: 13 additions & 0 deletions list.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package jsh

import "net/http"

// List is just a wrapper around an object array that implements Sendable
type List struct {
Objects []*Object `json:"data"`
}

// Prepare returns a success status response
func (l List) Prepare(r *http.Request) (*Response, *Error) {
return &Response{Data: l.Objects, HTTPStatus: http.StatusOK}, nil
}
52 changes: 52 additions & 0 deletions list_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package jsh

import (
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"

. "github.com/smartystreets/goconvey/convey"
)

func TestList(t *testing.T) {

Convey("List Tests", t, func() {

testObject := &Object{
ID: "ID123",
Type: "testConversion",
Attributes: json.RawMessage(`{"foo":"bar"}`),
}

testList := &List{Objects: []*Object{testObject}}
req := &http.Request{}

Convey("->Prepare()", func() {
response, err := testList.Prepare(req)
So(err, ShouldBeNil)
So(response.HTTPStatus, ShouldEqual, http.StatusOK)
})

Convey("->Send(list)", func() {

Convey("should send a properly formatted List response", func() {

writer := httptest.NewRecorder()
err := Send(req, writer, testList)

So(err, ShouldBeNil)
So(writer.Code, ShouldEqual, http.StatusOK)
contentLength, convErr := strconv.Atoi(writer.HeaderMap.Get("Content-Length"))
So(convErr, ShouldBeNil)
So(contentLength, ShouldBeGreaterThan, 0)
So(writer.HeaderMap.Get("Content-Type"), ShouldEqual, ContentType)

closer := createIOCloser(writer.Body.Bytes())
responseList, err := ParseList(closer)
So(len(responseList), ShouldEqual, 1)
})
})
})
}
Loading

0 comments on commit ffaf161

Please sign in to comment.