-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathmetadata.go
More file actions
390 lines (334 loc) · 12.8 KB
/
Copy pathmetadata.go
File metadata and controls
390 lines (334 loc) · 12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
package flagsmithapi
import (
"fmt"
"net/url"
"sort"
"strconv"
"strings"
"unicode/utf8"
)
// MetadataFieldValueMaxLength mirrors FIELD_VALUE_MAX_LENGTH in
// api/metadata/models.py.
const MetadataFieldValueMaxLength = 2000
// metadataFieldsPageLimit guards against looping forever on a bad `next` link.
const metadataFieldsPageLimit = 100
// FieldValue is always a string on the wire, whatever the custom field's configured
// type. ModelField is a MetadataModelField ID, not a MetadataField ID.
type Metadata struct {
ID int64 `json:"id,omitempty"`
ModelField int64 `json:"model_field"`
FieldValue string `json:"field_value"`
}
// MetadataModelField binds a custom field to a Django content type, i.e. to features,
// segments or environments. Its ID is what Metadata.ModelField refers to.
type MetadataModelField struct {
ID int64 `json:"id"`
ContentType int64 `json:"content_type"`
}
// MetadataField is a custom field definition. Fields belong to an organisation and may
// optionally be scoped to a single project.
type MetadataField struct {
ID int64 `json:"id"`
Name string `json:"name"`
Type string `json:"type"`
Description *string `json:"description"`
Organisation int64 `json:"organisation"`
Project *int64 `json:"project"`
ModelFields []MetadataModelField `json:"model_fields"`
}
// ValidateValue mirrors MetadataField.is_field_value_valid in
// api/metadata/models.py. The API remains authoritative: unknown field types are
// accepted here and left for the server to reject.
func (f MetadataField) ValidateValue(value string) error {
if value == "" {
// field_value is a TextField with blank=False, so DRF rejects empty values.
return fmt.Errorf("value for custom field '%s' must not be empty", f.Name)
}
// Counted in runes, not bytes: the API validates with Python's len on a str, so a
// value of 2000 multi byte characters is valid there and must be valid here.
if length := utf8.RuneCountInString(value); length > MetadataFieldValueMaxLength {
return fmt.Errorf("value for custom field '%s' is %d characters, the maximum is %d",
f.Name, length, MetadataFieldValueMaxLength)
}
switch f.Type {
case "int":
if _, err := strconv.ParseInt(value, 10, 64); err != nil {
return fmt.Errorf("custom field '%s' is of type 'int', but got '%s'", f.Name, value)
}
case "bool":
lowered := strings.ToLower(value)
if lowered != "true" && lowered != "false" {
return fmt.Errorf("custom field '%s' is of type 'bool', but got '%s'", f.Name, value)
}
case "url":
parsed, err := url.Parse(value)
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
return fmt.Errorf("custom field '%s' is of type 'url', but got '%s'", f.Name, value)
}
}
return nil
}
// ContentTypeInfo identifies a Django content type. IDs are specific to a Flagsmith
// installation and must never be hardcoded.
type ContentTypeInfo struct {
ID int64 `json:"id"`
AppLabel string `json:"app_label"`
Model string `json:"model"`
}
type MetadataEntity string
const (
MetadataEntityFeature MetadataEntity = "feature"
MetadataEntitySegment MetadataEntity = "segment"
MetadataEntityEnvironment MetadataEntity = "environment"
)
// metadataEntityAppLabels are the Django app labels each entity's content type is
// expected to live in, used to disambiguate content types with the same model name.
var metadataEntityAppLabels = map[MetadataEntity]string{
MetadataEntityFeature: "features",
MetadataEntitySegment: "segments",
MetadataEntityEnvironment: "environments",
}
// GetProjectMetadataFields returns every custom field definition visible to a project:
// the project's own fields plus the inherited organisation level fields.
//
// NOTE: the endpoint's `entity` query param is deliberately not used. It filters which
// *fields* are returned but does NOT filter each field's nested `model_fields`, so
// callers have to match on content type regardless. Fetching everything lets a single
// response serve all three entity types and produce precise "this field exists but is
// not enabled for features" errors.
func (c *Client) GetProjectMetadataFields(projectID int64) ([]MetadataField, error) {
requestURL := fmt.Sprintf("%s/projects/%d/metadata/fields/", c.baseURL, projectID)
fields := []MetadataField{}
for page := 1; page <= metadataFieldsPageLimit; page++ {
result := struct {
Next *string `json:"next"`
Results []MetadataField `json:"results"`
}{}
resp, err := c.client.R().
SetQueryParams(map[string]string{
// Without this, organisation level fields -- where custom fields
// created in the UI usually live -- are omitted entirely.
"include_organisation": "true",
"page": strconv.Itoa(page),
}).
SetResult(&result).
Get(requestURL)
if err != nil {
return nil, err
}
if !resp.IsSuccess() {
return nil, fmt.Errorf("flagsmithapi: Error getting metadata fields: %s", resp)
}
fields = append(fields, result.Results...)
if result.Next == nil || *result.Next == "" {
return fields, nil
}
}
return nil, fmt.Errorf("flagsmithapi: Error getting metadata fields: more than %d pages of results",
metadataFieldsPageLimit)
}
func (c *Client) GetSupportedMetadataContentTypes(organisationID int64) ([]ContentTypeInfo, error) {
requestURL := fmt.Sprintf("%s/organisations/%d/metadata-model-fields/supported-content-types/",
c.baseURL, organisationID)
contentTypes := []ContentTypeInfo{}
resp, err := c.client.R().SetResult(&contentTypes).Get(requestURL)
if err != nil {
return nil, err
}
if !resp.IsSuccess() {
return nil, fmt.Errorf("flagsmithapi: Error getting supported metadata content types: %s", resp)
}
return contentTypes, nil
}
// contentTypeIDForEntity resolves the content type ID for an entity, preferring an exact
// (app_label, model) match and falling back to a unique match on the model name alone.
func contentTypeIDForEntity(contentTypes []ContentTypeInfo, entity MetadataEntity) (int64, error) {
var candidates []ContentTypeInfo
for _, contentType := range contentTypes {
if contentType.Model != string(entity) {
continue
}
if contentType.AppLabel == metadataEntityAppLabels[entity] {
return contentType.ID, nil
}
candidates = append(candidates, contentType)
}
switch len(candidates) {
case 0:
return 0, fmt.Errorf("flagsmithapi: no content type found for '%s'; custom fields may not be supported by this Flagsmith version", entity)
case 1:
return candidates[0].ID, nil
default:
return 0, fmt.Errorf("flagsmithapi: found %d content types named '%s'; cannot determine which one custom fields apply to",
len(candidates), entity)
}
}
// MetadataFieldResolver maps custom field names to MetadataModelField IDs for a single
// project and entity type, and back again.
type MetadataFieldResolver struct {
entity MetadataEntity
contentTypeID int64
// nameToModelFieldID only covers fields bound to this entity.
nameToModelFieldID map[string]int64
nameToField map[string]MetadataField
// modelFieldIDToName covers every model field in the project, whatever entity it
// is bound to, so that reverse lookups never fail spuriously.
modelFieldIDToName map[int64]string
// unboundNames are fields that exist in the project but are not enabled for this
// entity.
unboundNames map[string]struct{}
}
func newMetadataFieldResolver(entity MetadataEntity, contentTypeID int64, fields []MetadataField) *MetadataFieldResolver {
resolver := &MetadataFieldResolver{
entity: entity,
contentTypeID: contentTypeID,
nameToModelFieldID: map[string]int64{},
nameToField: map[string]MetadataField{},
modelFieldIDToName: map[int64]string{},
unboundNames: map[string]struct{}{},
}
for _, field := range fields {
bound := false
for _, modelField := range field.ModelFields {
resolver.modelFieldIDToName[modelField.ID] = field.Name
if modelField.ContentType == contentTypeID {
resolver.nameToModelFieldID[field.Name] = modelField.ID
resolver.nameToField[field.Name] = field
bound = true
}
}
if !bound {
resolver.unboundNames[field.Name] = struct{}{}
}
}
return resolver
}
// ModelFieldID returns a MetadataFieldNotBoundError if the field exists in the project
// but is not enabled for this entity, and a MetadataFieldNotFoundError if it does not
// exist at all.
func (r *MetadataFieldResolver) ModelFieldID(name string) (int64, error) {
if modelFieldID, ok := r.nameToModelFieldID[name]; ok {
return modelFieldID, nil
}
if _, ok := r.unboundNames[name]; ok {
return 0, MetadataFieldNotBoundError{Name: name, Entity: string(r.entity)}
}
return 0, MetadataFieldNotFoundError{Name: name, Entity: string(r.entity), KnownNames: r.BoundNames()}
}
// Name reverse resolves a model field ID. ok is false for IDs the resolver has never
// seen, for example when the custom field has been deleted since the value was written.
func (r *MetadataFieldResolver) Name(modelFieldID int64) (string, bool) {
name, ok := r.modelFieldIDToName[modelFieldID]
return name, ok
}
// BoundNames returns the sorted names of the custom fields available for this entity.
func (r *MetadataFieldResolver) BoundNames() []string {
names := make([]string, 0, len(r.nameToModelFieldID))
for name := range r.nameToModelFieldID {
names = append(names, name)
}
sort.Strings(names)
return names
}
// Field returns the definition of a custom field bound to this entity.
func (r *MetadataFieldResolver) Field(name string) (MetadataField, bool) {
field, ok := r.nameToField[name]
return field, ok
}
// GetMetadataFieldResolver returns a resolver for the given project and entity.
//
// Building one costs three requests: the project, its organisation's content types, and
// the project's custom field definitions. The result is cached for the lifetime of the
// client, so a caller writing many entities in one project pays that once. A field
// created after the first call for a project will not be seen.
func (c *Client) GetMetadataFieldResolver(projectID int64, entity MetadataEntity) (*MetadataFieldResolver, error) {
cacheKey := fmt.Sprintf("%d:%s", projectID, entity)
c.resolverMu.Lock()
defer c.resolverMu.Unlock()
if resolver, ok := c.resolverCache[cacheKey]; ok {
return resolver, nil
}
project, err := c.GetProjectByID(projectID)
if err != nil {
return nil, err
}
contentTypes, err := c.GetSupportedMetadataContentTypes(project.Organisation)
if err != nil {
return nil, err
}
contentTypeID, err := contentTypeIDForEntity(contentTypes, entity)
if err != nil {
return nil, err
}
fields, err := c.GetProjectMetadataFields(projectID)
if err != nil {
return nil, err
}
resolver := newMetadataFieldResolver(entity, contentTypeID, fields)
c.resolverCache[cacheKey] = resolver
return resolver, nil
}
// BuildMetadata converts a map of custom field names to values into the wire format,
// validating each value against its field's configured type.
//
// A nil or empty map yields a non-nil empty slice, which serialises as `"metadata": []`,
// i.e. an explicit "this entity has no custom field values".
func (c *Client) BuildMetadata(projectID int64, entity MetadataEntity, values map[string]string) (*[]Metadata, error) {
metadata := []Metadata{}
if len(values) == 0 {
return &metadata, nil
}
resolver, err := c.GetMetadataFieldResolver(projectID, entity)
if err != nil {
return nil, err
}
// Sorted so that request bodies are deterministic.
names := make([]string, 0, len(values))
for name := range values {
names = append(names, name)
}
sort.Strings(names)
for _, name := range names {
modelFieldID, err := resolver.ModelFieldID(name)
if err != nil {
return nil, err
}
if field, ok := resolver.Field(name); ok {
if err := field.ValidateValue(values[name]); err != nil {
return nil, MetadataFieldInvalidValueError{
Name: name,
FieldType: field.Type,
Value: values[name],
Reason: err.Error(),
}
}
}
metadata = append(metadata, Metadata{ModelField: modelFieldID, FieldValue: values[name]})
}
return &metadata, nil
}
// ResolveMetadataNames converts wire format metadata back into a map of custom field
// names to values.
//
// Model field IDs the resolver does not recognise are skipped and returned in
// unresolved, so that a deleted custom field cannot make a read fail.
func (c *Client) ResolveMetadataNames(projectID int64, entity MetadataEntity, metadata []Metadata) (map[string]string, []int64, error) {
values := map[string]string{}
if len(metadata) == 0 {
return values, nil, nil
}
resolver, err := c.GetMetadataFieldResolver(projectID, entity)
if err != nil {
return nil, nil, err
}
var unresolved []int64
for _, item := range metadata {
name, ok := resolver.Name(item.ModelField)
if !ok {
unresolved = append(unresolved, item.ModelField)
continue
}
values[name] = item.FieldValue
}
return values, unresolved, nil
}