Skip to content

feat: Add custom field (metadata) support - #23

Merged
matthewelwell merged 5 commits into
mainfrom
feat/metadata-support
Aug 26, 2026
Merged

feat: Add custom field (metadata) support#23
matthewelwell merged 5 commits into
mainfrom
feat/metadata-support

Conversation

@matthewelwell

@matthewelwell matthewelwell commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Adds read and write support for Flagsmith custom fields on features, segments and environments.

Unblocks terraform-provider-flagsmith#215: the provider can't set a custom field at all today, so an organisation with a mandatory one can't create features through Terraform.

Resolving field names

The API takes a MetadataModelField ID, not the custom field's own ID — a field bound to features, segments and environments has three. MetadataFieldResolver handles that for one (project, entity) pair, with BuildMetadata / ResolveMetadataNames for the map-of-names case. Building one costs three requests — the project, its organisation's content types, and the project's field definitions — and nothing is cached:

resolver, err := client.GetMetadataFieldResolver(projectID, flagsmithapi.MetadataEntityFeature)
modelFieldID, err := resolver.ModelFieldID("Jira Ticket")

Two API details shape this, both commented in the code:

  • ?entity=feature filters which fields come back, but not their nested model_fields, so the caller has to match on content type anyway. The param is unused, so one response serves all three entity types.
  • Content type IDs are installation-specific, so they're resolved at runtime from supported-content-types rather than hardcoded.

Feature reads cost one more request

GET /features/get-by-uuid/ uses CreateFeatureSerializer, which omits metadata, so GetFeature hydrates it from the project-scoped retrieve endpoint. That's one extra request per feature read. There's a TODO for the upstream fix that would remove the hydration entirely. Segments and environments need no workaround.

Feature.UnmarshalJSON

It decodes via an explicit allowlist, so adding Metadata to the struct alone would have silently dropped it on every response. The allowlist now carries a warning, and TestFeatureUnmarshalJSONDoesNotDropFields reflects over the struct so the next field can't be missed the same way.

🤖 Generated with Claude Code

@matthewelwell
matthewelwell requested a review from a team as a code owner August 25, 2026 16:40
@matthewelwell
matthewelwell requested review from khvn26 and removed request for a team August 25, 2026 16:40
@matthewelwell
matthewelwell marked this pull request as draft August 25, 2026 16:49
@matthewelwell
matthewelwell removed the request for review from khvn26 August 25, 2026 16:54
@matthewelwell
matthewelwell force-pushed the feat/lookup-features-and-segments-by-name branch from 0055c7c to 7cca891 Compare August 25, 2026 17:20
@matthewelwell
matthewelwell force-pushed the feat/metadata-support branch from 961dedb to a9d977b Compare August 25, 2026 17:21
@matthewelwell
matthewelwell force-pushed the feat/lookup-features-and-segments-by-name branch from 7cca891 to b617e75 Compare August 25, 2026 17:32
@matthewelwell
matthewelwell force-pushed the feat/metadata-support branch 2 times, most recently from 96dd804 to e673aa8 Compare August 25, 2026 17:39
@matthewelwell
matthewelwell changed the base branch from feat/lookup-features-and-segments-by-name to main August 25, 2026 17:39
@matthewelwell
matthewelwell force-pushed the feat/metadata-support branch from e673aa8 to 0517426 Compare August 25, 2026 17:50
@matthewelwell
matthewelwell changed the base branch from main to perf/cache-project-lookups August 25, 2026 17:50
@matthewelwell
matthewelwell force-pushed the feat/metadata-support branch from 0517426 to 3cd0a6d Compare August 25, 2026 17:51
@matthewelwell
matthewelwell force-pushed the perf/cache-project-lookups branch from 801dcee to fd5b450 Compare August 25, 2026 18:00
@matthewelwell
matthewelwell force-pushed the feat/metadata-support branch from 3cd0a6d to b69421f Compare August 25, 2026 18:02
@matthewelwell
matthewelwell changed the base branch from perf/cache-project-lookups to main August 25, 2026 18:02
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>
@matthewelwell
matthewelwell force-pushed the feat/metadata-support branch from b69421f to 394616d Compare August 25, 2026 18:06
@matthewelwell

Copy link
Copy Markdown
Contributor Author

@CodeRabbit review

@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown
✅ Action performed

Review finished.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

Next included review available in 16 minutes.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 88ad6d71-3064-4607-a7b9-daa6ecde4b1a

📥 Commits

Reviewing files that changed from the base of the PR and between 394616d and 79715cf.

📒 Files selected for processing (5)
  • client.go
  • errors.go
  • metadata.go
  • metadata_test.go
  • models.go
📝 Walkthrough

Walkthrough

The client adds metadata models, field validation, typed errors, paginated field retrieval, content-type lookup, and entity-specific field resolution. It can build metadata from named values and resolve model-field identifiers back to names. Feature, segment, and environment models now expose metadata. Feature retrieval hydrates metadata through a project-scoped endpoint. Tests cover API requests, validation, serialisation, resolution, and resource round trips.

Estimated code review effort: 4 (Complex) | ~60 minutes

Merge Risk: 🟡 Moderate · up to 39461

This change adds metadata hydration to feature reads, but a 403 or 404 from the metadata endpoint currently causes the entire feature request to fail instead of returning the feature without metadata. That availability regression should be addressed or explicitly accepted before merging; the other risks are bounded follow-up items.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 5


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: e3aadaf4-8e18-44e1-a38b-f64bffebcec1

📥 Commits

Reviewing files that changed from the base of the PR and between d4a039a and 394616d.

📒 Files selected for processing (5)
  • client.go
  • errors.go
  • metadata.go
  • metadata_test.go
  • models.go

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.

Comment thread client.go Outdated
Comment thread errors.go
Comment thread metadata_test.go
Comment thread metadata.go Outdated
Comment thread metadata.go
Count value lengths in runes rather than bytes. The API validates with Python's
len on a str, which counts characters, so a value of 2000 multi byte characters
is valid there and was being rejected here.

Report a not-found rather than a metadata error when a feature is deleted
between the get-by-uuid read and the metadata read, so the caller can treat it
the same way as any other missing feature.

Drop the trailing empty list from MetadataFieldNotFoundError when no custom
field is enabled for the entity at all, which is the most likely case for a
project that has none.

Add request body assertions for the segment and environment write paths. Only
the feature create path had one, and sending a model field ID bound to the wrong
entity type is the failure mode most worth guarding against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@matthewelwell

Copy link
Copy Markdown
Contributor Author

Responses to the CodeRabbit review. Threaded replies aren't possible while there's a pending review on the PR, so they're collected here.

metadata.go:58 — count runes, not bytes. Fixed in bd6ca0f: now utf8.RuneCountInString, with a test for 2000 multi-byte characters. (The web search in that comment couldn't find FIELD_VALUE_MAX_LENGTH, but it does exist — api/metadata/models.py defines it and field_value is a TextField(max_length=FIELD_VALUE_MAX_LENGTH). The byte-vs-character point stands regardless.)

errors.go:52 — empty KnownNames gives (available: ). Fixed in bd6ca0f, with a different message: when nothing is bound, echoing back the field name is noise, so it's now just no custom fields are enabled for features in this project.

metadata_test.go — no write-path tests for segments and environments. Fair, and writes are the point of this change. Added TestCreateSegmentSendsMetadata, TestUpdateSegmentSendsMetadata, TestCreateEnvironmentSendsMetadata and TestUpdateEnvironmentSendsMetadata, each asserting the entity-specific model field ID in the request body.

client.go:146 — make hydration non-fatal for 403 and 404. Half-taken. A 404 now returns FeatureNotFoundError: get-by-uuid found the feature moments earlier, so a 404 means it was just deleted, and the caller should handle that like any other missing feature.

Not swallowing 403 — both endpoints are gated by the same VIEW_PROJECT permission, so a 403 on the metadata read means get-by-uuid would already have failed. A permissions problem should surface, not silently degrade to "this feature has no custom fields".

metadata.go:306 — cache the resolver. Declined. The caching was reviewed out of this PR deliberately as a premature optimisation, and the PR carrying it (#24) is closed. Worth revisiting with a measurement behind it.

Comment thread client.go Outdated
Comment thread errors.go Outdated
Comment thread models.go Outdated
matthewelwell and others added 3 commits August 25, 2026 19:32
Drops the TODO on the feature metadata hydration, the doc comments on the
metadata error types, and the warning above the Feature.UnmarshalJSON allowlist.
The allowlist is guarded by a test whose failure message names the fix, so the
comment was duplicating it.

Also drops a handful of comments in metadata.go that only restated the name of
the thing below them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Mapping a 404 from the metadata read to FeatureNotFoundError only mattered if a
feature was deleted between two back to back requests, and GetFeature already
has the same unguarded race one line earlier with GetProjectByID. It also meant
passing a feature UUID into getFeatureMetadata solely to build the error.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Building a resolver costs three requests, and callers need one per entity they
write, so a Terraform run over many features in one project repeated that for
every resource. Caches the resolver on the client, keyed on project and entity.

Scoped to the resolver itself rather than the three responses behind it: a
second entity type in the same project is built separately, which keeps this to
one map and one mutex while still collapsing the common case of many resources
of the same kind. Failures are not cached, so a transient error cannot poison
the client.

The cache lives for the lifetime of the client, which for the Terraform provider
is a single plan or apply. A custom field created after the first resolver for a
project will not be seen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@matthewelwell
matthewelwell marked this pull request as ready for review August 25, 2026 18:53
@matthewelwell
matthewelwell merged commit bf85cd9 into main Aug 26, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants