Designing a features storage service - part 3
Once I have my in-memory feature storage implemented, it’s fairly easy to implement the other layers for this service.
package api
import (
"github.com/bigkevmcd/go-features/store"
)
func New(fs store.FeatureStore, l Logger) *API {
return &API{fs: fs, log: l}
}
type API struct {
fs store.FeatureStore
log Logger
}
func (a *API) GetFeaturesForResource(resource, id string) ([]string, error) {
a.log.Debugf("fetching all features for resource=%s id=%s", resource, id)
return a.fs.GetFeaturesForResource(resource, id)
}
func (a *API) ResourceHasFeature(resource, id, feature string) (bool, error) {
a.log.Debugf("checking for resource feature resource=%s id=%s feature=%s",
resource, id, feature)
return a.fs.HasFeature(resource, id, feature)
}
func (a *API) AddFeatureToResource(resource, id, feature string) error {
a.log.Debugf("adding feature to resource resource=%s id=%s feature=%s",
resource, id, feature)
return a.fs.AddFeature(resource, id, feature)
}
This is basically delegating the calls to the underlying store, and logging the requests, it’d be fairly easy to time the calls, in order to improve visibility into issues fetchng from the store.
And to provide an HTTP implementation of this microservice…
package http
import (
"encoding/json"
"net/http"
"github.com/bigkevmcd/go-features/api"
"github.com/julienschmidt/httprouter"
)
func NewHTTPEndpoint(api *api.API) *HTTPEndpoint {
endpoint := &HTTPEndpoint{Router: httprouter.New(), api: api}
endpoint.GET("/features/:resource/:id", endpoint.GetFeaturesForResource)
endpoint.GET("/features/:resource/:id/:feature", endpoint.ResourceHasFeature)
endpoint.POST("/features/:resource/:id/:feature", endpoint.AddFeatureToResource)
return endpoint
}
type HTTPEndpoint struct {
api *api.API
*httprouter.Router
}
func (e *HTTPEndpoint) GetFeaturesForResource(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
resource := p.ByName("resource")
resourceId := p.ByName("id")
features, err := e.api.GetFeaturesForResource(resource, resourceId)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(features)
}
type hasFeatureResponse struct {
Result bool `json:"result"`
}
func (e *HTTPEndpoint) ResourceHasFeature(w http.ResponseWriter, r *http.Request, p httprouter.Params) {
resource := p.ByName("resource")
resourceId := p.ByName("id")
feature := p.ByName("feature")
has, err := e.api.ResourceHasFeature(resource, resourceId, feature)
if err != nil {
http.Error(w, err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(hasFeatureResponse{Result: has})
}
This is fairly simple to implement, and tests are fairly simple too…
package http
import (
"bytes"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/bigkevmcd/go-features/api"
store "github.com/bigkevmcd/go-features/store/memory"
)
func createRequest(method, url string, body []byte) *http.Request {
req := httptest.NewRequest(method, url, bytes.NewBuffer(body))
req.Header.Set("Content-Type", "application/json")
return req
}
func decodeJSON(t *testing.T, resp *http.Response) []byte {
if resp.StatusCode != http.StatusOK {
t.Fatalf("got %v, wanted %v", resp.StatusCode, http.StatusOK)
}
body, _ := ioutil.ReadAll(resp.Body)
defer resp.Body.Close()
return body
}
func createAPI() *api.API {
store := store.NewFeatureStore()
return api.New(store)
}
func TestGetFeaturesForResource(t *testing.T) {
endpoint := NewHTTPEndpoint(createAPI())
req := createRequest("GET", "http://example.com/features/pipeline/76bd2782-b603-4590-ac71-aa43d278bc31", nil)
w := httptest.NewRecorder()
endpoint.ServeHTTP(w, req)
resp := w.Result()
body := decodeJSON(t, resp)
if b := string(body); b != "[]\n" {
t.Fatalf("got %s, wanted %s", b, "[]")
}
}
func TestGetFeaturesForResourceWithFeatures(t *testing.T) {
api := createAPI()
api.AddFeatureToResource("pipeline", "76bd2782-b603-4590-ac71-aa43d278bc31", "feature-1")
api.AddFeatureToResource("pipeline", "76bd2782-b603-4590-ac71-aa43d278bc31", "feature-2")
endpoint := NewHTTPEndpoint(api)
req := createRequest("GET", "http://example.com/features/pipeline/76bd2782-b603-4590-ac71-aa43d278bc31", nil)
w := httptest.NewRecorder()
endpoint.ServeHTTP(w, req)
resp := w.Result()
body := decodeJSON(t, resp)
if b := string(body); b != "[\"feature-1\",\"feature-2\"]\n" {
t.Fatalf("got %s, wanted %s", b, "[\"feature-1\",\"feature-2\"]\n")
}
}
Which makes my cmd/api-service/main.go
look like this…
package main
import (
"net/http"
"github.com/bigkevmcd/go-features/api"
httpapi "github.com/bigkevmcd/go-features/http"
"github.com/bigkevmcd/go-features/store/memory"
log "github.com/sirupsen/logrus"
)
func main() {
logger := log.WithFields(log.Fields{
"srv": "api-service",
})
log.SetLevel(log.DebugLevel)
store := memory.NewFeatureStore()
api := api.New(store, logger)
srv := httpapi.NewHTTPEndpoint(api)
log.Println("listening on port :8080")
log.Fatal(http.ListenAndServe(":8080", srv))
}
Exercising the service is fairly simple…
$ curl -X POST http://localhost:8080/features/users/1840298b-5ee4-4122-99a2-5798b252133d/feature1
$ curl http://localhost:8080/features/users/1840298b-5ee4-4122-99a2-5798b252133d/feature1
{"result":true}
$ curl http://localhost:8080/features/users/1840298b-5ee4-4122-99a2-5798b252133d
["feature1"]