diff --git a/client.go b/client.go index e0bce54..a6c4b04 100644 --- a/client.go +++ b/client.go @@ -233,6 +233,42 @@ func (c *Client) RemoveFeatureOwners(feature *Feature, ownerIDs []int64) error { return nil } +func (c *Client) manageFeatureGroupOwners(feature *Feature, groupIDs []int64, endpoint string) (*resty.Response, error) { + if feature.ProjectID == nil || feature.ID == nil { + return nil, fmt.Errorf("flagsmithapi: feature.ProjectID and feature.ID are required") + } + url := fmt.Sprintf("%s/projects/%d/features/%d/%s/", c.baseURL, *feature.ProjectID, *feature.ID, endpoint) + body := struct { + GroupIDs []int64 `json:"group_ids"` + }{ + GroupIDs: groupIDs, + } + resp, err := c.client.R().SetBody(body).Post(url) + return resp, err +} + +func (c *Client) AddFeatureGroupOwners(feature *Feature, groupIDs []int64) error { + resp, err := c.manageFeatureGroupOwners(feature, groupIDs, "add-group-owners") + if err != nil { + return err + } + if !resp.IsSuccess() { + return fmt.Errorf("flagsmithapi: Error adding feature group owners: %s", resp) + } + return nil +} + +func (c *Client) RemoveFeatureGroupOwners(feature *Feature, groupIDs []int64) error { + resp, err := c.manageFeatureGroupOwners(feature, groupIDs, "remove-group-owners") + if err != nil { + return err + } + if !resp.IsSuccess() { + return fmt.Errorf("flagsmithapi: Error removing feature group owners: %s", resp) + } + return nil +} + func (c *Client) GetFeatureMVOption(featureUUID, mvOptionUUID string) (*FeatureMultivariateOption, error) { url := fmt.Sprintf("%s/multivariate/options/get-by-uuid/%s/", c.baseURL, mvOptionUUID) featureMVOption := FeatureMultivariateOption{} diff --git a/client_test.go b/client_test.go index 406d4ba..511babf 100644 --- a/client_test.go +++ b/client_test.go @@ -234,7 +234,8 @@ const GetProjectResponseJson = ` "hide_disabled_flags": false, "enable_dynamo_db": true, "migration_status": "NOT_APPLICABLE", - "use_edge_identities": false + "use_edge_identities": false, + "enforce_feature_owners": true } ` const ProjectID int64 = 10 @@ -264,6 +265,16 @@ const CreateFeatureResponseJson = ` "id": 2, "email": "some_other_user@email.com" } + ], + "group_owners": [ + { + "id": 3, + "name": "Test Group" + }, + { + "id": 4, + "name": "Another Group" + } ] } @@ -483,6 +494,9 @@ func TestGetFeature(t *testing.T) { expectedOwners := []int64{1, 2} assert.Equal(t, &expectedOwners, feature.Owners) + expectedGroupOwners := []int64{3, 4} + assert.Equal(t, &expectedGroupOwners, feature.GroupOwners) + expectedTags := []int64{1} assert.Equal(t, expectedTags, feature.Tags) @@ -637,6 +651,121 @@ func TestRemoveFeatureOwners(t *testing.T) { } +func TestAddFeatureGroupOwners(t *testing.T) { + // Given + projectID := ProjectID + featureID := FeatureID + + description := "feature description" + + feature := flagsmithapi.Feature{ + Name: FeatureName, + ID: &featureID, + ProjectUUID: ProjectUUID, + ProjectID: &projectID, + Description: &description, + } + groupIDs := []int64{3, 4} + expectedRequestBody := `{"group_ids":[3,4]}` + + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + assert.Equal(t, fmt.Sprintf("/api/v1/projects/%d/features/%d/add-group-owners/", ProjectID, FeatureID), req.URL.Path) + assert.Equal(t, "POST", req.Method) + assert.Equal(t, "Api-Key "+MasterAPIKey, req.Header.Get("Authorization")) + + // Test that we sent the correct body + rawBody, err := io.ReadAll(req.Body) + assert.NoError(t, err) + assert.Equal(t, expectedRequestBody, string(rawBody)) + + rw.Header().Set("Content-Type", "application/json") + _, err = io.WriteString(rw, CreateFeatureResponseJson) + assert.NoError(t, err) + + })) + defer server.Close() + + client := flagsmithapi.NewClient(MasterAPIKey, server.URL+"/api/v1") + + // When + err := client.AddFeatureGroupOwners(&feature, groupIDs) + + // Then + assert.NoError(t, err) + +} + +func TestRemoveFeatureGroupOwners(t *testing.T) { + // Given + projectID := ProjectID + featureID := FeatureID + + description := "feature description" + + feature := flagsmithapi.Feature{ + Name: FeatureName, + ID: &featureID, + ProjectUUID: ProjectUUID, + ProjectID: &projectID, + Description: &description, + } + groupIDs := []int64{3, 4} + + expectedRequestBody := `{"group_ids":[3,4]}` + + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + assert.Equal(t, fmt.Sprintf("/api/v1/projects/%d/features/%d/remove-group-owners/", ProjectID, FeatureID), req.URL.Path) + assert.Equal(t, "POST", req.Method) + assert.Equal(t, "Api-Key "+MasterAPIKey, req.Header.Get("Authorization")) + + // Test that we sent the correct body + rawBody, err := io.ReadAll(req.Body) + assert.NoError(t, err) + assert.Equal(t, expectedRequestBody, string(rawBody)) + + rw.Header().Set("Content-Type", "application/json") + _, err = io.WriteString(rw, CreateFeatureResponseJson) + assert.NoError(t, err) + + })) + defer server.Close() + + client := flagsmithapi.NewClient(MasterAPIKey, server.URL+"/api/v1") + + // When + err := client.RemoveFeatureGroupOwners(&feature, groupIDs) + + // Then + assert.NoError(t, err) + +} + +func TestAddFeatureGroupOwnersMissingParams(t *testing.T) { + // Given + client := flagsmithapi.NewClient(MasterAPIKey, "http://localhost/api/v1") + fID := FeatureID + pID := ProjectID + + featureWithoutID := flagsmithapi.Feature{Name: "test", ProjectID: &pID} + featureWithoutProjectID := flagsmithapi.Feature{Name: "test", ID: &fID} + featureWithoutBoth := flagsmithapi.Feature{Name: "test"} + + // When / Then - missing ID + err := client.AddFeatureGroupOwners(&featureWithoutID, []int64{1}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "feature.ProjectID and feature.ID are required") + + // missing ProjectID + err = client.RemoveFeatureGroupOwners(&featureWithoutProjectID, []int64{1}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "feature.ProjectID and feature.ID are required") + + // missing both + err = client.AddFeatureGroupOwners(&featureWithoutBoth, []int64{1}) + assert.Error(t, err) + assert.Contains(t, err.Error(), "feature.ProjectID and feature.ID are required") +} + // 200 is arbitrarily chosen to avoid collision with other ids const MVFeatureOptionID int64 = 200 const MVFeatureOptionUUID = "8d3512d3-721a-4cae-9855-56c02cb0afe9" diff --git a/errors.go b/errors.go index 4c06849..c8348f9 100644 --- a/errors.go +++ b/errors.go @@ -16,6 +16,9 @@ type SegmentNotFoundError struct { type FeatureMVOptionNotFoundError struct { featureMVOptionUUID string } +type UserNotFoundError struct { + email string +} func (e FeatureNotFoundError) Error() string { return fmt.Sprintf("flagsmithapi: feature '%s' not found", e.featureUUID) @@ -32,3 +35,7 @@ func (e FeatureStateNotFoundError) Error() string { func (e FeatureMVOptionNotFoundError) Error() string { return fmt.Sprintf("flagsmithapi: feature mv option '%s' not found", e.featureMVOptionUUID) } + +func (e UserNotFoundError) Error() string { + return fmt.Sprintf("flagsmithapi: user with email '%s' not found", e.email) +} diff --git a/models.go b/models.go index b5a3ad4..f7c0392 100644 --- a/models.go +++ b/models.go @@ -17,6 +17,7 @@ type Project struct { FeatureNameRegex string `json:"feature_name_regex,omitempty"` StaleFlagsLimitDays int64 `json:"stale_flags_limit_days,omitempty"` EnableRealtimeUpdates bool `json:"enable_realtime_updates,omitempty"` + EnforceFeatureOwners bool `json:"enforce_feature_owners,omitempty"` } type FeatureMultivariateOption struct { @@ -43,6 +44,7 @@ type Feature struct { DefaultEnabled bool `json:"default_enabled,omitempty"` IsArchived bool `json:"is_archived,omitempty"` Owners *[]int64 `json:"owners,omitempty"` + GroupOwners *[]int64 `json:"group_owners,omitempty"` Tags []int64 `json:"tags"` ProjectUUID string `json:"-"` @@ -53,18 +55,22 @@ func (f *Feature) UnmarshalJSON(data []byte) error { type owner struct { ID int64 `json:"id"` } + type groupOwner struct { + ID int64 `json:"id"` + } var obj struct { - Name string `json:"name"` - UUID string `json:"uuid,omitempty"` - ID *int64 `json:"id,omitempty"` - Type *string `json:"type,omitempty"` - Description *string `json:"description,omitempty"` - InitialValue string `json:"initial_value,omitempty"` - DefaultEnabled bool `json:"default_enabled,omitempty"` - IsArchived bool `json:"is_archived,omitempty"` - Owners []owner `json:"owners,omitempty"` - ProjectID *int64 `json:"project,omitempty"` - Tags []int64 `json:"tags"` + Name string `json:"name"` + UUID string `json:"uuid,omitempty"` + ID *int64 `json:"id,omitempty"` + Type *string `json:"type,omitempty"` + Description *string `json:"description,omitempty"` + InitialValue string `json:"initial_value,omitempty"` + DefaultEnabled bool `json:"default_enabled,omitempty"` + IsArchived bool `json:"is_archived,omitempty"` + Owners []owner `json:"owners,omitempty"` + GroupOwners []groupOwner `json:"group_owners,omitempty"` + ProjectID *int64 `json:"project,omitempty"` + Tags []int64 `json:"tags"` } err := json.Unmarshal(data, &obj) @@ -89,6 +95,13 @@ func (f *Feature) UnmarshalJSON(data []byte) error { *f.Owners = append(*f.Owners, o.ID) } } + if obj.GroupOwners != nil { + groups := make([]int64, 0, len(obj.GroupOwners)) + for _, g := range obj.GroupOwners { + groups = append(groups, g.ID) + } + f.GroupOwners = &groups + } return nil } @@ -270,3 +283,14 @@ type Organisation struct { RestrictProjectCreateToAdmin bool `json:"restrict_project_create_to_admin"` PersistTraitData bool `json:"persist_trait_data"` } + +type User struct { + ID int64 `json:"id"` + Email string `json:"email"` + FirstName string `json:"first_name"` + LastName string `json:"last_name"` + LastLogin string `json:"last_login"` + DateJoined string `json:"date_joined"` + UUID string `json:"uuid"` + Role string `json:"role"` +} diff --git a/organisation.go b/organisation.go index 0ad3756..800ca53 100644 --- a/organisation.go +++ b/organisation.go @@ -20,3 +20,33 @@ func (c *Client) GetOrganisationByUUID(orgUUID string) (*Organisation, error) { return &organisation, nil } + +func (c *Client) GetOrganisationUsers(orgID int64) ([]User, error) { + url := fmt.Sprintf("%s/organisations/%d/users/", c.baseURL, orgID) + users := []User{} + resp, err := c.client.R(). + SetResult(&users). + Get(url) + + if err != nil { + return nil, err + } + if !resp.IsSuccess() { + return nil, fmt.Errorf("flagsmithapi: Error getting organisation users: %s", resp) + } + return users, nil +} + +func (c *Client) GetOrganisationUserByEmail(orgID int64, email string) (*User, error) { + users, err := c.GetOrganisationUsers(orgID) + if err != nil { + return nil, err + } + for i := range users { + if users[i].Email == email { + u := users[i] + return &u, nil + } + } + return nil, UserNotFoundError{email: email} +} diff --git a/organisation_test.go b/organisation_test.go new file mode 100644 index 0000000..ad624cb --- /dev/null +++ b/organisation_test.go @@ -0,0 +1,116 @@ +package flagsmithapi_test + +import ( + "fmt" + "io" + "net/http" + "net/http/httptest" + + "testing" + + "github.com/stretchr/testify/assert" + + flagsmithapi "github.com/Flagsmith/flagsmith-go-api-client" +) + +const GetOrganisationUsersResponseJson = ` +[ + { + "id": 100, + "email": "john@example.com", + "first_name": "John", + "last_name": "Doe", + "last_login": "2026-03-01T10:00:00Z", + "uuid": "abc-123", + "role": "ADMIN", + "date_joined": "2025-01-01T00:00:00Z" + }, + { + "id": 200, + "email": "jane@example.com", + "first_name": "Jane", + "last_name": "Smith", + "last_login": "2026-03-15T10:00:00Z", + "uuid": "def-456", + "role": "USER", + "date_joined": "2025-06-01T00:00:00Z" + } +] +` + +func TestGetOrganisationUsers(t *testing.T) { + // Given + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + assert.Equal(t, fmt.Sprintf("/api/v1/organisations/%d/users/", OrganisationID), req.URL.Path) + assert.Equal(t, "GET", req.Method) + assert.Equal(t, "Api-Key "+MasterAPIKey, req.Header.Get("Authorization")) + + rw.Header().Set("Content-Type", "application/json") + _, err := io.WriteString(rw, GetOrganisationUsersResponseJson) + assert.NoError(t, err) + })) + defer server.Close() + + client := flagsmithapi.NewClient(MasterAPIKey, server.URL+"/api/v1") + + // When + users, err := client.GetOrganisationUsers(OrganisationID) + + // Then + assert.NoError(t, err) + assert.Len(t, users, 2) + + assert.Equal(t, int64(100), users[0].ID) + assert.Equal(t, "john@example.com", users[0].Email) + assert.Equal(t, "John", users[0].FirstName) + assert.Equal(t, "Doe", users[0].LastName) + assert.Equal(t, "ADMIN", users[0].Role) + assert.Equal(t, "2025-01-01T00:00:00Z", users[0].DateJoined) + + assert.Equal(t, int64(200), users[1].ID) + assert.Equal(t, "jane@example.com", users[1].Email) + assert.Equal(t, "2025-06-01T00:00:00Z", users[1].DateJoined) +} + +func TestGetOrganisationUserByEmail(t *testing.T) { + // Given + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Header().Set("Content-Type", "application/json") + _, err := io.WriteString(rw, GetOrganisationUsersResponseJson) + assert.NoError(t, err) + })) + defer server.Close() + + client := flagsmithapi.NewClient(MasterAPIKey, server.URL+"/api/v1") + + // When + user, err := client.GetOrganisationUserByEmail(OrganisationID, "jane@example.com") + + // Then + assert.NoError(t, err) + assert.Equal(t, int64(200), user.ID) + assert.Equal(t, "jane@example.com", user.Email) + assert.Equal(t, "Jane", user.FirstName) + assert.Equal(t, "Smith", user.LastName) + assert.Equal(t, "USER", user.Role) +} + +func TestGetOrganisationUserByEmailNotFound(t *testing.T) { + // Given + server := httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + rw.Header().Set("Content-Type", "application/json") + _, err := io.WriteString(rw, GetOrganisationUsersResponseJson) + assert.NoError(t, err) + })) + defer server.Close() + + client := flagsmithapi.NewClient(MasterAPIKey, server.URL+"/api/v1") + + // When + user, err := client.GetOrganisationUserByEmail(OrganisationID, "notfound@example.com") + + // Then + assert.Nil(t, user) + assert.Error(t, err) + assert.IsType(t, flagsmithapi.UserNotFoundError{}, err) +} diff --git a/project_test.go b/project_test.go index 29fa093..642c8e9 100644 --- a/project_test.go +++ b/project_test.go @@ -38,6 +38,7 @@ func TestGetProject(t *testing.T) { assert.Equal(t, ProjectID, project.ID) assert.Equal(t, ProjectUUID, project.UUID) assert.Equal(t, "project-1", project.Name) + assert.Equal(t, true, project.EnforceFeatureOwners) }