Skip to content

Commit

Permalink
adds support for self-describing Parameters (#52)
Browse files Browse the repository at this point in the history
- users can now create custom types that have `GetName` and `GetDesc`
functions, these names and descriptions are used by all endpoints that
add the param, allowing for reuse of common types.
- All params are now returned by the discovery url, providing additional
information to API clients

fixes #43
  • Loading branch information
xentek authored Mar 27, 2017
1 parent 2f9739b commit e77c46d
Show file tree
Hide file tree
Showing 9 changed files with 217 additions and 35 deletions.
57 changes: 47 additions & 10 deletions discovery.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,22 +12,37 @@ type RootResource struct {
XMLName xml.Name `json:"-" xml:"api"`
Resource string `json:"resource" xml:"-"`
Name string `json:"name" xml:"name,attr"`
Endpoints []EndpointResource `json:"endpoints" xml:"endpoints"`
Endpoints []EndpointResource `json:"endpoints" xml:"endpoints>endpoint"`
}

// EndpointResource contains information about and Endpoint, and is
// the hypermedia respresentation returned by the Discovery URL endpoint for
// API clients to learn about the Endpoint.
type EndpointResource struct {
XMLName xml.Name `json:"-" xml:"endpoint"`
Resource string `json:"resource" xml:"-"`
Name string `json:"name" xml:"name,attr"`
Path string `json:"path" xml:"path,attr"`
MethodsList string `json:"-" xml:"methods,attr"`
Methods []string `json:"methods" xml:"-"`
MediaTypesList string `json:"-" xml:"media-types,attr"`
MediaTypes []string `json:"media-types" xml:"-"`
Desc string `json:"description" xml:"description"`
XMLName xml.Name `json:"-" xml:"endpoint"`
Resource string `json:"resource" xml:"-"`
Name string `json:"name" xml:"name,attr"`
Path string `json:"path" xml:"path,attr"`
MethodsList string `json:"-" xml:"methods,attr"`
Methods []string `json:"methods" xml:"-"`
MediaTypesList string `json:"-" xml:"media-types,attr"`
MediaTypes []string `json:"media-types" xml:"-"`
Desc string `json:"description" xml:"description"`
Params []EndpointResourceParam `json:"params" xml:"params>param"`
}

// EndpointResourceParam contains information about endpoint parameters, and is
// part of the hypermedia representation returned by the Discovery URL endpoint
// for API clients to learn about input allowed (and/or required) by the
// Endpoint.
type EndpointResourceParam struct {
XMLName xml.Name `json:"-" xml:"param"`
Name string `json:"name" xml:"name,attr"`
Desc string `json:"description" xml:"description"`
AllowedList string `json:"-" xml:"allowed,attr"`
Allowed []string `json:"allowed" xml:"-"`
RequiredList string `json:"-" xml:"required,attr"`
Required []string `json:"required" xml:"-"`
}

// NewRootResource creates an instance of RootResource from the given API.
Expand All @@ -46,7 +61,29 @@ func NewEndpointResource(e Endpointer) EndpointResource {
MediaTypesList: GetContentTypesList(hAPI, e),
MediaTypes: GetContentTypes(hAPI, e),
Desc: e.GetDesc(),
Params: createEndpointResourceParams(e),
}
}

// NewEndpointResourceParam creates an instance of EndpointResourceParam from the given parsedParam.
func NewEndpointResourceParam(p parsedParam) EndpointResourceParam {
return EndpointResourceParam{
Name: p.Name,
Desc: p.Desc,
Allowed: p.Allowed,
AllowedList: p.AllowedList(),
Required: p.Required,
RequiredList: p.RequiredList(),
}
}

func createEndpointResourceParams(e Endpointer) []EndpointResourceParam {
var params = []EndpointResourceParam{}
pp := parseEndpoint(e)
for _, p := range pp {
params = append(params, NewEndpointResourceParam(p))
}
return params
}

// AddEndpoint adds EndpointResources to the slice of Endpoints on an instance of RootResource.
Expand Down
8 changes: 8 additions & 0 deletions discovery_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,14 @@ func (suite *HyperdriveTestSuite) TestAddEndpointer() {
suite.Equal(1, len(suite.TestRoot.Endpoints), "expects 1 Endpoints")
}

func (suite *HyperdriveTestSuite) TestNewEndpointResource() {
suite.Equal(suite.TestEndpointResourceCustom, NewEndpointResource(suite.TestCustomEndpoint), "expects the correct EndpointResource")
}

func (suite *HyperdriveTestSuite) TestNewEndpointResourceParam() {
suite.Equal(suite.TestEndpointResourceParam, NewEndpointResourceParam(suite.TestParsedParamCustom), "expects the correct EndpointResource")
}

func (suite *HyperdriveTestSuite) TestRootResourceServeHTTP() {
suite.Implements((*http.Handler)(nil), suite.TestRoot, "return an implementation of http.Handler")
}
Expand Down
20 changes: 18 additions & 2 deletions encoder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,15 @@ func (suite *HyperdriveTestSuite) TestJSONEncoderEncode() {
rw := httptest.NewRecorder()
enc := JSONEncoder{Encoder: json.NewEncoder(rw)}
enc.Encode(suite.TestEndpointResource)
json := `{"resource":"endpoint","name":"Test","path":"/test","methods":["OPTIONS"],"media-types":["application/vnd.api.test.v1.0.1-beta.json","application/vnd.api.test.v1.0.1-beta.xml"],"description":"Test Endpoint"}` + "\n"
json := `{"resource":"endpoint","name":"Test","path":"/test","methods":["OPTIONS"],"media-types":["application/vnd.api.test.v1.0.1-beta.json","application/vnd.api.test.v1.0.1-beta.xml"],"description":"Test Endpoint","params":[]}` + "\n"
suite.Equal(json, rw.Body.String(), "returns nil")
}

func (suite *HyperdriveTestSuite) TestJSONEncoderEncodeParams() {
rw := httptest.NewRecorder()
enc := JSONEncoder{Encoder: json.NewEncoder(rw)}
enc.Encode(suite.TestEndpointResourceCustom)
json := `{"resource":"endpoint","name":"Test","path":"/test","methods":["OPTIONS"],"media-types":["application/vnd.api.test.v1.0.1-beta.json","application/vnd.api.test.v1.0.1-beta.xml"],"description":"Test Endpoint","params":[{"name":"ID","description":"The unique identifer for this resource.","allowed":["GET","PATCH","POST","PUT"],"required":["GET"]}]}` + "\n"
suite.Equal(json, rw.Body.String(), "returns nil")
}

Expand All @@ -46,7 +54,15 @@ func (suite *HyperdriveTestSuite) TestXMLEncoderEncode() {
rw := httptest.NewRecorder()
enc := XMLEncoder{Encoder: xml.NewEncoder(rw)}
enc.Encode(suite.TestEndpointResource)
xml := `<endpoint name="Test" path="/test" methods="OPTIONS" media-types="application/vnd.api.test.v1.0.1-beta.json,application/vnd.api.test.v1.0.1-beta.xml"><description>Test Endpoint</description></endpoint>`
xml := `<endpoint name="Test" path="/test" methods="OPTIONS" media-types="application/vnd.api.test.v1.0.1-beta.json,application/vnd.api.test.v1.0.1-beta.xml"><description>Test Endpoint</description><params></params></endpoint>`
suite.Equal(xml, rw.Body.String(), "returns nil")
}

func (suite *HyperdriveTestSuite) TestXMLEncoderEncodeParams() {
rw := httptest.NewRecorder()
enc := XMLEncoder{Encoder: xml.NewEncoder(rw)}
enc.Encode(suite.TestEndpointResourceCustom)
xml := `<endpoint name="Test" path="/test" methods="OPTIONS" media-types="application/vnd.api.test.v1.0.1-beta.json,application/vnd.api.test.v1.0.1-beta.xml"><description>Test Endpoint</description><params><param name="ID" allowed="GET,PATCH,POST,PUT" required="GET"><description>The unique identifer for this resource.</description></param></params></endpoint>`
suite.Equal(xml, rw.Body.String(), "returns nil")
}

Expand Down
2 changes: 1 addition & 1 deletion endpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ func GetMethodsList(e Endpointer) string {
}

// NewMethodHandler sets the correct http.Handler for each method, depending on
// the interfaces the Enpointer supports. It returns an http.Handler, ready
// the interfaces the Endpointer supports. It returns an http.Handler, ready
// to be served directly, wrapped in other middleware, etc.
func NewMethodHandler(e Endpointer) http.Handler {
handler := make(handlers.MethodHandler)
Expand Down
81 changes: 62 additions & 19 deletions hyperdrive_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package hyperdrive

import (
"encoding/xml"
"net/http"
"net/http/httptest"
"strings"
Expand All @@ -22,23 +23,42 @@ type TaggedEndpoint struct {
ID string `param:"id;r=GET"`
}

type CustomEndpoint struct {
Endpoint
ID ID `param:"id;r=GET"`
}

type ID int

func (id *ID) GetName() string {
return "ID"
}

func (id *ID) GetDesc() string {
return "The unique identifer for this resource."
}

type HyperdriveTestSuite struct {
suite.Suite
TestAPI API
TestEndpoint Endpointer
TestHandler http.Handler
TestRoot *RootResource
TestEndpointResource EndpointResource
TestGetRequest *http.Request
TestGetRequestNoParams *http.Request
TestPostRequest *http.Request
TestParsedParam parsedParam
TestParsedParamDefault parsedParam
TestParsedParamEmpty parsedParam
TestParsedParamRequired parsedParam
TestTaggedStruct *TaggedStruct
TestParsedParamMap parsedParams
TestTaggedEndpoint *TaggedEndpoint
TestAPI API
TestEndpoint Endpointer
TestHandler http.Handler
TestRoot *RootResource
TestEndpointResource EndpointResource
TestEndpointResourceCustom EndpointResource
TestEndpointResourceParam EndpointResourceParam
TestGetRequest *http.Request
TestGetRequestNoParams *http.Request
TestPostRequest *http.Request
TestParsedParam parsedParam
TestParsedParamDefault parsedParam
TestParsedParamEmpty parsedParam
TestParsedParamRequired parsedParam
TestParsedParamCustom parsedParam
TestTaggedStruct *TaggedStruct
TestParsedParamMap parsedParams
TestTaggedEndpoint *TaggedEndpoint
TestCustomEndpoint *CustomEndpoint
}

func (suite *HyperdriveTestSuite) SetupTest() {
Expand All @@ -47,16 +67,39 @@ func (suite *HyperdriveTestSuite) SetupTest() {
suite.TestHandler = NewMethodHandler(suite.TestEndpoint)
suite.TestRoot = NewRootResource(suite.TestAPI)
suite.TestEndpointResource = NewEndpointResource(suite.TestEndpoint)
suite.TestEndpointResourceParam = EndpointResourceParam{
XMLName: xml.Name{Space: "", Local: ""},
Name: "ID",
Desc: "The unique identifer for this resource.",
Allowed: []string{"GET", "PATCH", "POST", "PUT"},
AllowedList: "GET,PATCH,POST,PUT",
Required: []string{"GET"},
RequiredList: "GET",
}
suite.TestEndpointResourceCustom = EndpointResource{
XMLName: xml.Name{Space: "", Local: ""},
Resource: "endpoint",
Name: "Test",
Path: "/test",
MethodsList: "OPTIONS",
Methods: []string{"OPTIONS"},
MediaTypesList: "application/vnd.api.test.v1.0.1-beta.json,application/vnd.api.test.v1.0.1-beta.xml",
MediaTypes: []string{"application/vnd.api.test.v1.0.1-beta.json", "application/vnd.api.test.v1.0.1-beta.xml"},
Desc: "Test Endpoint",
Params: []EndpointResourceParam{suite.TestEndpointResourceParam},
}
suite.TestGetRequest = httptest.NewRequest("GET", "/test/2?id=1&a=b", nil)
suite.TestGetRequestNoParams = httptest.NewRequest("GET", "/test", nil)
suite.TestPostRequest = httptest.NewRequest("POST", "/test/2?id=1&a=b", strings.NewReader(`{"id":3}`))
suite.TestParsedParam = parsedParam{"TestParam", "string", "test_param", []string{"GET", "PUT"}, []string{"PUT"}}
suite.TestParsedParamDefault = parsedParam{"TestParamDefault", "string", "test_param_default", []string{"GET", "PATCH", "POST", "PUT"}, []string{}}
suite.TestParsedParamEmpty = parsedParam{"TestParamEmpty", "string", "TestParamEmpty", []string{"GET", "PATCH", "POST", "PUT"}, []string{}}
suite.TestParsedParamRequired = parsedParam{"TestParamRequired", "string", "test_param_required", []string{"GET", "PUT"}, []string{"PUT"}}
suite.TestParsedParam = parsedParam{"TestParam", "...", "TestParam", "string", "test_param", []string{"GET", "PUT"}, []string{"PUT"}}
suite.TestParsedParamDefault = parsedParam{"TestParamDefault", "...", "TestParamDefault", "string", "test_param_default", []string{"GET", "PATCH", "POST", "PUT"}, []string{}}
suite.TestParsedParamEmpty = parsedParam{"TestParamEmpty", "...", "TestParamEmpty", "string", "TestParamEmpty", []string{"GET", "PATCH", "POST", "PUT"}, []string{}}
suite.TestParsedParamRequired = parsedParam{"TestParamRequired", "...", "TestParamRequired", "string", "test_param_required", []string{"GET", "PUT"}, []string{"PUT"}}
suite.TestParsedParamCustom = parsedParam{"ID", "The unique identifer for this resource.", "ID", "ID", "id", []string{"GET", "PATCH", "POST", "PUT"}, []string{"GET"}}
suite.TestParsedParamMap = parsedParams{"test_param": suite.TestParsedParam, "test_param_default": suite.TestParsedParamDefault, "TestParamEmpty": suite.TestParsedParamEmpty, "test_param_required": suite.TestParsedParamRequired}
suite.TestTaggedStruct = &TaggedStruct{Endpoint: *NewEndpoint("Test", "Test Endpoint", "/test", "1.0.1-beta")}
suite.TestTaggedEndpoint = &TaggedEndpoint{Endpoint: *NewEndpoint("Test", "Test Endpoint", "/test", "1.0.1-beta")}
suite.TestCustomEndpoint = &CustomEndpoint{Endpoint: *NewEndpoint("Test", "Test Endpoint", "/test", "1.0.1-beta")}
}

func (suite *HyperdriveTestSuite) TestNewAPI() {
Expand Down
10 changes: 8 additions & 2 deletions params.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ package hyperdrive

import (
"fmt"
"log"
"net/http"
"net/url"

Expand Down Expand Up @@ -67,7 +66,6 @@ func GetParams(e Endpointer, r *http.Request) (url.Values, error) {
pp := parseEndpoint(e)
p := Params(r)
for k := range p {
log.Println("k=%v,contains=%v", k, pp.Allowed(r.Method))
if contains(pp.Allowed(r.Method), k) != true {
p.Del(k)
}
Expand All @@ -80,3 +78,11 @@ func GetParams(e Endpointer, r *http.Request) (url.Values, error) {
}
return p, nil
}

// Parameter is an interface to allow users to create self-describing custom types
// to be used as endpoint params. The name and description are reusable
// across multiple endpoints.
type Parameter interface {
GetName() string
GetDesc() string
}
4 changes: 4 additions & 0 deletions params_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,3 +78,7 @@ func (suite *HyperdriveTestSuite) TestGetParamsError() {
suite.Equal(url.Values{}, params, "returns populated url.Values")
suite.Error(err, "returns populated url.Values")
}

func (suite *HyperdriveTestSuite) TestParameter() {
suite.Implements((*Parameter)(nil), new(ID), "is an implementation of Parameter")
}
30 changes: 29 additions & 1 deletion tag.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ const (
)

type parsedParam struct {
Name string
Desc string
Field string
Type string
Key string
Expand All @@ -23,10 +25,18 @@ func (p parsedParam) IsAllowed(method string) bool {
return contains(p.Allowed, method)
}

func (p parsedParam) AllowedList() string {
return strings.Join(p.Allowed, ",")
}

func (p parsedParam) IsRequired(method string) bool {
return contains(p.Required, method)
}

func (p parsedParam) RequiredList() string {
return strings.Join(p.Required, ",")
}

type parsedParams map[string]parsedParam

func (pp parsedParams) Allowed(method string) []string {
Expand Down Expand Up @@ -78,6 +88,7 @@ func parseField(field reflect.StructField) parsedParam {
allowed = []string{"GET", "POST", "PUT", "PATCH"}
required = []string{}
)

t := field.Tag.Get(tagName)
tags = strings.Split(t, ";")
key, tags = tags[0], tags[1:]
Expand All @@ -103,5 +114,22 @@ func parseField(field reflect.StructField) parsedParam {
required = set.Strings(required)
allowed = append(allowed, required...)
allowed = set.Strings(allowed)
return parsedParam{field.Name, field.Type.Name(), key, allowed, required}
name, desc := fieldNameAndDesc(field)
return parsedParam{name, desc, field.Name, field.Type.Name(), key, allowed, required}
}

func fieldNameAndDesc(field reflect.StructField) (string, string) {
var name = field.Name
var desc = "..."
in := []reflect.Value{reflect.New(field.Type)}

if gname, ok := reflect.PtrTo(field.Type).MethodByName("GetName"); ok {
name = gname.Func.Call(in)[0].String()
}

if gdesc, ok := reflect.PtrTo(field.Type).MethodByName("GetDesc"); ok {
desc = gdesc.Func.Call(in)[0].String()
}

return name, desc
}
40 changes: 40 additions & 0 deletions tag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ func (suite *HyperdriveTestSuite) TestIsRequiredFalse() {
suite.Equal(false, suite.TestParsedParam.IsRequired("POST"), "expects it to return false")
}

func (suite *HyperdriveTestSuite) TestAllowedList() {
suite.Equal("GET,PUT", suite.TestParsedParamMap["test_param"].AllowedList(), "expects it to return the correct methods")
}

func (suite *HyperdriveTestSuite) TestRequiredList() {
suite.Equal("PUT", suite.TestParsedParamMap["test_param"].RequiredList(), "expects it to return the correct methods")
}

func (suite *HyperdriveTestSuite) TestContainsTrue() {
suite.Equal(true, contains([]string{"GET"}, "GET"), "expects it to return true")
}
Expand All @@ -32,6 +40,26 @@ func (suite *HyperdriveTestSuite) TestParse() {
suite.IsType(parsedParams{}, parseEndpoint(suite.TestTaggedStruct), "expects it to return a map of parsedParams")
}

func (suite *HyperdriveTestSuite) TestParsedParamsAllowed() {
suite.IsType([]string{}, suite.TestParsedParamMap.Allowed("GET"), "expects it to return the correct slice of strings")
}

func (suite *HyperdriveTestSuite) TestParsedParamsNotAllowed() {
suite.IsType([]string{}, suite.TestParsedParamMap.Allowed("POST"), "expects it to return the correct slice of strings")
}

func (suite *HyperdriveTestSuite) TestParsedParamsRequired() {
suite.IsType([]string{}, suite.TestParsedParamMap.Required("PUT"), "expects it to return the correct slice of strings")
}

func (suite *HyperdriveTestSuite) TestParsedParamsNotRequired() {
suite.IsType([]string{}, suite.TestParsedParamMap.Required("GET"), "expects it to return the correct slice of strings")
}

func (suite *HyperdriveTestSuite) TestParsedParamsRequiredEmpty() {
suite.IsType([]string{}, suite.TestParsedParamMap.Required("POST"), "expects it to return a map of parsedParams")
}

func (suite *HyperdriveTestSuite) TestParseTestParam() {
suite.Equal(suite.TestParsedParamMap["test_param"], parseEndpoint(suite.TestTaggedStruct)["test_param"], "expects it to return the correct parsedParam")
}
Expand All @@ -47,3 +75,15 @@ func (suite *HyperdriveTestSuite) TestParseTestParamEmpty() {
func (suite *HyperdriveTestSuite) TestParseTestParamRequired() {
suite.Equal(suite.TestParsedParamMap["test_param_required"], parseEndpoint(suite.TestTaggedStruct)["test_param_required"], "expects it to return the correct parsedParam")
}

func (suite *HyperdriveTestSuite) TestParameterName() {
suite.Equal("ID", parseEndpoint(suite.TestCustomEndpoint)["id"].Name, "expects it to return the correct Name")
}

func (suite *HyperdriveTestSuite) TestParameterDesc() {
suite.Equal("The unique identifer for this resource.", parseEndpoint(suite.TestCustomEndpoint)["id"].Desc, "expects it to return the correct Desc")
}

func (suite *HyperdriveTestSuite) TestParseTestParamCustom() {
suite.Equal(suite.TestParsedParamCustom, parseEndpoint(suite.TestCustomEndpoint)["id"], "expects it to return the correct parsedParam")
}

0 comments on commit e77c46d

Please sign in to comment.