Skip to content

Commit 394616d

Browse files
matthewelwellclaude
andcommitted
feat: Add custom field (metadata) support
Adds support for reading and writing Flagsmith custom fields on features, segments and environments, which terraform-provider-flagsmith#215 needs. Until now the provider could not set a custom field at all, so an organisation with a mandatory custom field could not create features through it. The API takes a MetadataModelField ID, which is not the same as the custom field's own ID: a single field bound to features, segments and environments has three of them. Resolving a name to the right one is fiddly enough that it does not belong in every caller, so this adds a MetadataFieldResolver that maps names to model field IDs for one (project, entity) pair and back again. Two API details drive the design: - `GET /projects/{id}/metadata/fields/?entity=feature` filters which *fields* are returned but does NOT filter each field's nested `model_fields`, so the caller has to match on content type regardless. The entity param is therefore not used, which also lets one response serve all three entity types. - Django content type IDs are specific to an installation, so they are resolved at runtime from the supported-content-types endpoint and never hardcoded. `GET /features/get-by-uuid/` is served by CreateFeatureSerializer, which omits metadata, so GetFeature hydrates it from the project scoped retrieve endpoint. That costs one extra request per feature read. Segments and environments need no workaround: their get-by-uuid responses already include metadata. See the TODO for the upstream fix that would remove this. Feature.UnmarshalJSON decodes into an explicit allowlist, so adding Metadata to the struct alone would have silently dropped it on every read. The allowlist now carries a warning, and a reflection based test fails if a future field is missed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent d4a039a commit 394616d

5 files changed

Lines changed: 1228 additions & 19 deletions

File tree

client.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,9 +132,46 @@ func (c *Client) GetFeature(featureUUID string) (*Feature, error) {
132132
return nil, err
133133
}
134134
feature.ProjectUUID = project.UUID
135+
136+
// `features/get-by-uuid/` is served by CreateFeatureSerializer, which omits
137+
// metadata (see api/features/views.py::get_feature_by_uuid), so it has to be read
138+
// from the project scoped retrieve endpoint instead.
139+
// TODO: drop this once get_feature_by_uuid returns metadata.
140+
if feature.ID != nil {
141+
metadata, err := c.getFeatureMetadata(*feature.ProjectID, *feature.ID)
142+
if err != nil {
143+
return nil, err
144+
}
145+
feature.Metadata = metadata
146+
}
147+
135148
return &feature, nil
136149
}
137150

151+
// getFeatureMetadata reads just the metadata of a feature from the project scoped
152+
// retrieve endpoint.
153+
//
154+
// It decodes into an anonymous struct rather than a Feature so that the response cannot
155+
// overwrite fields the caller has already populated.
156+
func (c *Client) getFeatureMetadata(projectID, featureID int64) (*[]Metadata, error) {
157+
url := fmt.Sprintf("%s/projects/%d/features/%d/", c.baseURL, projectID, featureID)
158+
result := struct {
159+
Metadata *[]Metadata `json:"metadata"`
160+
}{}
161+
162+
resp, err := c.client.R().SetResult(&result).Get(url)
163+
164+
if err != nil {
165+
return nil, err
166+
}
167+
168+
if !resp.IsSuccess() {
169+
return nil, fmt.Errorf("flagsmithapi: Error getting feature metadata: %s", resp)
170+
}
171+
172+
return result.Metadata, nil
173+
}
174+
138175
func (c *Client) CreateFeature(feature *Feature) error {
139176
if feature.ProjectID == nil {
140177
projectID, err := c.getProjectID(feature.ProjectUUID)

errors.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package flagsmithapi
22

33
import (
44
"fmt"
5+
"strings"
56
)
67

78
type FeatureNotFoundError struct {
@@ -20,6 +21,45 @@ type UserNotFoundError struct {
2021
email string
2122
}
2223

24+
// MetadataFieldNotFoundError is returned when no custom field with the given name is
25+
// visible to the project. Fields are exported so that callers can build their own
26+
// messages without parsing this one.
27+
type MetadataFieldNotFoundError struct {
28+
Name string
29+
Entity string
30+
KnownNames []string
31+
}
32+
33+
// MetadataFieldNotBoundError is returned when a custom field exists in the project but
34+
// is not enabled for the entity being written to.
35+
type MetadataFieldNotBoundError struct {
36+
Name string
37+
Entity string
38+
}
39+
40+
// MetadataFieldInvalidValueError is returned when a value does not match its custom
41+
// field's configured type.
42+
type MetadataFieldInvalidValueError struct {
43+
Name string
44+
FieldType string
45+
Value string
46+
Reason string
47+
}
48+
49+
func (e MetadataFieldNotFoundError) Error() string {
50+
return fmt.Sprintf("flagsmithapi: no custom field named '%s' is available for %ss in this project (available: %s)",
51+
e.Name, e.Entity, strings.Join(e.KnownNames, ", "))
52+
}
53+
54+
func (e MetadataFieldNotBoundError) Error() string {
55+
return fmt.Sprintf("flagsmithapi: the custom field '%s' exists but is not enabled for %ss",
56+
e.Name, e.Entity)
57+
}
58+
59+
func (e MetadataFieldInvalidValueError) Error() string {
60+
return fmt.Sprintf("flagsmithapi: invalid value for custom field '%s': %s", e.Name, e.Reason)
61+
}
62+
2363
func (e FeatureNotFoundError) Error() string {
2464
return fmt.Sprintf("flagsmithapi: feature '%s' not found", e.featureUUID)
2565
}

0 commit comments

Comments
 (0)