Skip to content

Commit 7cca891

Browse files
matthewelwellclaude
andcommitted
feat: Add GetFeatureByName and GetSegmentByName
Adds lookups by name for features and segments, so that callers which know a project UUID and a name -- but not a UUID -- can resolve an entity. This is what the Terraform provider needs to offer `flagsmith_feature` and `flagsmith_segment` data sources. Both are built on a shared `searchProjectResources` helper. Two things about the API are worth knowing: - Features filter on `search`, segments filter on `q`. Passing the wrong one is silently ignored and returns every result in the project. - Both filters are case insensitive "contains" matches, so results have to be filtered client side for an exact match. Without that, asking for `flag` could return `flagship`. Pages are requested as `page=N` against the client's configured base URL rather than by following DRF's `next` link, which is built from the incoming request host and so can be unreachable behind a self hosted reverse proxy. Segment names are not unique within a project, so a name that matches more than one segment returns MultipleSegmentsFoundError rather than picking arbitrarily. Feature names are uniquely indexed, so the equivalent feature error is defensive only. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d4a039a commit 7cca891

3 files changed

Lines changed: 630 additions & 0 deletions

File tree

client.go

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,39 @@ func (c *Client) GetFeature(featureUUID string) (*Feature, error) {
135135
return &feature, nil
136136
}
137137

138+
// GetFeatureByName returns the feature in the given project whose name is an exact
139+
// match for featureName.
140+
//
141+
// The match is case sensitive. The API's `search` filter is a case insensitive
142+
// "contains" match, so results are filtered client side.
143+
func (c *Client) GetFeatureByName(projectUUID string, featureName string) (*Feature, error) {
144+
projectID, err := c.getProjectID(projectUUID)
145+
if err != nil {
146+
return nil, err
147+
}
148+
149+
url := fmt.Sprintf("%s/projects/%d/features/", c.baseURL, projectID)
150+
matches, err := searchProjectResources(c, url, "search", featureName, "features",
151+
func(feature *Feature) string { return feature.Name })
152+
153+
if err != nil {
154+
return nil, err
155+
}
156+
157+
switch len(matches) {
158+
case 0:
159+
return nil, FeatureNotFoundError{featureName: featureName, projectUUID: projectUUID}
160+
case 1:
161+
feature := matches[0]
162+
// The API never returns the project UUID, and unlike GetFeature we already
163+
// know it, so there is no need to look the project up again.
164+
feature.ProjectUUID = projectUUID
165+
return feature, nil
166+
default:
167+
return nil, MultipleFeaturesFoundError{featureName: featureName, projectUUID: projectUUID, count: len(matches)}
168+
}
169+
}
170+
138171
func (c *Client) CreateFeature(feature *Feature) error {
139172
if feature.ProjectID == nil {
140173
projectID, err := c.getProjectID(feature.ProjectUUID)
@@ -189,6 +222,61 @@ func (c *Client) UpdateFeature(feature *Feature) error {
189222
return nil
190223
}
191224

225+
// searchPageLimit caps the number of pages walked while searching, as a guard
226+
// against a server that always reports a further page.
227+
const searchPageLimit = 100
228+
229+
// searchProjectResources walks a paginated project list endpoint, passing searchTerm
230+
// as queryParam, and returns every result whose name is an exact match.
231+
//
232+
// The Flagsmith API's search filters are case insensitive "contains" matches, so
233+
// callers cannot rely on the server to return exactly one result.
234+
//
235+
// Pages are requested as `page=N` against the client's own base URL rather than by
236+
// following the `next` link, which DRF builds from the incoming request host and so
237+
// may be unreachable behind a self hosted reverse proxy. `next` is used only to
238+
// decide whether a further page exists.
239+
func searchProjectResources[T any](c *Client, url, queryParam, searchTerm, resourceName string,
240+
nameOf func(*T) string) ([]*T, error) {
241+
matches := []*T{}
242+
243+
for page := 1; page <= searchPageLimit; page++ {
244+
result := struct {
245+
Next *string `json:"next"`
246+
Results []*T `json:"results"`
247+
}{}
248+
249+
resp, err := c.client.R().
250+
SetQueryParams(map[string]string{
251+
queryParam: searchTerm,
252+
"page": strconv.Itoa(page),
253+
}).
254+
SetResult(&result).
255+
Get(url)
256+
257+
if err != nil {
258+
return nil, err
259+
}
260+
261+
if !resp.IsSuccess() {
262+
return nil, fmt.Errorf("flagsmithapi: Error searching %s: %s", resourceName, resp)
263+
}
264+
265+
for _, item := range result.Results {
266+
if nameOf(item) == searchTerm {
267+
matches = append(matches, item)
268+
}
269+
}
270+
271+
if result.Next == nil || *result.Next == "" {
272+
return matches, nil
273+
}
274+
}
275+
276+
return nil, fmt.Errorf("flagsmithapi: Error searching %s: more than %d pages of results",
277+
resourceName, searchPageLimit)
278+
}
279+
192280
func (c *Client) getProjectID(projectUUID string) (int64, error) {
193281
project, err := c.GetProject(projectUUID)
194282

@@ -376,6 +464,46 @@ func (c *Client) GetSegment(segmentUUID string) (*Segment, error) {
376464
segment.ProjectUUID = project.UUID
377465
return &segment, nil
378466
}
467+
468+
// GetSegmentByName returns the segment in the given project whose name is an exact
469+
// match for segmentName.
470+
//
471+
// The match is case sensitive. The API's `q` filter is a case insensitive
472+
// "contains" match, so results are filtered client side.
473+
//
474+
// Note that segment names are not unique within a project: if more than one segment
475+
// matches, a MultipleSegmentsFoundError is returned. System segments are excluded by
476+
// the list endpoint, so they can only be looked up by UUID.
477+
func (c *Client) GetSegmentByName(projectUUID string, segmentName string) (*Segment, error) {
478+
projectID, err := c.getProjectID(projectUUID)
479+
if err != nil {
480+
return nil, err
481+
}
482+
483+
url := fmt.Sprintf("%s/projects/%d/segments/", c.baseURL, projectID)
484+
// NOTE: segments filter on `q`, features filter on `search`. Passing `search`
485+
// here is silently ignored and returns every segment in the project.
486+
matches, err := searchProjectResources(c, url, "q", segmentName, "segments",
487+
func(segment *Segment) string { return segment.Name })
488+
489+
if err != nil {
490+
return nil, err
491+
}
492+
493+
switch len(matches) {
494+
case 0:
495+
return nil, SegmentNotFoundError{segmentName: segmentName, projectUUID: projectUUID}
496+
case 1:
497+
segment := matches[0]
498+
// The API never returns the project UUID, and unlike GetSegment we already
499+
// know it, so there is no need to look the project up again.
500+
segment.ProjectUUID = projectUUID
501+
return segment, nil
502+
default:
503+
return nil, MultipleSegmentsFoundError{segmentName: segmentName, projectUUID: projectUUID, count: len(matches)}
504+
}
505+
}
506+
379507
func (c *Client) DeleteSegment(projectID, segmentID int64) error {
380508
url := fmt.Sprintf("%s/projects/%d/segments/%d/", c.baseURL, projectID, segmentID)
381509

errors.go

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,16 @@ import (
66

77
type FeatureNotFoundError struct {
88
featureUUID string
9+
featureName string
10+
projectUUID string
911
}
1012
type FeatureStateNotFoundError struct {
1113
featureStateUUID string
1214
}
1315
type SegmentNotFoundError struct {
1416
segmentUUID string
17+
segmentName string
18+
projectUUID string
1519
}
1620
type FeatureMVOptionNotFoundError struct {
1721
featureMVOptionUUID string
@@ -20,14 +24,46 @@ type UserNotFoundError struct {
2024
email string
2125
}
2226

27+
// MultipleFeaturesFoundError is returned when a lookup by name matches more than
28+
// one feature. Feature names are unique within a project, so this is defensive.
29+
type MultipleFeaturesFoundError struct {
30+
featureName string
31+
projectUUID string
32+
count int
33+
}
34+
35+
// MultipleSegmentsFoundError is returned when a lookup by name matches more than
36+
// one segment. Unlike features, segment names are not unique within a project.
37+
type MultipleSegmentsFoundError struct {
38+
segmentName string
39+
projectUUID string
40+
count int
41+
}
42+
2343
func (e FeatureNotFoundError) Error() string {
44+
if e.featureName != "" {
45+
return fmt.Sprintf("flagsmithapi: feature named '%s' not found in project '%s'", e.featureName, e.projectUUID)
46+
}
2447
return fmt.Sprintf("flagsmithapi: feature '%s' not found", e.featureUUID)
2548
}
2649

2750
func (e SegmentNotFoundError) Error() string {
51+
if e.segmentName != "" {
52+
return fmt.Sprintf("flagsmithapi: segment named '%s' not found in project '%s'", e.segmentName, e.projectUUID)
53+
}
2854
return fmt.Sprintf("flagsmithapi: segment '%s' not found", e.segmentUUID)
2955
}
3056

57+
func (e MultipleFeaturesFoundError) Error() string {
58+
return fmt.Sprintf("flagsmithapi: found %d features named '%s' in project '%s', expected exactly one",
59+
e.count, e.featureName, e.projectUUID)
60+
}
61+
62+
func (e MultipleSegmentsFoundError) Error() string {
63+
return fmt.Sprintf("flagsmithapi: found %d segments named '%s' in project '%s', expected exactly one",
64+
e.count, e.segmentName, e.projectUUID)
65+
}
66+
3167
func (e FeatureStateNotFoundError) Error() string {
3268
return fmt.Sprintf("flagsmithapi: feature state '%s' not found", e.featureStateUUID)
3369
}

0 commit comments

Comments
 (0)