Skip to content

Commit b7b3f09

Browse files
gagantrivediclaude
andauthored
feat: Add group owners, user lookup, and enforce_feature_owners support (#19)
* feat: Add group owners, user lookup, and enforce_feature_owners support - Add GroupOwners field to Feature model with custom UnmarshalJSON - Add AddFeatureGroupOwners/RemoveFeatureGroupOwners client methods - Add User model and GetOrganisationUsers/GetOrganisationUserByEmail methods - Add EnforceFeatureOwners field to Project model - Add UserNotFoundError type * fix: Address PR review comments - Add parameter validation to manageFeatureGroupOwners (return error if feature.ProjectID or feature.ID is nil) - Add error path test for AddFeatureGroupOwners/RemoveFeatureGroupOwners with missing params - Pre-allocate GroupOwners slice in UnmarshalJSON for efficiency - Return copy of user in GetOrganisationUserByEmail to avoid pinning the slice in memory - Add missing date_joined field to User model and assert in tests https://claude.ai/code/session_01V7MNgv5pxkpz4chuJBzLcZ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 92d414d commit b7b3f09

File tree

7 files changed

+355
-12
lines changed

7 files changed

+355
-12
lines changed

client.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,42 @@ func (c *Client) RemoveFeatureOwners(feature *Feature, ownerIDs []int64) error {
233233
return nil
234234
}
235235

236+
func (c *Client) manageFeatureGroupOwners(feature *Feature, groupIDs []int64, endpoint string) (*resty.Response, error) {
237+
if feature.ProjectID == nil || feature.ID == nil {
238+
return nil, fmt.Errorf("flagsmithapi: feature.ProjectID and feature.ID are required")
239+
}
240+
url := fmt.Sprintf("%s/projects/%d/features/%d/%s/", c.baseURL, *feature.ProjectID, *feature.ID, endpoint)
241+
body := struct {
242+
GroupIDs []int64 `json:"group_ids"`
243+
}{
244+
GroupIDs: groupIDs,
245+
}
246+
resp, err := c.client.R().SetBody(body).Post(url)
247+
return resp, err
248+
}
249+
250+
func (c *Client) AddFeatureGroupOwners(feature *Feature, groupIDs []int64) error {
251+
resp, err := c.manageFeatureGroupOwners(feature, groupIDs, "add-group-owners")
252+
if err != nil {
253+
return err
254+
}
255+
if !resp.IsSuccess() {
256+
return fmt.Errorf("flagsmithapi: Error adding feature group owners: %s", resp)
257+
}
258+
return nil
259+
}
260+
261+
func (c *Client) RemoveFeatureGroupOwners(feature *Feature, groupIDs []int64) error {
262+
resp, err := c.manageFeatureGroupOwners(feature, groupIDs, "remove-group-owners")
263+
if err != nil {
264+
return err
265+
}
266+
if !resp.IsSuccess() {
267+
return fmt.Errorf("flagsmithapi: Error removing feature group owners: %s", resp)
268+
}
269+
return nil
270+
}
271+
236272
func (c *Client) GetFeatureMVOption(featureUUID, mvOptionUUID string) (*FeatureMultivariateOption, error) {
237273
url := fmt.Sprintf("%s/multivariate/options/get-by-uuid/%s/", c.baseURL, mvOptionUUID)
238274
featureMVOption := FeatureMultivariateOption{}

client_test.go

Lines changed: 130 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,8 @@ const GetProjectResponseJson = `
234234
"hide_disabled_flags": false,
235235
"enable_dynamo_db": true,
236236
"migration_status": "NOT_APPLICABLE",
237-
"use_edge_identities": false
237+
"use_edge_identities": false,
238+
"enforce_feature_owners": true
238239
}
239240
`
240241
const ProjectID int64 = 10
@@ -264,6 +265,16 @@ const CreateFeatureResponseJson = `
264265
"id": 2,
265266
"email": "some_other_user@email.com"
266267
}
268+
],
269+
"group_owners": [
270+
{
271+
"id": 3,
272+
"name": "Test Group"
273+
},
274+
{
275+
"id": 4,
276+
"name": "Another Group"
277+
}
267278
]
268279
}
269280
@@ -483,6 +494,9 @@ func TestGetFeature(t *testing.T) {
483494
expectedOwners := []int64{1, 2}
484495
assert.Equal(t, &expectedOwners, feature.Owners)
485496

497+
expectedGroupOwners := []int64{3, 4}
498+
assert.Equal(t, &expectedGroupOwners, feature.GroupOwners)
499+
486500
expectedTags := []int64{1}
487501
assert.Equal(t, expectedTags, feature.Tags)
488502

@@ -637,6 +651,121 @@ func TestRemoveFeatureOwners(t *testing.T) {
637651

638652
}
639653

654+
func TestAddFeatureGroupOwners(t *testing.T) {
655+
// Given
656+
projectID := ProjectID
657+
featureID := FeatureID
658+
659+
description := "feature description"
660+
661+
feature := flagsmithapi.Feature{
662+
Name: FeatureName,
663+
ID: &featureID,
664+
ProjectUUID: ProjectUUID,
665+
ProjectID: &projectID,
666+
Description: &description,
667+
}
668+
groupIDs := []int64{3, 4}
669+
expectedRequestBody := `{"group_ids":[3,4]}`
670+
671+
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
672+
assert.Equal(t, fmt.Sprintf("/api/v1/projects/%d/features/%d/add-group-owners/", ProjectID, FeatureID), req.URL.Path)
673+
assert.Equal(t, "POST", req.Method)
674+
assert.Equal(t, "Api-Key "+MasterAPIKey, req.Header.Get("Authorization"))
675+
676+
// Test that we sent the correct body
677+
rawBody, err := io.ReadAll(req.Body)
678+
assert.NoError(t, err)
679+
assert.Equal(t, expectedRequestBody, string(rawBody))
680+
681+
rw.Header().Set("Content-Type", "application/json")
682+
_, err = io.WriteString(rw, CreateFeatureResponseJson)
683+
assert.NoError(t, err)
684+
685+
}))
686+
defer server.Close()
687+
688+
client := flagsmithapi.NewClient(MasterAPIKey, server.URL+"/api/v1")
689+
690+
// When
691+
err := client.AddFeatureGroupOwners(&feature, groupIDs)
692+
693+
// Then
694+
assert.NoError(t, err)
695+
696+
}
697+
698+
func TestRemoveFeatureGroupOwners(t *testing.T) {
699+
// Given
700+
projectID := ProjectID
701+
featureID := FeatureID
702+
703+
description := "feature description"
704+
705+
feature := flagsmithapi.Feature{
706+
Name: FeatureName,
707+
ID: &featureID,
708+
ProjectUUID: ProjectUUID,
709+
ProjectID: &projectID,
710+
Description: &description,
711+
}
712+
groupIDs := []int64{3, 4}
713+
714+
expectedRequestBody := `{"group_ids":[3,4]}`
715+
716+
server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
717+
assert.Equal(t, fmt.Sprintf("/api/v1/projects/%d/features/%d/remove-group-owners/", ProjectID, FeatureID), req.URL.Path)
718+
assert.Equal(t, "POST", req.Method)
719+
assert.Equal(t, "Api-Key "+MasterAPIKey, req.Header.Get("Authorization"))
720+
721+
// Test that we sent the correct body
722+
rawBody, err := io.ReadAll(req.Body)
723+
assert.NoError(t, err)
724+
assert.Equal(t, expectedRequestBody, string(rawBody))
725+
726+
rw.Header().Set("Content-Type", "application/json")
727+
_, err = io.WriteString(rw, CreateFeatureResponseJson)
728+
assert.NoError(t, err)
729+
730+
}))
731+
defer server.Close()
732+
733+
client := flagsmithapi.NewClient(MasterAPIKey, server.URL+"/api/v1")
734+
735+
// When
736+
err := client.RemoveFeatureGroupOwners(&feature, groupIDs)
737+
738+
// Then
739+
assert.NoError(t, err)
740+
741+
}
742+
743+
func TestAddFeatureGroupOwnersMissingParams(t *testing.T) {
744+
// Given
745+
client := flagsmithapi.NewClient(MasterAPIKey, "http://localhost/api/v1")
746+
fID := FeatureID
747+
pID := ProjectID
748+
749+
featureWithoutID := flagsmithapi.Feature{Name: "test", ProjectID: &pID}
750+
featureWithoutProjectID := flagsmithapi.Feature{Name: "test", ID: &fID}
751+
featureWithoutBoth := flagsmithapi.Feature{Name: "test"}
752+
753+
// When / Then - missing ID
754+
err := client.AddFeatureGroupOwners(&featureWithoutID, []int64{1})
755+
assert.Error(t, err)
756+
assert.Contains(t, err.Error(), "feature.ProjectID and feature.ID are required")
757+
758+
// missing ProjectID
759+
err = client.RemoveFeatureGroupOwners(&featureWithoutProjectID, []int64{1})
760+
assert.Error(t, err)
761+
assert.Contains(t, err.Error(), "feature.ProjectID and feature.ID are required")
762+
763+
// missing both
764+
err = client.AddFeatureGroupOwners(&featureWithoutBoth, []int64{1})
765+
assert.Error(t, err)
766+
assert.Contains(t, err.Error(), "feature.ProjectID and feature.ID are required")
767+
}
768+
640769
// 200 is arbitrarily chosen to avoid collision with other ids
641770
const MVFeatureOptionID int64 = 200
642771
const MVFeatureOptionUUID = "8d3512d3-721a-4cae-9855-56c02cb0afe9"

errors.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,9 @@ type SegmentNotFoundError struct {
1616
type FeatureMVOptionNotFoundError struct {
1717
featureMVOptionUUID string
1818
}
19+
type UserNotFoundError struct {
20+
email string
21+
}
1922

2023
func (e FeatureNotFoundError) Error() string {
2124
return fmt.Sprintf("flagsmithapi: feature '%s' not found", e.featureUUID)
@@ -32,3 +35,7 @@ func (e FeatureStateNotFoundError) Error() string {
3235
func (e FeatureMVOptionNotFoundError) Error() string {
3336
return fmt.Sprintf("flagsmithapi: feature mv option '%s' not found", e.featureMVOptionUUID)
3437
}
38+
39+
func (e UserNotFoundError) Error() string {
40+
return fmt.Sprintf("flagsmithapi: user with email '%s' not found", e.email)
41+
}

models.go

Lines changed: 35 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ type Project struct {
1717
FeatureNameRegex string `json:"feature_name_regex,omitempty"`
1818
StaleFlagsLimitDays int64 `json:"stale_flags_limit_days,omitempty"`
1919
EnableRealtimeUpdates bool `json:"enable_realtime_updates,omitempty"`
20+
EnforceFeatureOwners bool `json:"enforce_feature_owners,omitempty"`
2021
}
2122

2223
type FeatureMultivariateOption struct {
@@ -43,6 +44,7 @@ type Feature struct {
4344
DefaultEnabled bool `json:"default_enabled,omitempty"`
4445
IsArchived bool `json:"is_archived,omitempty"`
4546
Owners *[]int64 `json:"owners,omitempty"`
47+
GroupOwners *[]int64 `json:"group_owners,omitempty"`
4648
Tags []int64 `json:"tags"`
4749

4850
ProjectUUID string `json:"-"`
@@ -53,18 +55,22 @@ func (f *Feature) UnmarshalJSON(data []byte) error {
5355
type owner struct {
5456
ID int64 `json:"id"`
5557
}
58+
type groupOwner struct {
59+
ID int64 `json:"id"`
60+
}
5661
var obj struct {
57-
Name string `json:"name"`
58-
UUID string `json:"uuid,omitempty"`
59-
ID *int64 `json:"id,omitempty"`
60-
Type *string `json:"type,omitempty"`
61-
Description *string `json:"description,omitempty"`
62-
InitialValue string `json:"initial_value,omitempty"`
63-
DefaultEnabled bool `json:"default_enabled,omitempty"`
64-
IsArchived bool `json:"is_archived,omitempty"`
65-
Owners []owner `json:"owners,omitempty"`
66-
ProjectID *int64 `json:"project,omitempty"`
67-
Tags []int64 `json:"tags"`
62+
Name string `json:"name"`
63+
UUID string `json:"uuid,omitempty"`
64+
ID *int64 `json:"id,omitempty"`
65+
Type *string `json:"type,omitempty"`
66+
Description *string `json:"description,omitempty"`
67+
InitialValue string `json:"initial_value,omitempty"`
68+
DefaultEnabled bool `json:"default_enabled,omitempty"`
69+
IsArchived bool `json:"is_archived,omitempty"`
70+
Owners []owner `json:"owners,omitempty"`
71+
GroupOwners []groupOwner `json:"group_owners,omitempty"`
72+
ProjectID *int64 `json:"project,omitempty"`
73+
Tags []int64 `json:"tags"`
6874
}
6975

7076
err := json.Unmarshal(data, &obj)
@@ -89,6 +95,13 @@ func (f *Feature) UnmarshalJSON(data []byte) error {
8995
*f.Owners = append(*f.Owners, o.ID)
9096
}
9197
}
98+
if obj.GroupOwners != nil {
99+
groups := make([]int64, 0, len(obj.GroupOwners))
100+
for _, g := range obj.GroupOwners {
101+
groups = append(groups, g.ID)
102+
}
103+
f.GroupOwners = &groups
104+
}
92105
return nil
93106
}
94107

@@ -270,3 +283,14 @@ type Organisation struct {
270283
RestrictProjectCreateToAdmin bool `json:"restrict_project_create_to_admin"`
271284
PersistTraitData bool `json:"persist_trait_data"`
272285
}
286+
287+
type User struct {
288+
ID int64 `json:"id"`
289+
Email string `json:"email"`
290+
FirstName string `json:"first_name"`
291+
LastName string `json:"last_name"`
292+
LastLogin string `json:"last_login"`
293+
DateJoined string `json:"date_joined"`
294+
UUID string `json:"uuid"`
295+
Role string `json:"role"`
296+
}

organisation.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,33 @@ func (c *Client) GetOrganisationByUUID(orgUUID string) (*Organisation, error) {
2020
return &organisation, nil
2121

2222
}
23+
24+
func (c *Client) GetOrganisationUsers(orgID int64) ([]User, error) {
25+
url := fmt.Sprintf("%s/organisations/%d/users/", c.baseURL, orgID)
26+
users := []User{}
27+
resp, err := c.client.R().
28+
SetResult(&users).
29+
Get(url)
30+
31+
if err != nil {
32+
return nil, err
33+
}
34+
if !resp.IsSuccess() {
35+
return nil, fmt.Errorf("flagsmithapi: Error getting organisation users: %s", resp)
36+
}
37+
return users, nil
38+
}
39+
40+
func (c *Client) GetOrganisationUserByEmail(orgID int64, email string) (*User, error) {
41+
users, err := c.GetOrganisationUsers(orgID)
42+
if err != nil {
43+
return nil, err
44+
}
45+
for i := range users {
46+
if users[i].Email == email {
47+
u := users[i]
48+
return &u, nil
49+
}
50+
}
51+
return nil, UserNotFoundError{email: email}
52+
}

0 commit comments

Comments
 (0)