From a40a81676e8fe8fec58ab3d265e2d810cc14ac56 Mon Sep 17 00:00:00 2001 From: Antoine Augusti Date: Mon, 14 Dec 2015 19:54:39 +0100 Subject: [PATCH 1/2] Implement endpoint POST features/access --- README.md | 32 ++++++++++++++++-- http/handlers.go | 71 ++++++++++++++++++++++++++++++--------- http/handlers_test.go | 78 +++++++++++++++++++++++++++++++++++++++---- http/routes.go | 9 ++++- 4 files changed, 165 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index af1ffd8..17542dd 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,8 @@ This API does not ship with an authentication layer. You **should not** expose t - [`GET` /features/:featureKey](#get-featuresfeaturekey) - Get a single feature flag - [`DELETE` /features/:featureKey](#delete-featuresfeaturekey) - Delete a feature flag - [`PATCH` /features/:featureKey](#patch-featuresfeaturekey) - Update a feature flag -- [`POST` /features/:featureKey/access](#get-featuresfeaturekeyaccess) - Check if someone has access to a feature +- [`POST` /features/access](#post-featuresaccess) - Get accessible features for a user or some groups +- [`POST` /features/:featureKey/access](#post-featuresfeaturekeyaccess) - Check if a user or some groups have access to a feature ### API Documentation #### `GET` `/features` @@ -57,7 +58,7 @@ Get a list of available feature flags. - Method: `GET` - Endpoint: `/features` - Responses: - * **200** on success + * 200 OK ```json [ { @@ -249,6 +250,33 @@ Update a feature flag. Common reason: - the percentage must be between `0` and `100` +#### `POST` `/features/access` +Get a list of accessible features for a user or a list of groups. +- Method: `POST` +- Endpoint: `/features/ccess` +- Input: + The `Content-Type` HTTP header should be set to `application/json` + + ```json + { + "groups":[ + "dev", + "test" + ], + "user":42 + } + ``` +- Responses: + * 200 OK + Same as in [`POST` /features](#post-features). An empty array indicates that no known features are accessible for the given input. + * 422 Unprocessable entity: + ```json + { + "status":"invalid_json", + "message":"Cannot decode the given JSON payload" + } + ``` + #### `POST` `/features/:featureKey/access` Check if a feature flag is enabled for a user or a list of groups. - Method: `POST` diff --git a/http/handlers.go b/http/handlers.go index 29bed16..e39d2f0 100644 --- a/http/handlers.go +++ b/http/handlers.go @@ -65,6 +65,37 @@ func (handler APIHandler) FeatureShow(w http.ResponseWriter, r *http.Request) { } } +func (handler APIHandler) FeaturesAccess(w http.ResponseWriter, r *http.Request) { + var ar AccessRequest + + // Get all features in the bucket + features, err := handler.FeatureService.GetFeatures() + if err != nil { + panic(err) + } + + // Decode the access request + err = json.NewDecoder(r.Body).Decode(&ar) + if err != nil { + writeUnprocessableEntity(err, w) + return + } + + // Keep only accessible features + accessibleFeatures := make(m.FeatureFlags, 0) + for _, feature := range features { + if hasAccessToFeature(feature, ar) { + accessibleFeatures = append(accessibleFeatures, feature) + } + } + + w.Header().Set("Content-Type", getJsonHeader()) + w.WriteHeader(http.StatusOK) + if err := json.NewEncoder(w).Encode(accessibleFeatures); err != nil { + panic(err) + } +} + func (handler APIHandler) FeatureAccess(w http.ResponseWriter, r *http.Request) { var ar AccessRequest vars := mux.Vars(r) @@ -81,8 +112,6 @@ func (handler APIHandler) FeatureAccess(w http.ResponseWriter, r *http.Request) panic(err) } - hasAccess := feature.IsEnabled() - // Decode the access request err = json.NewDecoder(r.Body).Decode(&ar) if err != nil { @@ -90,20 +119,7 @@ func (handler APIHandler) FeatureAccess(w http.ResponseWriter, r *http.Request) return } - if len(ar.Groups) > 0 { - for _, group := range ar.Groups { - if feature.GroupHasAccess(group) { - hasAccess = true - break - } - } - } - - if ar.User > 0 && !hasAccess { - hasAccess = feature.UserHasAccess(ar.User) - } - - if hasAccess { + if hasAccessToFeature(feature, ar) { writeMessage(http.StatusOK, "has_access", "The user has access to the feature", w) } else { writeMessage(http.StatusOK, "not_access", "The user does not have access to the feature", w) @@ -217,3 +233,26 @@ func writeMessage(code int, status string, message string, w http.ResponseWriter w.WriteHeader(apiMessage.code) w.Write(bytes) } + +func hasAccessToFeature(feature m.FeatureFlag, ar AccessRequest) bool { + // Handle trivial case + if feature.IsEnabled() { + return true + } + + // Access thanks to a group? + if len(ar.Groups) > 0 { + for _, group := range ar.Groups { + if feature.GroupHasAccess(group) { + return true + } + } + } + + // Access thanks to the user? + if ar.User > 0 { + return feature.UserHasAccess(ar.User) + } + + return false +} diff --git a/http/handlers_test.go b/http/handlers_test.go index 6e10c1d..07f4a8e 100644 --- a/http/handlers_test.go +++ b/http/handlers_test.go @@ -170,30 +170,92 @@ func TestEditFeatureFlag(t *testing.T) { assertResponseWithStatusAndMessage(t, res, http.StatusBadRequest, "invalid_feature", "Percentage must be between 0 and 100") } -func TestAccessFeatureFlag(t *testing.T) { +func TestAccessFeatureFlags(t *testing.T) { + var features m.FeatureFlags onStart() defer onFinish() + url := fmt.Sprintf("%s/access", base) + // Add the default dummy feature createDummyFeatureFlag() + // Invalid JSON payload + reader = strings.NewReader(`{foo:bar}`) + request, _ := http.NewRequest("POST", url, reader) + res, _ := http.DefaultClient.Do(request) + assert422Response(t, res) + // Access thanks to the user ID reader = strings.NewReader(`{"user":2}`) - request, _ := http.NewRequest("POST", fmt.Sprintf("%s/%s/access", base, "homepage_v2"), reader) + request, _ = http.NewRequest("POST", url, reader) + res, _ = http.DefaultClient.Do(request) + + json.NewDecoder(res.Body).Decode(&features) + assert.Equal(t, 1, len(features)) + assert.Equal(t, "homepage_v2", features[0].Key) + + // No access because of the user ID + reader = strings.NewReader(`{"user":0}`) + request, _ = http.NewRequest("POST", url, reader) + res, _ = http.DefaultClient.Do(request) + + json.NewDecoder(res.Body).Decode(&features) + assert.Equal(t, 0, len(features)) + + // Add a feature enabled for everybody + payload := `{ + "key":"testflag", + "enabled":true, + "users":[], + "groups":[], + "percentage":0 + }` + createFeatureWithPayload(payload) + + // Access thanks to the group + reader = strings.NewReader(`{"groups":["dev"]}`) + request, _ = http.NewRequest("POST", url, reader) + res, _ = http.DefaultClient.Do(request) + + json.NewDecoder(res.Body).Decode(&features) + assert.Equal(t, 2, len(features)) + assert.Equal(t, "homepage_v2", features[0].Key) + assert.Equal(t, "testflag", features[1].Key) +} + +func TestAccessFeatureFlag(t *testing.T) { + onStart() + defer onFinish() + + url := fmt.Sprintf("%s/%s/access", base, "homepage_v2") + + // Add the default dummy feature + createDummyFeatureFlag() + + // Invalid JSON payload + reader = strings.NewReader(`{foo:bar}`) + request, _ := http.NewRequest("POST", url, reader) res, _ := http.DefaultClient.Do(request) + assert422Response(t, res) + + // Access thanks to the user ID + reader = strings.NewReader(`{"user":2}`) + request, _ = http.NewRequest("POST", url, reader) + res, _ = http.DefaultClient.Do(request) assertAccessToTheFeature(t, res) // No access because of the user ID reader = strings.NewReader(`{"user":3}`) - request, _ = http.NewRequest("POST", fmt.Sprintf("%s/%s/access", base, "homepage_v2"), reader) + request, _ = http.NewRequest("POST", url, reader) res, _ = http.DefaultClient.Do(request) assertNoAccessToTheFeature(t, res) // Access thanks to the group reader = strings.NewReader(`{"user":3, "groups":["dev", "foo"]}`) - request, _ = http.NewRequest("POST", fmt.Sprintf("%s/%s/access", base, "homepage_v2"), reader) + request, _ = http.NewRequest("POST", url, reader) res, _ = http.DefaultClient.Do(request) assertAccessToTheFeature(t, res) @@ -229,8 +291,8 @@ func assertNoAccessToTheFeature(t *testing.T, res *http.Response) { assertResponseWithStatusAndMessage(t, res, http.StatusOK, "not_access", "The user does not have access to the feature") } -func createDummyFeatureFlag() *http.Response { - reader = strings.NewReader(getDummyFeaturePayload()) +func createFeatureWithPayload(payload string) *http.Response { + reader = strings.NewReader(payload) postRequest, _ := http.NewRequest("POST", base, reader) res, err := http.DefaultClient.Do(postRequest) if err != nil { @@ -240,6 +302,10 @@ func createDummyFeatureFlag() *http.Response { return res } +func createDummyFeatureFlag() *http.Response { + return createFeatureWithPayload(getDummyFeaturePayload()) +} + func assert422Response(t *testing.T, res *http.Response) { assertResponseWithStatusAndMessage(t, res, 422, "invalid_json", "Cannot decode the given JSON payload") } diff --git a/http/routes.go b/http/routes.go index 10accc4..cd17b3d 100644 --- a/http/routes.go +++ b/http/routes.go @@ -43,7 +43,14 @@ func getRoutes(api APIHandler) Routes { "/features/{featureKey}", api.FeatureShow, }, - // curl -H "Content-Type: application/json" -X POST -d '{"groups":"foo"}' -X GET http://localhost:8080/features/feature_test/access + // curl -H "Content-Type: application/json" -X POST -d '{"groups":["foo"]}' http://localhost:8080/features/access + Route{ + "FeaturesAccess", + "POST", + "/features/access", + api.FeaturesAccess, + }, + // curl -H "Content-Type: application/json" -X POST -d '{"groups":["foo"]}' http://localhost:8080/features/feature_test/access Route{ "FeatureAccess", "POST", From 397deeeff665f6884a3c8a15713e178544a18b7a Mon Sep 17 00:00:00 2001 From: Antoine Augusti Date: Mon, 14 Dec 2015 19:57:45 +0100 Subject: [PATCH 2/2] README formatting --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 17542dd..b061c0a 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,7 @@ Get a list of accessible features for a user or a list of groups. ``` - Responses: * 200 OK + Same as in [`POST` /features](#post-features). An empty array indicates that no known features are accessible for the given input. * 422 Unprocessable entity: ```json