Skip to content

Commit 67ec00b

Browse files
committed
feat: flag segment overrides — get, update, and delete
Slice 3 of 06-flags: segment overrides. - `flag get <feature> --segment <id>` reads segment_feature_state from the features endpoint (environment + segment params) and shows the override, erroring when the feature has no override for that segment. - `flag update <feature> --segment <id>` sets the override via update-flag-v2: the environment default is always carried forward unchanged, and a fresh override with no --value inherits the environment default value. - `flag delete <feature> --segment <id>` removes an override via delete-segment-override; a bare `flag delete` exits 2 asking for --segment, and a missing override surfaces the endpoint's 404 as a clear message. Identity overrides (--identifier) are deferred. beep boop
1 parent aa01e4c commit 67ec00b

6 files changed

Lines changed: 428 additions & 49 deletions

File tree

README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,9 @@ flagsmith flag list # list the flags in the current environment
1919

2020
- `flagsmith init` — bind the current directory to a project (writes `flagsmith.json`).
2121
- `flagsmith flag list` — list feature flags in the current environment.
22-
- `flagsmith flag get <feature>` — show a single flag's state in the current environment.
23-
- `flagsmith flag update <feature>` — toggle (`--enable`/`--disable`) or set the value (`--value`, `--type`).
22+
- `flagsmith flag get <feature>` — show a single flag's state (`--segment <id>` for a segment override).
23+
- `flagsmith flag update <feature>` — toggle (`--enable`/`--disable`) or set the value (`--value`, `--type`); `--segment <id>` targets a segment override.
24+
- `flagsmith flag delete <feature> --segment <id>` — delete a segment override.
2425
- `flagsmith config` — show the resolved context and where each value comes from.
2526
- `flagsmith login` / `logout` — browser OAuth (PKCE, loopback); also `auth login`/`auth logout`.
2627
- `flagsmith auth status` — identity, organisations, credential source, token expiry.

internal/api/client.go

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,7 @@ type Feature struct {
163163
LifecycleStage string `json:"lifecycle_stage"`
164164
CodeReferencesCounts []CodeReferenceCount `json:"code_references_counts"`
165165
EnvironmentState *FeatureState `json:"environment_feature_state"`
166+
SegmentState *FeatureState `json:"segment_feature_state"`
166167

167168
raw json.RawMessage
168169
}
@@ -196,10 +197,15 @@ func (f Feature) CodeReferences() int {
196197
}
197198

198199
// Features lists a project's features with their state in one environment, via
199-
// the Admin API. The environment is identified by its numeric ID.
200-
func Features(ctx context.Context, apiURL string, auth Auth, projectID, environmentID int) ([]Feature, error) {
200+
// the Admin API. The environment is identified by its numeric ID. When
201+
// segmentID is non-zero, each feature also carries its segment_feature_state
202+
// for that segment.
203+
func Features(ctx context.Context, apiURL string, auth Auth, projectID, environmentID, segmentID int) ([]Feature, error) {
201204
var features []Feature
202205
path := fmt.Sprintf("/api/v1/projects/%d/features/?environment=%d", projectID, environmentID)
206+
if segmentID != 0 {
207+
path += fmt.Sprintf("&segment=%d", segmentID)
208+
}
203209
if err := getList(ctx, apiURL, path, auth, &features); err != nil {
204210
return nil, err
205211
}
@@ -226,11 +232,54 @@ type EnvironmentDefault struct {
226232
Value FeatureValue `json:"value"`
227233
}
228234

229-
// UpdateFlagRequest is the update-flag-v2 body (environment default only; this
230-
// endpoint does not manage identity overrides).
235+
// SegmentOverride is one segment's state in the update-flag-v2 body.
236+
type SegmentOverride struct {
237+
SegmentID int `json:"segment_id"`
238+
Enabled bool `json:"enabled"`
239+
Value FeatureValue `json:"value"`
240+
}
241+
242+
// UpdateFlagRequest is the update-flag-v2 body. environment_default is always
243+
// required; segment_overrides only creates/updates the segments listed and
244+
// never removes others. This endpoint does not manage identity overrides.
231245
type UpdateFlagRequest struct {
232246
Feature FeatureRef `json:"feature"`
233247
EnvironmentDefault EnvironmentDefault `json:"environment_default"`
248+
SegmentOverrides []SegmentOverride `json:"segment_overrides,omitempty"`
249+
}
250+
251+
// DeleteSegmentOverride removes a feature's override for one segment, via the
252+
// experimental delete-segment-override endpoint keyed by the environment key.
253+
func DeleteSegmentOverride(ctx context.Context, apiURL string, auth Auth, environmentKey, featureName string, segmentID int) error {
254+
body, err := json.Marshal(map[string]any{
255+
"feature": FeatureRef{Name: featureName},
256+
"segment": map[string]int{"id": segmentID},
257+
})
258+
if err != nil {
259+
return err
260+
}
261+
u := strings.TrimRight(apiURL, "/") + "/api/experiments/environments/" + environmentKey + "/delete-segment-override/"
262+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(body))
263+
if err != nil {
264+
return err
265+
}
266+
req.Header.Set("Content-Type", "application/json")
267+
auth.Apply(req)
268+
resp, err := http.DefaultClient.Do(req)
269+
if err != nil {
270+
return err
271+
}
272+
defer resp.Body.Close()
273+
if resp.StatusCode == http.StatusForbidden {
274+
return ErrWorkflowGated
275+
}
276+
if resp.StatusCode == http.StatusNotFound {
277+
return fmt.Errorf("no override exists for segment %d", segmentID)
278+
}
279+
if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
280+
return fmt.Errorf("POST %s returned %s", u, resp.Status)
281+
}
282+
return nil
234283
}
235284

236285
// ErrWorkflowGated is returned when update-flag-v2 refuses because the

internal/api/client_test.go

Lines changed: 67 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ func TestFeatures(t *testing.T) {
176176
defer srv.Close()
177177

178178
// When
179-
features, err := Features(context.Background(), srv.URL, APIKey("k.s"), 101, 1)
179+
features, err := Features(context.Background(), srv.URL, APIKey("k.s"), 101, 1, 0)
180180

181181
// Then
182182
if err != nil {
@@ -206,7 +206,7 @@ func TestFeatures(t *testing.T) {
206206
defer srv.Close()
207207

208208
// When
209-
features, err := Features(context.Background(), srv.URL, APIKey("k.s"), 101, 1)
209+
features, err := Features(context.Background(), srv.URL, APIKey("k.s"), 101, 1, 0)
210210
if err != nil {
211211
t.Fatal(err)
212212
}
@@ -229,13 +229,77 @@ func TestFeatures(t *testing.T) {
229229
defer srv.Close()
230230

231231
// When
232-
_, err := Features(context.Background(), srv.URL, APIKey("k.s"), 101, 1)
232+
_, err := Features(context.Background(), srv.URL, APIKey("k.s"), 101, 1, 0)
233233

234234
// Then
235235
if err == nil || !strings.Contains(err.Error(), "403") {
236236
t.Errorf("err = %v, want 403", err)
237237
}
238238
})
239+
240+
t.Run("segment param is sent when non-zero", func(t *testing.T) {
241+
// Given
242+
var gotSegment string
243+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
244+
gotSegment = r.URL.Query().Get("segment")
245+
fmt.Fprint(w, `{"count":0,"results":[]}`)
246+
}))
247+
defer srv.Close()
248+
249+
// When
250+
if _, err := Features(context.Background(), srv.URL, APIKey("k.s"), 101, 1, 12); err != nil {
251+
t.Fatal(err)
252+
}
253+
254+
// Then
255+
if gotSegment != "12" {
256+
t.Errorf("segment = %q, want 12", gotSegment)
257+
}
258+
})
259+
}
260+
261+
func TestDeleteSegmentOverride(t *testing.T) {
262+
t.Run("posts the feature and segment, accepts 204", func(t *testing.T) {
263+
// Given
264+
var body map[string]any
265+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
266+
if r.Method != http.MethodPost ||
267+
r.URL.Path != "/api/experiments/environments/envkey/delete-segment-override/" {
268+
t.Errorf("request = %s %s", r.Method, r.URL.Path)
269+
}
270+
json.NewDecoder(r.Body).Decode(&body)
271+
w.WriteHeader(http.StatusNoContent)
272+
}))
273+
defer srv.Close()
274+
275+
// When
276+
err := DeleteSegmentOverride(context.Background(), srv.URL, APIKey("k.s"), "envkey", "max_items", 12)
277+
278+
// Then
279+
if err != nil {
280+
t.Fatal(err)
281+
}
282+
if body["feature"].(map[string]any)["name"] != "max_items" ||
283+
body["segment"].(map[string]any)["id"] != float64(12) {
284+
t.Errorf("body = %+v", body)
285+
}
286+
})
287+
288+
t.Run("404 becomes a no-override error", func(t *testing.T) {
289+
// Given
290+
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
291+
w.WriteHeader(http.StatusNotFound)
292+
}))
293+
defer srv.Close()
294+
295+
// When
296+
err := DeleteSegmentOverride(context.Background(), srv.URL, APIKey("k.s"), "envkey", "max_items", 12)
297+
298+
// Then
299+
if err == nil || !strings.Contains(err.Error(), "segment 12") {
300+
t.Errorf("err = %v, want a no-override error", err)
301+
}
302+
})
239303
}
240304

241305
func TestCreateEnvironment(t *testing.T) {

0 commit comments

Comments
 (0)