From 9a7fb5e9aac5411decc969ebe23ae20a5de806df Mon Sep 17 00:00:00 2001 From: Eric Marden Date: Thu, 9 Mar 2017 17:50:26 -0600 Subject: [PATCH] adds automatic discovery url at API root - adds automatic discvoery url when the API root endpoint is requested, e.g. `GET /` - the response, currently in JSON, is put together by a `Representation` type, which is essentially just a `map[string]interface{}` - adds help methods for creating a slice or list of the HTTP methods a given endpoint supports - continued reorg of files was done in this branch with tests moved to their respective `_test.go` counterparts fixes #1 --- config_test.go | 34 +++++++++++++++++++++ discovery.go | 64 ++++++++++++++++++++++++++++++++++++++++ discovery_test.go | 33 +++++++++++++++++++++ endpoint.go | 73 ++++++++++++++++++++++++++++++++++++++++++++-- endpoint_test.go | 29 ++++++++++++++++++ hyperdrive.go | 46 ++++++++++------------------- hyperdrive_test.go | 70 +++++++++----------------------------------- middleware.go | 6 ++++ middleware_test.go | 15 ++++++++++ 9 files changed, 281 insertions(+), 89 deletions(-) create mode 100644 config_test.go create mode 100644 discovery.go create mode 100644 discovery_test.go create mode 100644 endpoint_test.go create mode 100644 middleware_test.go diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..ae8618c --- /dev/null +++ b/config_test.go @@ -0,0 +1,34 @@ +package hyperdrive + +import "os" + +func (suite *HyperdriveTestSuite) TestNewConfig() { + suite.IsType(Config{}, NewConfig(), "expects an instance of *hyperdrive.Config") +} + +func (suite *HyperdriveTestSuite) TestPortConfigFromDefault() { + c := NewConfig() + suite.Equal(5000, c.Port, "Port should be equal to default value") +} + +func (suite *HyperdriveTestSuite) TestPortConfigFromEnv() { + os.Setenv("PORT", "5001") + c := NewConfig() + suite.Equal(5001, c.Port, "Port should be equal to PORT value set via ENV var") +} + +func (suite *HyperdriveTestSuite) TestGetPort() { + c := NewConfig() + suite.Equal(":5000", c.GetPort(), "c.Port value should be return, prefixed with a colon, e.g. :5000") +} + +func (suite *HyperdriveTestSuite) TestEnvConfigFromDefault() { + c := NewConfig() + suite.Equal("development", c.Env, "Env should be equal to default value") +} + +func (suite *HyperdriveTestSuite) TestEnvConfigFromEnv() { + os.Setenv("HYPERDRIVE_ENVIRONMENT", "test") + c := NewConfig() + suite.Equal("test", c.Env, "Env should be equal to HYPERDRIVE_ENVIRONMENT value set via ENV var") +} diff --git a/discovery.go b/discovery.go new file mode 100644 index 0000000..5c6c06e --- /dev/null +++ b/discovery.go @@ -0,0 +1,64 @@ +package hyperdrive + +import ( + "encoding/json" + "net/http" +) + +// Representation is a data structure representing the response output. The +// representation is used when automatically encoding responses based on the +// Content Type determined by content negotation. +type Representation map[string]interface{} + +// RootResource contains information about the API and its Endpoints, and is +// the hypermedia respresentation returned by the Discovery URL endpoint for +// API clients to learn about the API. +type RootResource struct { + Name string + Endpoints []Endpointer +} + +// NewRootResource creates an instance of RootResource, based on the given API. +func NewRootResource(api API) *RootResource { + return &RootResource{Name: api.Name} +} + +// AddEndpointer adds Endpointers to the slice of Endpointers on an instance of RootResource. +func (root *RootResource) AddEndpointer(e Endpointer) { + root.Endpoints = append(root.Endpoints, e) +} + +// Present returns an Representation of the RootResource to describe the API +// for the Discovery URL. +func (root *RootResource) Present() Representation { + return Representation{ + "resource": "api", + "name": root.Name, + "endpoints": root.endpointRepresentations(), + } +} + +func (root *RootResource) endpointRepresentations() []Representation { + var endpoints = []Representation{} + for _, e := range root.Endpoints { + endpoints = append(endpoints, PresentEndpoint(e)) + } + return endpoints +} + +// PresentEndpoint returns a Representation to describe an Endpoint for the Discovery URL. +func PresentEndpoint(e Endpointer) Representation { + return Representation{ + "name": e.GetName(), + "desc": e.GetDesc(), + "path": e.GetPath(), + "methods": GetMethods(e), + } +} + +// ServeHTTP satisfies the http.Handler interface and returns the hypermedia +// representation of the Discovery URL. +func (root *RootResource) ServeHTTP(rw http.ResponseWriter, r *http.Request) { + rw.Header().Set("Content-Type", "application/json") + json.NewEncoder(rw).Encode(root.Present()) +} diff --git a/discovery_test.go b/discovery_test.go new file mode 100644 index 0000000..83b5da4 --- /dev/null +++ b/discovery_test.go @@ -0,0 +1,33 @@ +package hyperdrive + +import "net/http" + +func (suite *HyperdriveTestSuite) TestNewRootResource() { + suite.IsType(&RootResource{}, suite.TestRoot, "expects an instance of RootResource") +} + +func (suite *HyperdriveTestSuite) TestRootResourceEndpointsEmpty() { + suite.Equal(0, len(suite.TestRoot.Endpoints), "expects 0 Endpoints") +} + +func (suite *HyperdriveTestSuite) TestAddEndpointer() { + suite.TestRoot.AddEndpointer(suite.TestEndpoint) + suite.Equal(1, len(suite.TestRoot.Endpoints), "expects 1 Endpoints") +} + +func (suite *HyperdriveTestSuite) TestRootResourceServeHTTP() { + suite.Implements((*http.Handler)(nil), suite.TestRoot, "return an implementation of http.Handler") +} + +func (suite *HyperdriveTestSuite) TestPresentRepresentation() { + suite.IsType(Representation{}, suite.TestRoot.Present(), "return a Representation") +} + +func (suite *HyperdriveTestSuite) TestPresent() { + suite.TestRoot.AddEndpointer(suite.TestEndpoint) + suite.Equal(suite.TestRootRepresentation, suite.TestRoot.Present(), "return the correct Representation of RootResource") +} + +func (suite *HyperdriveTestSuite) TestPresentEndpoint() { + suite.Equal(suite.TestEndpointRepresentation, PresentEndpoint(suite.TestEndpoint), "return the correct Representation of RootResource") +} diff --git a/endpoint.go b/endpoint.go index e8607b2..7be14a1 100644 --- a/endpoint.go +++ b/endpoint.go @@ -1,6 +1,11 @@ package hyperdrive -import "net/http" +import ( + "net/http" + "strings" + + "github.com/gorilla/handlers" +) // GetHandler interface is satisfied if the endpoint has implemented // a http.Handler method called Get(). If this is not implemented, @@ -52,7 +57,8 @@ type OptionsHandler interface { } // Endpointer interface provides flexibility in how endpoints are created -// allowing for expressiveness how developers make use of this package. +// allowing for expressiveness in how developers make use of the hyperdrive +// package. type Endpointer interface { GetName() string GetDesc() string @@ -92,3 +98,66 @@ func (e *Endpoint) GetPath() string { func NewEndpoint(name string, desc string, path string) *Endpoint { return &Endpoint{EndpointName: name, EndpointDesc: desc, EndpointPath: path} } + +// GetMethods returns a slice of the methods an Endpoint supports. +func GetMethods(e Endpointer) []string { + var methods = []string{"OPTIONS"} + + if _, ok := interface{}(e).(GetHandler); ok { + methods = append(methods, "GET") + } + + if _, ok := interface{}(e).(PostHandler); ok { + methods = append(methods, "POST") + } + + if _, ok := interface{}(e).(PutHandler); ok { + methods = append(methods, "PUT") + } + + if _, ok := interface{}(e).(PatchHandler); ok { + methods = append(methods, "PATCH") + } + + if _, ok := interface{}(e).(DeleteHandler); ok { + methods = append(methods, "DELETE") + } + + return methods +} + +// GetMethodsList returns a list of the methods an Endpoint supports. +func GetMethodsList(e Endpointer) string { + return strings.Join(GetMethods(e), ", ") +} + +// NewMethodHandler sets the correct http.Handler for each method, depending on +// the interfaces the Enpointer supports. It returns an http.HandlerFunc, ready +// to be served directly, wrapped in other middleware, etc. +func NewMethodHandler(e Endpointer) http.HandlerFunc { + handler := make(handlers.MethodHandler) + if h, ok := interface{}(e).(GetHandler); ok { + handler["GET"] = http.HandlerFunc(h.Get) + } + + if h, ok := interface{}(e).(PostHandler); ok { + handler["POST"] = http.HandlerFunc(h.Post) + } + + if h, ok := interface{}(e).(PutHandler); ok { + handler["PUT"] = http.HandlerFunc(h.Put) + } + + if h, ok := interface{}(e).(PatchHandler); ok { + handler["PATCH"] = http.HandlerFunc(h.Patch) + } + + if h, ok := interface{}(e).(DeleteHandler); ok { + handler["DELETE"] = http.HandlerFunc(h.Delete) + } + + if h, ok := interface{}(e).(OptionsHandler); ok { + handler["OPTIONS"] = http.HandlerFunc(h.Options) + } + return http.HandlerFunc(handler.ServeHTTP) +} diff --git a/endpoint_test.go b/endpoint_test.go new file mode 100644 index 0000000..fa62947 --- /dev/null +++ b/endpoint_test.go @@ -0,0 +1,29 @@ +package hyperdrive + +func (suite *HyperdriveTestSuite) TestNewEndpoint() { + suite.IsType(&Endpoint{}, suite.TestEndpoint, "expects an instance of hyperdrive.Endpoint") +} + +func (suite *HyperdriveTestSuite) TestGetName() { + suite.Equal("Test", suite.TestEndpoint.GetName(), "expects GetName() to return Name") +} + +func (suite *HyperdriveTestSuite) TestGetDesc() { + suite.Equal("Test Endpoint", suite.TestEndpoint.GetDesc(), "expects GetDesc() to return Desc") +} + +func (suite *HyperdriveTestSuite) TestGetPath() { + suite.Equal("/test", suite.TestEndpoint.GetPath(), "expects GetPath() to return Path") +} + +func (suite *HyperdriveTestSuite) TestEndpointer() { + suite.Implements((*Endpointer)(nil), suite.TestEndpoint, "expects an implementation of hyperdrive.Endpointer interface") +} + +func (suite *HyperdriveTestSuite) TestGetMethods() { + suite.Equal([]string{"OPTIONS"}, GetMethods(suite.TestEndpoint), "expects a slice of supported method strings") +} + +func (suite *HyperdriveTestSuite) TestGetMethodsList() { + suite.Equal("OPTIONS", GetMethodsList(suite.TestEndpoint), "expects a list of supported method strings") +} diff --git a/hyperdrive.go b/hyperdrive.go index 31963ae..d52a44e 100644 --- a/hyperdrive.go +++ b/hyperdrive.go @@ -5,22 +5,31 @@ import ( "net/http" "time" - "github.com/gorilla/handlers" "github.com/gorilla/mux" ) // API is a logical collection of one or more endpoints, connecting requests // to the response handlers using a gorlla mux Router. type API struct { + Name string + Desc string Router *mux.Router Server *http.Server + Root *RootResource conf Config endpoints []Endpoint } -// NewAPI creates an instance of an API with an initialized Router. -func NewAPI() API { - api := API{Router: mux.NewRouter(), conf: NewConfig()} +// NewAPI creates an instance of API, with an initialized Router, Config, Server, and RootResource. +func NewAPI(name string, desc string) API { + api := API{ + Name: name, + Desc: desc, + Router: mux.NewRouter(), + conf: NewConfig(), + } + api.Root = NewRootResource(api) + api.Router.Handle("/", api.DefaultMiddlewareChain(api.Root)).Methods("GET") api.Server = &http.Server{ Handler: api.Router, Addr: api.conf.GetPort(), @@ -34,33 +43,8 @@ func NewAPI() API { // respond with a 405 error if the endpoint does not support a particular // HTTP method. func (api *API) AddEndpoint(e Endpointer) { - handler := make(handlers.MethodHandler) - if h, ok := interface{}(e).(GetHandler); ok { - handler["GET"] = http.HandlerFunc(h.Get) - } - - if h, ok := interface{}(e).(PostHandler); ok { - handler["POST"] = http.HandlerFunc(h.Post) - } - - if h, ok := interface{}(e).(PutHandler); ok { - handler["PUT"] = http.HandlerFunc(h.Put) - } - - if h, ok := interface{}(e).(PatchHandler); ok { - handler["PATCH"] = http.HandlerFunc(h.Patch) - } - - if h, ok := interface{}(e).(DeleteHandler); ok { - handler["DELETE"] = http.HandlerFunc(h.Delete) - } - - if h, ok := interface{}(e).(OptionsHandler); ok { - handler["OPTIONS"] = http.HandlerFunc(h.Options) - } - - middleware := api.LoggingMiddleware(api.RecoveryMiddleware(http.HandlerFunc(handler.ServeHTTP))) - api.Router.Handle(e.GetPath(), middleware) + api.Root.AddEndpointer(e) + api.Router.Handle(e.GetPath(), api.DefaultMiddlewareChain(NewMethodHandler(e))) } // Start starts the configured http server, listening on the configured Port diff --git a/hyperdrive_test.go b/hyperdrive_test.go index 54e078c..abe32ea 100644 --- a/hyperdrive_test.go +++ b/hyperdrive_test.go @@ -2,7 +2,6 @@ package hyperdrive import ( "net/http" - "os" "testing" "github.com/stretchr/testify/suite" @@ -10,70 +9,29 @@ import ( type HyperdriveTestSuite struct { suite.Suite - Endpoint Endpointer + TestAPI API + TestEndpoint Endpointer + TestHandler http.Handler + TestRoot *RootResource + TestRootRepresentation Representation + TestEndpointRepresentation Representation } func (suite *HyperdriveTestSuite) SetupTest() { - suite.Endpoint = NewEndpoint("Test", "Test Endpoint", "/test") + suite.TestAPI = NewAPI("Test API", "Test API Desc") + suite.TestEndpoint = NewEndpoint("Test", "Test Endpoint", "/test") + suite.TestHandler = NewMethodHandler(suite.TestEndpoint) + suite.TestRoot = NewRootResource(suite.TestAPI) + suite.TestEndpointRepresentation = Representation{"name": "Test", "desc": "Test Endpoint", "path": "/test", "methods": []string{"OPTIONS"}} + suite.TestRootRepresentation = Representation{"resource": "api", "name": "Test API", "endpoints": []Representation{suite.TestEndpointRepresentation}} } func (suite *HyperdriveTestSuite) TestNewAPI() { - suite.IsType(API{}, NewAPI(), "expects an instance of hyperdrive.API") + suite.IsType(API{}, suite.TestAPI, "expects an instance of hyperdrive.API") } func (suite *HyperdriveTestSuite) TestAPIServer() { - suite.IsType(&http.Server{}, NewAPI().Server, "expects an instance of *http.Server") -} - -func (suite *HyperdriveTestSuite) TestNewEndpoint() { - suite.IsType(&Endpoint{}, suite.Endpoint, "expects an instance of hyperdrive.Endpoint") -} - -func (suite *HyperdriveTestSuite) TestGetName() { - suite.Equal("Test", suite.Endpoint.GetName(), "expects GetName() to return Name") -} - -func (suite *HyperdriveTestSuite) TestGetDesc() { - suite.Equal("Test Endpoint", suite.Endpoint.GetDesc(), "expects GetDesc() to return Desc") -} - -func (suite *HyperdriveTestSuite) TestGetPath() { - suite.Equal("/test", suite.Endpoint.GetPath(), "expects GetPath() to return Path") -} - -func (suite *HyperdriveTestSuite) TestEndpointer() { - suite.Implements((*Endpointer)(nil), suite.Endpoint, "expects an implementation of hyperdrive.Endpointer interface") -} - -func (suite *HyperdriveTestSuite) TestNewConfig() { - suite.IsType(Config{}, NewConfig(), "expects an instance of *hyperdrive.config") -} - -func (suite *HyperdriveTestSuite) TestPortConfigFromDefault() { - c := NewConfig() - suite.Equal(5000, c.Port, "Port should be equal to default value") -} - -func (suite *HyperdriveTestSuite) TestPortConfigFromEnv() { - os.Setenv("PORT", "5001") - c := NewConfig() - suite.Equal(5001, c.Port, "Port should be equal to PORT value set via ENV var") -} - -func (suite *HyperdriveTestSuite) TestGetPort() { - c := NewConfig() - suite.Equal(":5000", c.GetPort(), "c.Port value should be return, prefixed with a colon, e.g. :5000") -} - -func (suite *HyperdriveTestSuite) TestEnvConfigFromDefault() { - c := NewConfig() - suite.Equal("development", c.Env, "Env should be equal to default value") -} - -func (suite *HyperdriveTestSuite) TestEnvConfigFromEnv() { - os.Setenv("HYPERDRIVE_ENVIRONMENT", "test") - c := NewConfig() - suite.Equal("test", c.Env, "Env should be equal to HYPERDRIVE_ENVIRONMENT value set via ENV var") + suite.IsType(&http.Server{}, suite.TestAPI.Server, "expects an instance of *http.Server") } func TestHyperdriveTestSuite(t *testing.T) { diff --git a/middleware.go b/middleware.go index 4598679..02bee5a 100644 --- a/middleware.go +++ b/middleware.go @@ -7,6 +7,12 @@ import ( "github.com/gorilla/handlers" ) +// DefaultMiddlewareChain wraps the given http.Handler in the following chain +// of middleware: LoggingMiddleware, RecoveryMiddleware. +func (api *API) DefaultMiddlewareChain(h http.Handler) http.Handler { + return api.LoggingMiddleware(api.RecoveryMiddleware(h)) +} + // LoggingMiddleware wraps the given http.Handler and outputs requests in Apache-style // Combined Log format. All logging is done to STDOUT only. func (api *API) LoggingMiddleware(h http.Handler) http.Handler { diff --git a/middleware_test.go b/middleware_test.go new file mode 100644 index 0000000..588e61b --- /dev/null +++ b/middleware_test.go @@ -0,0 +1,15 @@ +package hyperdrive + +import "net/http" + +func (suite *HyperdriveTestSuite) TestDefaultMiddlewareChain() { + suite.Implements((*http.Handler)(nil), suite.TestAPI.DefaultMiddlewareChain(suite.TestHandler), "return an implementation of http.Handler") +} + +func (suite *HyperdriveTestSuite) TestLoggingMiddleware() { + suite.Implements((*http.Handler)(nil), suite.TestAPI.LoggingMiddleware(suite.TestHandler), "return an implementation of http.Handler") +} + +func (suite *HyperdriveTestSuite) TestRecoveryMiddleware() { + suite.Implements((*http.Handler)(nil), suite.TestAPI.RecoveryMiddleware(suite.TestHandler), "return an implementation of http.Handler") +}