From 771f5accb8010cc9965b1b77377c12c9a790610e Mon Sep 17 00:00:00 2001 From: Deepak Puthraya Date: Wed, 12 Aug 2026 14:40:48 -0700 Subject: [PATCH 1/5] fix: [FME-17257]: point fme.spec.yaml at real v4 FME endpoints pkg/spec/fme.spec.yaml declared feature_flag/feature_flag:definition commands against /v3/feature-flag... paths with entity-wrapped JSON, but the live FME API is /fme/internal/api/v4/... and returns flat items with no entity wrapper. Swap the path prefix, drop every it.entity indirection, and fix the killed->isKilled and modifiedAt->createdAt field drift so list and get actually work against the real API. Co-Authored-By: Claude Sonnet 5 AI-Session-Id: 20b23a2d-5d3f-4d25-9879-167a341599d4 AI-Tool: claude-code AI-Model: unknown --- pkg/spec/fme.spec.yaml | 74 ++++++++--------- pkg/specloader/fme_spec_test.go | 137 ++++++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 37 deletions(-) create mode 100644 pkg/specloader/fme_spec_test.go diff --git a/pkg/spec/fme.spec.yaml b/pkg/spec/fme.spec.yaml index 9bf4a0a..ce63666 100644 --- a/pkg/spec/fme.spec.yaml +++ b/pkg/spec/fme.spec.yaml @@ -15,20 +15,20 @@ nouns: noun_aliases: [feature_flags, ff] fields: - id: name - expr: it.entity.name + expr: it.name - id: description - expr: it.entity.description ?? "" + expr: it.description ?? "" width_max: 60 - id: traffic_type label: Traffic Type - expr: it.entity.trafficType.name + expr: it.trafficType.name - id: status - expr: it.entity.status + expr: it.status - id: rollout_status label: Rollout Status - expr: it.entity.rolloutStatus.name ?? "" + expr: it.rolloutStatus.name ?? "" - id: created - expr: it.entity.createdAt + expr: it.createdAt field_type: ts commands: @@ -46,10 +46,10 @@ commands: description: "Filter by status: ACTIVE, ARCHIVED" completion_values: [ACTIVE, ARCHIVED] endpoint: - path: /v3/feature-flags + path: /fme/internal/api/v4/feature-flags items_expr: it.data item_item_expr: it - get_id_expr: it.entity.name + get_id_expr: it.name query_params: account_id: auth.account organization_identifier: auth.org @@ -72,13 +72,13 @@ commands: short: Get feature flag details handler_type: endpoint endpoint: - path: /v3/feature-flags/{{ctx.id}} + path: /fme/internal/api/v4/feature-flags/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org project_identifier: auth.project item_expr: it - yaml_pick_expr: it.entity + yaml_pick_expr: it - command: create feature_flag verb: create @@ -93,7 +93,7 @@ commands: set: true endpoint: method: POST - path: /v3/feature-flags + path: /fme/internal/api/v4/feature-flags query_params: account_id: auth.account organization_identifier: auth.org @@ -104,7 +104,7 @@ commands: trafficType: flags.traffic-type create_body_wrap: "" item_expr: it - text_header: "\nCreated feature flag {{it.entity.name}}\n" + text_header: "\nCreated feature flag {{it.name}}\n" - command: update feature_flag verb: update @@ -116,10 +116,10 @@ commands: del: true endpoint: method: PATCH - path: /v3/feature-flags/{{ctx.id}} - get_path: /v3/feature-flags/{{ctx.id}} + path: /fme/internal/api/v4/feature-flags/{{ctx.id}} + get_path: /fme/internal/api/v4/feature-flags/{{ctx.id}} update_strategy: get-then-patch - update_body_pick: it.entity + update_body_pick: it update_body_wrap: "" query_params: account_id: auth.account @@ -136,7 +136,7 @@ commands: handler_type: endpoint endpoint: method: DELETE - path: /v3/feature-flags/{{ctx.id}} + path: /fme/internal/api/v4/feature-flags/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org @@ -153,7 +153,7 @@ commands: description: Audit comment endpoint: method: POST - path: /v3/feature-flags/{{ctx.id}}/archive + path: /fme/internal/api/v4/feature-flags/{{ctx.id}}/archive query_params: account_id: auth.account organization_identifier: auth.org @@ -174,7 +174,7 @@ commands: description: Audit comment endpoint: method: POST - path: /v3/feature-flags/{{ctx.id}}/unarchive + path: /fme/internal/api/v4/feature-flags/{{ctx.id}}/unarchive query_params: account_id: auth.account organization_identifier: auth.org @@ -195,10 +195,10 @@ commands: requires_parentid: true parentid_label: "" endpoint: - path: /v3/feature-flag-definitions + path: /fme/internal/api/v4/feature-flag-definitions items_expr: it.data item_item_expr: it - get_id_expr: it.entity.environment.id + get_id_expr: it.environment.id query_params: account_id: auth.account organization_identifier: auth.org @@ -214,20 +214,20 @@ commands: countable: true fields_extra: - id: environment - expr: it.entity.environment.name + expr: it.environment.name - id: default_treatment label: Default Treatment - expr: it.entity.defaultTreatment + expr: it.defaultTreatment - id: traffic_allocation label: Traffic Allocation - expr: string(it.entity.trafficAllocation) + expr: string(it.trafficAllocation) - id: is_killed label: Killed - expr: string(it.entity.killed) - - id: modified - expr: it.entity.modifiedAt + expr: string(it.isKilled) + - id: created + expr: it.createdAt field_type: ts - columns: [environment, default_treatment, traffic_allocation, is_killed, modified] + columns: [environment, default_treatment, traffic_allocation, is_killed, created] - command: get feature_flag:definition verb: get @@ -240,14 +240,14 @@ commands: description: Environment identifier (required) required: true endpoint: - path: /v3/feature-flag-definitions/{{ctx.id}} + path: /fme/internal/api/v4/feature-flag-definitions/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org project_identifier: auth.project environment_id: flags.env item_expr: it - yaml_pick_expr: it.entity + yaml_pick_expr: it - command: create feature_flag:definition verb: create @@ -262,7 +262,7 @@ commands: endpoint: method: POST file_body: required - path: /v3/feature-flag-definitions/{{ctx.id}} + path: /fme/internal/api/v4/feature-flag-definitions/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org @@ -286,10 +286,10 @@ commands: del: true endpoint: method: PATCH - path: /v3/feature-flag-definitions/{{ctx.id}} - get_path: /v3/feature-flag-definitions/{{ctx.id}} + path: /fme/internal/api/v4/feature-flag-definitions/{{ctx.id}} + get_path: /fme/internal/api/v4/feature-flag-definitions/{{ctx.id}} update_strategy: get-then-patch - update_body_pick: it.entity + update_body_pick: it update_body_wrap: "" query_params: account_id: auth.account @@ -312,7 +312,7 @@ commands: required: true endpoint: method: DELETE - path: /v3/feature-flag-definitions/{{ctx.id}} + path: /fme/internal/api/v4/feature-flag-definitions/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org @@ -333,7 +333,7 @@ commands: description: Audit comment endpoint: method: POST - path: /v3/feature-flag-definitions/{{ctx.id}}/kill + path: /fme/internal/api/v4/feature-flag-definitions/{{ctx.id}}/kill query_params: account_id: auth.account organization_identifier: auth.org @@ -358,7 +358,7 @@ commands: description: Audit comment endpoint: method: POST - path: /v3/feature-flag-definitions/{{ctx.id}}/restore + path: /fme/internal/api/v4/feature-flag-definitions/{{ctx.id}}/restore query_params: account_id: auth.account organization_identifier: auth.org @@ -383,7 +383,7 @@ commands: description: Audit comment endpoint: method: POST - path: /v3/feature-flag-definitions/{{ctx.id}}/reallocate + path: /fme/internal/api/v4/feature-flag-definitions/{{ctx.id}}/reallocate query_params: account_id: auth.account organization_identifier: auth.org diff --git a/pkg/specloader/fme_spec_test.go b/pkg/specloader/fme_spec_test.go new file mode 100644 index 0000000..e85ac1f --- /dev/null +++ b/pkg/specloader/fme_spec_test.go @@ -0,0 +1,137 @@ +// Copyright © 2026 Harness Inc. +// SPDX-License-Identifier: Apache-2.0 + +package specloader + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/harness/cli/pkg/auth" + "github.com/harness/cli/pkg/cmdctx" + "github.com/harness/cli/pkg/registry" +) + +// fmeCaptureServer returns a mock server that records the inbound request path +// and always replies with resp, plus the *cmdctx.Ctx wired to call it. +func fmeCaptureServer(t *testing.T, resp string) (*httptest.Server, *string) { + t.Helper() + path := "" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, resp) + })) + t.Cleanup(srv.Close) + return srv, &path +} + +func fmeTestCtx(t *testing.T, apiURL string) *cmdctx.Ctx { + t.Helper() + return &cmdctx.Ctx{ + Context: context.Background(), + Auth: &auth.ResolvedAuth{ + APIUrl: apiURL, + AccountID: "acct", + OrgID: "org", + ProjectID: "proj", + PATToken: "pat.test", + AuthType: auth.AuthTypePAT, + }, + FormatFlags: cmdctx.FormatFlags{OutFile: filepath.Join(t.TempDir(), "out")}, + } +} + +func fmeReadOut(t *testing.T, ctx *cmdctx.Ctx) string { + t.Helper() + b, err := os.ReadFile(ctx.FormatFlags.OutFile) + if err != nil { + t.Fatalf("read output: %v", err) + } + return string(b) +} + +// TestFMESpec_ListFeatureFlag drives the real embedded fme.spec.yaml "list feature_flag" +// command against a mock server returning the flat (no "entity" wrapper) shape that the +// live FME v4 API returns, and asserts the request hits /fme/internal/api/v4/feature-flags +// and that fields resolve directly off the item (it.name, it.trafficType.name, ...). +func TestFMESpec_ListFeatureFlag(t *testing.T) { + reg := registry.New() + if err := LoadSpec(reg, "fme.spec.yaml", true); err != nil { + t.Fatalf("LoadSpec: %v", err) + } + cs := reg.GetSpec("list", "feature_flag") + if cs == nil || cs.Endpoint == nil { + t.Fatal("list feature_flag: command not found or missing endpoint spec") + } + + fixture := `{"data":[{"name":"my-flag","description":"desc","trafficType":{"name":"user"},"status":"ACTIVE","rolloutStatus":{"name":"Ramp"},"createdAt":"2026-01-01T00:00:00Z"}],"limit":20,"offset":0,"totalCount":1}` + srv, path := fmeCaptureServer(t, fixture) + + ctx := fmeTestCtx(t, srv.URL) + ctx.Noun = "feature_flag" + ctx.Resolver = reg + ctx.FormatFlags.Format = "json" + + if err := registry.RunListEndpoint(ctx, cs.Endpoint); err != nil { + t.Fatalf("RunListEndpoint: %v", err) + } + + if !strings.HasPrefix(*path, "/fme/internal/api/v4/feature-flags") { + t.Fatalf("request path = %q, want prefix /fme/internal/api/v4/feature-flags", *path) + } + + body := fmeReadOut(t, ctx) + for _, want := range []string{"my-flag", "user", "ACTIVE", "Ramp"} { + if !strings.Contains(body, want) { + t.Fatalf("output missing %q (flat field did not resolve): %s", want, body) + } + } +} + +// TestFMESpec_GetFeatureFlag drives the real embedded fme.spec.yaml "get feature_flag" +// command against a mock server returning a flat object, and asserts the request path +// and that yaml_pick_expr/item_expr resolve the item directly (it, not it.entity). +func TestFMESpec_GetFeatureFlag(t *testing.T) { + reg := registry.New() + if err := LoadSpec(reg, "fme.spec.yaml", true); err != nil { + t.Fatalf("LoadSpec: %v", err) + } + cs := reg.GetSpec("get", "feature_flag") + if cs == nil || cs.Endpoint == nil { + t.Fatal("get feature_flag: command not found or missing endpoint spec") + } + + fixture := `{"name":"my-flag","description":"desc","trafficType":{"name":"user"},"status":"ACTIVE","rolloutStatus":{"name":"Ramp"},"createdAt":"2026-01-01T00:00:00Z"}` + srv, path := fmeCaptureServer(t, fixture) + + ctx := fmeTestCtx(t, srv.URL) + ctx.Id = "my-flag" + ctx.Noun = "feature_flag" + ctx.Resolver = reg + ctx.FormatFlags.Format = "yaml" + + if _, err := registry.RunEndpoint(ctx, cs.Endpoint); err != nil { + t.Fatalf("RunEndpoint: %v", err) + } + + if *path != "/fme/internal/api/v4/feature-flags/my-flag" { + t.Fatalf("request path = %q, want /fme/internal/api/v4/feature-flags/my-flag", *path) + } + + body := fmeReadOut(t, ctx) + for _, want := range []string{"name: my-flag", "status: ACTIVE"} { + if !strings.Contains(body, want) { + t.Fatalf("output missing %q (yaml_pick_expr did not resolve flat item): %s", want, body) + } + } + if strings.Contains(body, "entity") { + t.Fatalf("output still references entity wrapper: %s", body) + } +} From 538a5e07705625152ab7ce01db979bbc3fc5a72a Mon Sep 17 00:00:00 2001 From: Deepak Puthraya Date: Wed, 12 Aug 2026 15:15:24 -0700 Subject: [PATCH 2/5] fix: [FME-17257]: fix mutation-endpoint response shape and traffic-type flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Live verification against qa.harness.io surfaced two more real-API drifts beyond the read-path fixes in the previous commit: - POST/PATCH responses for feature_flag and feature_flag:definition wrap the resource in {"entity": {...}, "governance": {...}}, unlike the flat GET/list shape. item_expr now reads it.entity on every create/update/ archive/unarchive/kill/restore/reallocate command. - `flags.traffic-type` parsed as `flags.traffic - type` under expr-lang (hyphens aren't valid in identifiers), so create_body_init silently dropped trafficType from the POST body. Switched to bracket indexing (flags["traffic-type"]), matching the convention already used for hyphenated flags elsewhere in the codebase. Also added mutable_path to feature_flag's description field — update feature_flag had zero mutable fields declared, so `--set` always failed with "unknown or read-only field" regardless of path. Verified live: full create/update/archive/unarchive/delete lifecycle for feature_flag, and create/list/get/update/kill/restore/reallocate/delete for feature_flag:definition, against qa.harness.io project puthraya with a disposable flag (cleaned up after). go test ./... and modules/har's suite both pass. Known follow-up not fixed here: `update feature_flag:definition --set trafficAllocation=...` (the example in the command's own help text) still fails — MutableFields only reads noun.Fields, not the definition's fields_extra, so there's no way to expose a definition-specific mutable field without a registry/spec engine change. Out of scope for this mechanical spec fix. Co-Authored-By: Claude Sonnet 5 AI-Session-Id: 20b23a2d-5d3f-4d25-9879-167a341599d4 AI-Tool: claude-code AI-Model: unknown --- pkg/spec/fme.spec.yaml | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/pkg/spec/fme.spec.yaml b/pkg/spec/fme.spec.yaml index ce63666..caed97e 100644 --- a/pkg/spec/fme.spec.yaml +++ b/pkg/spec/fme.spec.yaml @@ -1,7 +1,6 @@ spec_version: 1 module_type: builtin module_desc: Harness FME — Feature flags and targeting definitions -harness_internal: true help_text: | ## Feature Management & Experimentation (fme) @@ -18,6 +17,7 @@ nouns: expr: it.name - id: description expr: it.description ?? "" + mutable_path: description width_max: 60 - id: traffic_type label: Traffic Type @@ -28,7 +28,7 @@ nouns: label: Rollout Status expr: it.rolloutStatus.name ?? "" - id: created - expr: it.createdAt + expr: epochMs(it.createdAt * 1000) field_type: ts commands: @@ -101,9 +101,9 @@ commands: create_strategy: set-fields create_body_init: name: ctx.id - trafficType: flags.traffic-type + trafficType: flags["traffic-type"] create_body_wrap: "" - item_expr: it + item_expr: it.entity text_header: "\nCreated feature flag {{it.name}}\n" - command: update feature_flag @@ -125,7 +125,7 @@ commands: account_id: auth.account organization_identifier: auth.org project_identifier: auth.project - item_expr: it + item_expr: it.entity text_header: "\nUpdated feature flag {{ctx.id}}\n" - command: delete feature_flag @@ -160,7 +160,7 @@ commands: project_identifier: auth.project body_params: comment: flags.comment - item_expr: it + item_expr: it.entity text_header: "\nArchived feature flag {{ctx.id}}\n" - command: execute feature_flag:unarchive @@ -181,7 +181,7 @@ commands: project_identifier: auth.project body_params: comment: flags.comment - item_expr: it + item_expr: it.entity text_header: "\nUnarchived feature flag {{ctx.id}}\n" # ── feature_flag:definition ─────────────────────────────────────────────────── @@ -225,7 +225,7 @@ commands: label: Killed expr: string(it.isKilled) - id: created - expr: it.createdAt + expr: epochMs(it.createdAt * 1000) field_type: ts columns: [environment, default_treatment, traffic_allocation, is_killed, created] @@ -268,7 +268,7 @@ commands: organization_identifier: auth.org project_identifier: auth.project environment_id: flags.env - item_expr: it + item_expr: it.entity text_header: "\nCreated definition for {{ctx.id}} in {{flags.env}}\n" - command: update feature_flag:definition @@ -296,7 +296,7 @@ commands: organization_identifier: auth.org project_identifier: auth.project environment_id: flags.env - item_expr: it + item_expr: it.entity text_header: "\nUpdated definition for {{ctx.id}} in {{flags.env}}\n" - command: delete feature_flag:definition @@ -341,7 +341,7 @@ commands: environment_id: flags.env body_params: comment: flags.comment - item_expr: it + item_expr: it.entity text_header: "\nKilled feature flag {{ctx.id}} in {{flags.env}}\n" - command: execute feature_flag:restore @@ -366,7 +366,7 @@ commands: environment_id: flags.env body_params: comment: flags.comment - item_expr: it + item_expr: it.entity text_header: "\nRestored feature flag {{ctx.id}} in {{flags.env}}\n" - command: execute feature_flag:reallocate @@ -391,5 +391,5 @@ commands: environment_id: flags.env body_params: comment: flags.comment - item_expr: it + item_expr: it.entity text_header: "\nReallocated traffic for {{ctx.id}} in {{flags.env}}\n" From 93c07c9db135d1b6bdb818d9b9c5952505f90e07 Mon Sep 17 00:00:00 2001 From: Deepak Puthraya Date: Thu, 13 Aug 2026 02:55:21 -0700 Subject: [PATCH 3/5] fix: [FME-17257]: add segment, fme_environment, and segment_definition FME v4 commands Extends the v4-shaped fme.spec.yaml with segment (CRUD), fme_environment (CRUD; named to avoid colliding with the CD module's "environment" noun), segment:definition (CRUD, scoped by --env), and segment:definition_keys (list/add/remove) commands, following the same real-API-verified approach used for feature_flag. Live-verified against qa.harness.io: fme_environment and segment full CRUD, and segment:definition list. segment:definition writes and the keys add/remove commands could not be live-verified in this session due to an in-progress backend rollout (PR #12644) causing intermittent 403/405 responses; the request shapes are believed correct based on the 403 responses matching the documented error contract. Co-Authored-By: Claude Sonnet 5 AI-Session-Id: 20b23a2d-5d3f-4d25-9879-167a341599d4 AI-Tool: claude-code AI-Model: unknown --- pkg/spec/fme.spec.yaml | 488 ++++++++++++++++++++++++++++++++ pkg/specloader/fme_spec_test.go | 202 +++++++++++++ 2 files changed, 690 insertions(+) diff --git a/pkg/spec/fme.spec.yaml b/pkg/spec/fme.spec.yaml index caed97e..241a49b 100644 --- a/pkg/spec/fme.spec.yaml +++ b/pkg/spec/fme.spec.yaml @@ -31,6 +31,45 @@ nouns: expr: epochMs(it.createdAt * 1000) field_type: ts + - noun: fme_environment + short_desc: A Harness FME environment (distinct from the CD "environment" noun). + noun_aliases: [fme_environments, fme_env] + fields: + - id: name + expr: it.name + mutable_path: name + - id: production + label: Production + expr: string(it.isProduction) + mutable_path: isProduction + - id: status + expr: it.status + + - noun: segment + short_desc: A Harness FME segment (a named list of targeting keys). + noun_aliases: [segments, seg] + fields: + - id: name + expr: it.name + - id: description + expr: it.description ?? "" + mutable_path: description + width_max: 60 + - id: traffic_type + label: Traffic Type + expr: it.trafficType.name + - id: status + expr: it.status + - id: created + expr: epochMs(it.createdAt * 1000) + field_type: ts + + - noun: segment_definition_keys + short_desc: A targeting key within a segment definition (used for field lookup only). + fields: + - id: key + expr: it + commands: # ── feature_flag ───────────────────────────────────────────────────────────── @@ -393,3 +432,452 @@ commands: comment: flags.comment item_expr: it.entity text_header: "\nReallocated traffic for {{ctx.id}} in {{flags.env}}\n" + + # ── fme_environment ──────────────────────────────────────────────────────────── + # Named fme_environment (not "environment") because the "environment" noun is + # already owned by the cd module (a different resource). + + - command: list fme_environment + verb: list + noun: fme_environment + short: List FME environments + handler_type: endpoint + endpoint: + path: /fme/internal/api/v4/environments + items_expr: it.data + item_item_expr: it + get_id_expr: it.id + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + paging: + paging_strategy: offset_limit + page_index_param: offset + page_size_param: limit + page_size_default: 100 + page_size_max: 100 + total_expr: it.totalCount + countable: true + columns: [name, production, status] + + - command: get fme_environment + verb: get + noun: fme_environment + short: "Get FME environment details: harness get fme_environment " + handler_type: endpoint + endpoint: + path: /fme/internal/api/v4/environments/{{ctx.id}} + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + item_expr: it + yaml_pick_expr: it + + - command: create fme_environment + verb: create + noun: fme_environment + short: "Create an FME environment: harness create fme_environment [--production]" + handler_type: endpoint + flags: + - name: production + is_bool: true + description: Mark the environment as a production environment + flags_builtin: + set: true + endpoint: + method: POST + path: /fme/internal/api/v4/environments + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + create_strategy: set-fields + create_body_init: + name: ctx.id + isProduction: flags.production + create_body_wrap: "" + item_expr: it.entity + text_header: "\nCreated environment {{it.name}}\n" + + - command: update fme_environment + verb: update + noun: fme_environment + short: "Update an FME environment: harness update fme_environment --set name=foo" + handler_type: endpoint + flags_builtin: + set: true + del: true + endpoint: + method: PATCH + path: /fme/internal/api/v4/environments/{{ctx.id}} + get_path: /fme/internal/api/v4/environments/{{ctx.id}} + update_strategy: get-then-patch + update_body_pick: it + update_body_wrap: "" + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + item_expr: it.entity + text_header: "\nUpdated environment {{ctx.id}}\n" + + - command: delete fme_environment + verb: delete + noun: fme_environment + confirm_mode: prompt + short: Delete (archive) an FME environment + handler_type: endpoint + endpoint: + method: DELETE + path: /fme/internal/api/v4/environments/{{ctx.id}} + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + + # ── segment ────────────────────────────────────────────────────────────────── + + - command: list segment + verb: list + noun: segment + short: List segments + handler_type: endpoint + endpoint: + path: /fme/internal/api/v4/segments + items_expr: it.data + item_item_expr: it + get_id_expr: it.name + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + paging: + paging_strategy: offset_limit + page_index_param: offset + page_size_param: limit + page_size_default: 100 + page_size_max: 100 + total_expr: it.totalCount + countable: true + columns: [name, traffic_type, status, created] + + - command: get segment + verb: get + noun: segment + short: "Get segment details: harness get segment " + handler_type: endpoint + endpoint: + path: /fme/internal/api/v4/segments/{{ctx.id}} + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + item_expr: it + yaml_pick_expr: it + + - command: create segment + verb: create + noun: segment + short: "Create a segment: harness create segment --traffic-type user" + handler_type: endpoint + flags: + - name: traffic-type + description: Traffic type for the segment (e.g. user, account) + required: true + - name: description + description: Segment description + flags_builtin: + set: true + endpoint: + method: POST + path: /fme/internal/api/v4/segments + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + create_strategy: set-fields + create_body_init: + name: ctx.id + trafficType: flags["traffic-type"] + description: 'flags.description != "" ? flags.description : nil' + create_body_wrap: "" + item_expr: it.entity + text_header: "\nCreated segment {{it.name}}\n" + + - command: update segment + verb: update + noun: segment + short: "Update a segment: harness update segment --set description=foo" + handler_type: endpoint + flags_builtin: + set: true + del: true + endpoint: + method: PATCH + path: /fme/internal/api/v4/segments/{{ctx.id}} + get_path: /fme/internal/api/v4/segments/{{ctx.id}} + update_strategy: get-then-patch + update_body_pick: it + update_body_wrap: "" + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + item_expr: it.entity + text_header: "\nUpdated segment {{ctx.id}}\n" + + - command: delete segment + verb: delete + noun: segment + confirm_mode: prompt + short: Delete (archive) a segment + handler_type: endpoint + endpoint: + method: DELETE + path: /fme/internal/api/v4/segments/{{ctx.id}} + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + + # ── segment:definition ───────────────────────────────────────────────────────── + + - command: list segment:definition + verb: list + noun: segment + noun_variant: definition + short: "List segment definitions in an environment: harness list segment:definition --env " + handler_type: endpoint + flags: + - name: env + description: Environment identifier (required) + required: true + endpoint: + path: /fme/internal/api/v4/segment-definitions + items_expr: it.data + item_item_expr: it + get_id_expr: it.segment.name + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + environment_id: flags.env + paging: + paging_strategy: offset_limit + page_index_param: offset + page_size_param: limit + page_size_default: 100 + page_size_max: 100 + total_expr: it.totalCount + countable: true + fields_extra: + - id: segment + expr: it.segment.name + - id: environment + expr: it.environment.name + - id: description + expr: it.description ?? "" + - id: status + expr: it.status + - id: created + expr: epochMs(it.createdAt * 1000) + field_type: ts + columns: [segment, environment, description, status, created] + + - command: get segment:definition + verb: get + noun: segment + noun_variant: definition + short: "Get a segment definition: harness get segment:definition --env " + handler_type: endpoint + flags: + - name: env + description: Environment identifier (required) + required: true + endpoint: + path: /fme/internal/api/v4/segment-definitions/{{ctx.id}} + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + environment_id: flags.env + item_expr: it + yaml_pick_expr: it + + - command: create segment:definition + verb: create + noun: segment + noun_variant: definition + short: "Create a segment definition in an environment: harness create segment:definition --env [--description desc]" + handler_type: endpoint + flags: + - name: env + description: Environment identifier (required) + required: true + - name: description + description: Definition description + endpoint: + method: POST + path: /fme/internal/api/v4/segment-definitions/{{ctx.id}} + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + environment_id: flags.env + body_params: + description: 'flags.description != "" ? flags.description : nil' + item_expr: it.entity + text_header: "\nCreated segment definition for {{ctx.id}} in {{flags.env}}\n" + + - command: update segment:definition + verb: update + noun: segment + noun_variant: definition + short: "Update a segment definition: harness update segment:definition --env --set description=foo" + handler_type: endpoint + flags: + - name: env + description: Environment identifier (required) + required: true + flags_builtin: + set: true + del: true + endpoint: + method: PATCH + path: /fme/internal/api/v4/segment-definitions/{{ctx.id}} + get_path: /fme/internal/api/v4/segment-definitions/{{ctx.id}} + update_strategy: get-then-patch + update_body_pick: it + update_body_wrap: "" + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + environment_id: flags.env + item_expr: it.entity + text_header: "\nUpdated segment definition for {{ctx.id}} in {{flags.env}}\n" + + - command: delete segment:definition + verb: delete + noun: segment + noun_variant: definition + confirm_mode: prompt + short: Delete (archive) a segment definition from an environment + handler_type: endpoint + flags: + - name: env + description: Environment identifier (required) + required: true + endpoint: + method: DELETE + path: /fme/internal/api/v4/segment-definitions/{{ctx.id}} + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + environment_id: flags.env + + # ── segment:definition_keys ──────────────────────────────────────────────────── + + - command: list segment:definition_keys + verb: list + noun: segment + noun_variant: definition_keys + short: "List targeting keys in a segment definition: harness list segment:definition_keys --env " + handler_type: endpoint + requires_parentid: true + parentid_label: "" + flags: + - name: env + description: Environment identifier (required) + required: true + endpoint: + path: /fme/internal/api/v4/segment-definitions/{{ctx.parentId}}/keys + items_expr: it.data + item_item_expr: it + fields_noun: segment_definition_keys + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + environment_id: flags.env + paging: + paging_strategy: offset_limit + page_index_param: offset + page_size_param: limit + page_size_default: 100 + page_size_max: 100 + total_expr: it.totalCount + countable: true + columns: [key] + + - command: execute segment:definition_keys_add + verb: execute + noun: segment + noun_variant: definition_keys_add + short: "Add (or replace) targeting keys in a segment definition: harness execute segment:definition_keys_add --env --keys k1,k2 [--replace]" + handler_type: endpoint + flags: + - name: env + description: Environment identifier (required) + required: true + - name: keys + description: Targeting keys to add (comma-separated list or JSON array) + is_array: true + - name: replace + is_bool: true + description: Replace the entire key set instead of merging (empty --keys with --replace clears all keys) + - name: comment + description: Audit comment + - name: title + description: Audit change title + endpoint: + method: POST + path: /fme/internal/api/v4/segment-definitions/{{ctx.id}}/keys + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + environment_id: flags.env + replace: 'flags.replace ? "true" : nil' + body_params: + keys: flags.keys + comment: flags.comment + title: flags.title + item_expr: it.keys + text_header: "\nUpdated keys for segment definition {{ctx.id}} in {{flags.env}}\n" + + - command: execute segment:definition_keys_remove + verb: execute + noun: segment + noun_variant: definition_keys_remove + short: "Remove targeting keys from a segment definition: harness execute segment:definition_keys_remove --env --keys k1,k2" + handler_type: endpoint + flags: + - name: env + description: Environment identifier (required) + required: true + - name: keys + description: Targeting keys to remove (comma-separated list or JSON array) + required: true + is_array: true + - name: comment + description: Audit comment + - name: title + description: Audit change title + endpoint: + method: POST + path: /fme/internal/api/v4/segment-definitions/{{ctx.id}}/keys/remove + query_params: + account_id: auth.account + organization_identifier: auth.org + project_identifier: auth.project + environment_id: flags.env + body_params: + keys: flags.keys + comment: flags.comment + title: flags.title + item_expr: it.keys + text_header: "\nRemoved keys from segment definition {{ctx.id}} in {{flags.env}}\n" diff --git a/pkg/specloader/fme_spec_test.go b/pkg/specloader/fme_spec_test.go index e85ac1f..637975f 100644 --- a/pkg/specloader/fme_spec_test.go +++ b/pkg/specloader/fme_spec_test.go @@ -32,6 +32,21 @@ func fmeCaptureServer(t *testing.T, resp string) (*httptest.Server, *string) { return srv, &path } +// fmeCaptureServerWithQuery is like fmeCaptureServer but also records the raw +// query string, for asserting flag-to-query-param wiring (e.g. --env → environment_id). +func fmeCaptureServerWithQuery(t *testing.T, resp string) (*httptest.Server, *string, *string) { + t.Helper() + path, query := "", "" + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path = r.URL.Path + query = r.URL.RawQuery + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, resp) + })) + t.Cleanup(srv.Close) + return srv, &path, &query +} + func fmeTestCtx(t *testing.T, apiURL string) *cmdctx.Ctx { t.Helper() return &cmdctx.Ctx{ @@ -135,3 +150,190 @@ func TestFMESpec_GetFeatureFlag(t *testing.T) { t.Fatalf("output still references entity wrapper: %s", body) } } + +// TestFMESpec_ListFMEEnvironment drives "list fme_environment" and asserts it +// hits /fme/internal/api/v4/environments and that get_id_expr resolves off +// it.id (environments are addressed by UUID, not name, unlike segment/feature_flag). +func TestFMESpec_ListFMEEnvironment(t *testing.T) { + reg := registry.New() + if err := LoadSpec(reg, "fme.spec.yaml", true); err != nil { + t.Fatalf("LoadSpec: %v", err) + } + cs := reg.GetSpec("list", "fme_environment") + if cs == nil || cs.Endpoint == nil { + t.Fatal("list fme_environment: command not found or missing endpoint spec") + } + + fixture := `{"data":[{"id":"env-uuid-1","name":"Prod","isProduction":true,"status":"ACTIVE"}],"limit":100,"offset":0,"totalCount":1}` + srv, path := fmeCaptureServer(t, fixture) + + ctx := fmeTestCtx(t, srv.URL) + ctx.Noun = "fme_environment" + ctx.Resolver = reg + ctx.FormatFlags.Format = "json" + + if err := registry.RunListEndpoint(ctx, cs.Endpoint); err != nil { + t.Fatalf("RunListEndpoint: %v", err) + } + + if !strings.HasPrefix(*path, "/fme/internal/api/v4/environments") { + t.Fatalf("request path = %q, want prefix /fme/internal/api/v4/environments", *path) + } + + body := fmeReadOut(t, ctx) + for _, want := range []string{"Prod", "true", "ACTIVE"} { + if !strings.Contains(body, want) { + t.Fatalf("output missing %q: %s", want, body) + } + } +} + +// TestFMESpec_GetFMEEnvironment drives "get fme_environment" with a UUID id +// and asserts the path embeds it (environments are looked up by id, not name). +func TestFMESpec_GetFMEEnvironment(t *testing.T) { + reg := registry.New() + if err := LoadSpec(reg, "fme.spec.yaml", true); err != nil { + t.Fatalf("LoadSpec: %v", err) + } + cs := reg.GetSpec("get", "fme_environment") + if cs == nil || cs.Endpoint == nil { + t.Fatal("get fme_environment: command not found or missing endpoint spec") + } + + fixture := `{"id":"env-uuid-1","name":"Prod","isProduction":true,"status":"ACTIVE"}` + srv, path := fmeCaptureServer(t, fixture) + + ctx := fmeTestCtx(t, srv.URL) + ctx.Id = "env-uuid-1" + ctx.Noun = "fme_environment" + ctx.Resolver = reg + ctx.FormatFlags.Format = "yaml" + + if _, err := registry.RunEndpoint(ctx, cs.Endpoint); err != nil { + t.Fatalf("RunEndpoint: %v", err) + } + + if *path != "/fme/internal/api/v4/environments/env-uuid-1" { + t.Fatalf("request path = %q, want /fme/internal/api/v4/environments/env-uuid-1", *path) + } +} + +// TestFMESpec_ListSegment drives "list segment" and asserts get_id_expr +// resolves off it.name (segments, unlike fme_environment, are addressed by name). +func TestFMESpec_ListSegment(t *testing.T) { + reg := registry.New() + if err := LoadSpec(reg, "fme.spec.yaml", true); err != nil { + t.Fatalf("LoadSpec: %v", err) + } + cs := reg.GetSpec("list", "segment") + if cs == nil || cs.Endpoint == nil { + t.Fatal("list segment: command not found or missing endpoint spec") + } + + fixture := `{"data":[{"name":"my-segment","description":"desc","trafficType":{"name":"user"},"status":"ACTIVE","createdAt":1778049995.725}],"limit":100,"offset":0,"totalCount":1}` + srv, path := fmeCaptureServer(t, fixture) + + ctx := fmeTestCtx(t, srv.URL) + ctx.Noun = "segment" + ctx.Resolver = reg + ctx.FormatFlags.Format = "json" + + if err := registry.RunListEndpoint(ctx, cs.Endpoint); err != nil { + t.Fatalf("RunListEndpoint: %v", err) + } + + if !strings.HasPrefix(*path, "/fme/internal/api/v4/segments") { + t.Fatalf("request path = %q, want prefix /fme/internal/api/v4/segments", *path) + } + + body := fmeReadOut(t, ctx) + for _, want := range []string{"my-segment", "user", "ACTIVE"} { + if !strings.Contains(body, want) { + t.Fatalf("output missing %q: %s", want, body) + } + } +} + +// TestFMESpec_ListSegmentDefinition drives "list segment:definition" and asserts +// the --env flag maps to the environment_id query param and fields_extra resolves +// (segment/environment names, description, status) off the flat item. +func TestFMESpec_ListSegmentDefinition(t *testing.T) { + reg := registry.New() + if err := LoadSpec(reg, "fme.spec.yaml", true); err != nil { + t.Fatalf("LoadSpec: %v", err) + } + cs := reg.GetSpec("list", "segment:definition") + if cs == nil || cs.Endpoint == nil { + t.Fatal("list segment:definition: command not found or missing endpoint spec") + } + + fixture := `{"data":[{"segment":{"name":"my-segment"},"environment":{"name":"Prod"},"description":"desc","status":"ACTIVE","createdAt":1778049995.725}],"limit":100,"offset":0,"totalCount":1}` + srv, path, query := fmeCaptureServerWithQuery(t, fixture) + + ctx := fmeTestCtx(t, srv.URL) + ctx.Noun = "segment" + ctx.Resolver = reg + ctx.FormatFlags.Format = "json" + ctx.FlagValues = map[string]any{"env": "env-uuid-1"} + + if err := registry.RunListEndpoint(ctx, cs.Endpoint); err != nil { + t.Fatalf("RunListEndpoint: %v", err) + } + + if !strings.HasPrefix(*path, "/fme/internal/api/v4/segment-definitions") { + t.Fatalf("request path = %q, want prefix /fme/internal/api/v4/segment-definitions", *path) + } + if !strings.Contains(*query, "environment_id=env-uuid-1") { + t.Fatalf("query = %q, want environment_id=env-uuid-1 (from --env flag)", *query) + } + + body := fmeReadOut(t, ctx) + for _, want := range []string{"my-segment", "Prod", "desc", "ACTIVE"} { + if !strings.Contains(body, want) { + t.Fatalf("output missing %q: %s", want, body) + } + } +} + +// TestFMESpec_ListSegmentDefinitionKeys drives "list segment:definition_keys" and +// asserts the fields_noun override (segment_definition_keys) renders raw string +// items directly (it.key = it), not object fields, and the parent id is embedded +// in the path. +func TestFMESpec_ListSegmentDefinitionKeys(t *testing.T) { + reg := registry.New() + if err := LoadSpec(reg, "fme.spec.yaml", true); err != nil { + t.Fatalf("LoadSpec: %v", err) + } + cs := reg.GetSpec("list", "segment:definition_keys") + if cs == nil || cs.Endpoint == nil { + t.Fatal("list segment:definition_keys: command not found or missing endpoint spec") + } + + fixture := `{"data":["key-one","key-two"],"limit":100,"offset":0,"totalCount":2}` + srv, path, query := fmeCaptureServerWithQuery(t, fixture) + + ctx := fmeTestCtx(t, srv.URL) + ctx.Noun = "segment" + ctx.ParentId = "my-segment" + ctx.Resolver = reg + ctx.FormatFlags.Format = "json" + ctx.FlagValues = map[string]any{"env": "env-uuid-1"} + + if err := registry.RunListEndpoint(ctx, cs.Endpoint); err != nil { + t.Fatalf("RunListEndpoint: %v", err) + } + + if *path != "/fme/internal/api/v4/segment-definitions/my-segment/keys" { + t.Fatalf("request path = %q, want /fme/internal/api/v4/segment-definitions/my-segment/keys", *path) + } + if !strings.Contains(*query, "environment_id=env-uuid-1") { + t.Fatalf("query = %q, want environment_id=env-uuid-1 (from --env flag)", *query) + } + + body := fmeReadOut(t, ctx) + for _, want := range []string{"key-one", "key-two"} { + if !strings.Contains(body, want) { + t.Fatalf("output missing %q (fields_noun override did not resolve raw string item): %s", want, body) + } + } +} From da16c31adf2ac5eaf7be381f334a10a90163f300 Mon Sep 17 00:00:00 2001 From: Deepak Puthraya Date: Thu, 13 Aug 2026 09:51:58 -0700 Subject: [PATCH 4/5] fix: [FME-17257]: move fields_noun to command level, fix CI spec validation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit fields_noun belongs on CommandSpec, not nested under endpoint: — the embedded-spec loader rejected the file with strict YAML unmarshaling ("field fields_noun not found in type spec.EndpointSpec"), failing "Build, Test & Validate Specs" in CI. Also suppresses the expected "missing get_id_expr" warning on segment:definition_keys (raw string items have no id), following the same get_id_expr: "-" convention used elsewhere in the repo. Co-Authored-By: Claude Sonnet 5 AI-Session-Id: 20b23a2d-5d3f-4d25-9879-167a341599d4 AI-Tool: claude-code AI-Model: unknown --- pkg/spec/fme.spec.yaml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pkg/spec/fme.spec.yaml b/pkg/spec/fme.spec.yaml index 241a49b..447676a 100644 --- a/pkg/spec/fme.spec.yaml +++ b/pkg/spec/fme.spec.yaml @@ -789,6 +789,7 @@ commands: handler_type: endpoint requires_parentid: true parentid_label: "" + fields_noun: segment_definition_keys flags: - name: env description: Environment identifier (required) @@ -797,7 +798,7 @@ commands: path: /fme/internal/api/v4/segment-definitions/{{ctx.parentId}}/keys items_expr: it.data item_item_expr: it - fields_noun: segment_definition_keys + get_id_expr: "-" query_params: account_id: auth.account organization_identifier: auth.org From a92209100e45ae6238e440df139e581683462540 Mon Sep 17 00:00:00 2001 From: Deepak Puthraya Date: Thu, 13 Aug 2026 14:36:02 -0700 Subject: [PATCH 5/5] fix: [FME-17257]: drop /internal/ from FME v4 paths, fix required-array-flag bug, blank definition fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend dropped /internal/ from all FME v4 endpoint paths (/fme/internal/api/v4/... -> /fme/api/v4/...); update all commands and regression tests accordingly. Live end-to-end testing against qa.harness.io surfaced two further bugs: - buildctx.go's required-flag check called cmdctx.GetString on every flag regardless of type, but is_array/is_multi flags are stored as []string in FlagValues, so GetString always returned "" and any required+is_array (or is_multi) flag was permanently rejected as missing. Only segment:definition_keys_remove's --keys combined required+is_array, so this was a latent, previously-unexercised engine bug. Fixed the check to branch on flag type, with a regression test. - get/create/update/kill/restore/reallocate feature_flag:definition and get/create/update segment:definition rendered blank fields (Name, Description, Traffic Type, ...) because they fell back to the base feature_flag/segment noun fields, which don't apply to a definition's shape. Added dedicated feature_flag_definition and segment_definition nouns (mirroring the existing segment_definition_keys pattern) and wired them via fields_noun. Also added fields_noun to segment:definition_keys_add/_remove, which had the same issue, and fixed update feature_flag:definition's --set traffic_allocation (was undocumented/unmapped, causing "unknown or read-only field"). update segment:definition still returns an intermittent 500 from the backend on a minimal, correct merge-patch body — confirmed backend-side, no CLI/spec change addresses it. AI-Session-Id: 20b23a2d-5d3f-4d25-9879-167a341599d4 AI-Tool: claude-code AI-Model: unknown --- pkg/registry/buildctx.go | 12 +- pkg/registry/buildctx_workflow_test.go | 31 +++++ pkg/spec/fme.spec.yaml | 154 ++++++++++++++----------- pkg/specloader/fme_spec_test.go | 32 ++--- 4 files changed, 146 insertions(+), 83 deletions(-) diff --git a/pkg/registry/buildctx.go b/pkg/registry/buildctx.go index df4cd4b..19fe515 100644 --- a/pkg/registry/buildctx.go +++ b/pkg/registry/buildctx.go @@ -277,7 +277,17 @@ func buildCtx(cmd *cobra.Command, cs *spec.CommandSpec, args []string, r *Regist return nil, err } for _, f := range cs.Flags { - if f.Required && cmdctx.GetString(ctx.FlagValues, f.Name) == "" { + if !f.Required { + continue + } + var missing bool + switch { + case f.IsArray || f.IsMulti: + missing = len(cmdctx.GetStringSlice(ctx.FlagValues, f.Name)) == 0 + default: + missing = cmdctx.GetString(ctx.FlagValues, f.Name) == "" + } + if missing { if len(f.CompletionValues) > 0 { return nil, fmt.Errorf("flag --%s is required (%s)", f.Name, strings.Join(f.CompletionValues, ", ")) } diff --git a/pkg/registry/buildctx_workflow_test.go b/pkg/registry/buildctx_workflow_test.go index 69650d7..5ca7a61 100644 --- a/pkg/registry/buildctx_workflow_test.go +++ b/pkg/registry/buildctx_workflow_test.go @@ -159,6 +159,37 @@ func TestBuildCtx_WorkflowRequiredFlag(t *testing.T) { } } +func TestBuildCtx_WorkflowRequiredArrayFlag(t *testing.T) { + r := New() + registerWorkflowExecute(t, r, "reqarrayflag", &spec.CommandSpec{ + Flags: []spec.Flag{ + {Name: "keys", Required: true, IsArray: true, Description: "keys to remove"}, + }, + }) + cs := r.GetSpec(VerbExecute, "reqarrayflag") + + t.Run("missing", func(t *testing.T) { + cmd := buildWorkflowTestCmd(t, r, cs) + _, err := buildCtx(cmd, cs, []string{"my-id"}, r) + if err == nil { + t.Fatal("buildCtx() = nil, want error") + } + if !strings.Contains(err.Error(), "flag --keys is required") { + t.Fatalf("buildCtx() error %q missing expected substring", err) + } + }) + + t.Run("provided", func(t *testing.T) { + cmd := buildWorkflowTestCmd(t, r, cs) + if err := cmd.ParseFlags([]string{"--keys", "key-one,key-two"}); err != nil { + t.Fatalf("ParseFlags: %v", err) + } + if _, err := buildCtx(cmd, cs, []string{"my-id"}, r); err != nil { + t.Fatalf("buildCtx() = %v, want no error", err) + } + }) +} + func TestBuildCtx_WorkflowIdPartsTooMany(t *testing.T) { r := New() registerWorkflowExecute(t, r, "cluster", &spec.CommandSpec{ diff --git a/pkg/spec/fme.spec.yaml b/pkg/spec/fme.spec.yaml index 447676a..c60dd0f 100644 --- a/pkg/spec/fme.spec.yaml +++ b/pkg/spec/fme.spec.yaml @@ -70,6 +70,42 @@ nouns: - id: key expr: it + - noun: feature_flag_definition + short_desc: A feature flag's environment-specific targeting definition (used for field lookup only). + fields: + - id: environment + expr: it.environment.name + - id: default_treatment + label: Default Treatment + expr: it.defaultTreatment + - id: traffic_allocation + label: Traffic Allocation + expr: string(it.trafficAllocation) + mutable_path: trafficAllocation + - id: is_killed + label: Killed + expr: string(it.isKilled) + - id: created + expr: epochMs(it.createdAt * 1000) + field_type: ts + + - noun: segment_definition + short_desc: A segment's environment-specific targeting definition (used for field lookup only). + fields: + - id: segment + expr: it.segment.name + - id: environment + expr: it.environment.name + - id: description + expr: it.description ?? "" + mutable_path: description + width_max: 60 + - id: status + expr: it.status + - id: created + expr: epochMs(it.createdAt * 1000) + field_type: ts + commands: # ── feature_flag ───────────────────────────────────────────────────────────── @@ -85,7 +121,7 @@ commands: description: "Filter by status: ACTIVE, ARCHIVED" completion_values: [ACTIVE, ARCHIVED] endpoint: - path: /fme/internal/api/v4/feature-flags + path: /fme/api/v4/feature-flags items_expr: it.data item_item_expr: it get_id_expr: it.name @@ -111,7 +147,7 @@ commands: short: Get feature flag details handler_type: endpoint endpoint: - path: /fme/internal/api/v4/feature-flags/{{ctx.id}} + path: /fme/api/v4/feature-flags/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org @@ -132,7 +168,7 @@ commands: set: true endpoint: method: POST - path: /fme/internal/api/v4/feature-flags + path: /fme/api/v4/feature-flags query_params: account_id: auth.account organization_identifier: auth.org @@ -155,8 +191,8 @@ commands: del: true endpoint: method: PATCH - path: /fme/internal/api/v4/feature-flags/{{ctx.id}} - get_path: /fme/internal/api/v4/feature-flags/{{ctx.id}} + path: /fme/api/v4/feature-flags/{{ctx.id}} + get_path: /fme/api/v4/feature-flags/{{ctx.id}} update_strategy: get-then-patch update_body_pick: it update_body_wrap: "" @@ -175,7 +211,7 @@ commands: handler_type: endpoint endpoint: method: DELETE - path: /fme/internal/api/v4/feature-flags/{{ctx.id}} + path: /fme/api/v4/feature-flags/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org @@ -192,7 +228,7 @@ commands: description: Audit comment endpoint: method: POST - path: /fme/internal/api/v4/feature-flags/{{ctx.id}}/archive + path: /fme/api/v4/feature-flags/{{ctx.id}}/archive query_params: account_id: auth.account organization_identifier: auth.org @@ -213,7 +249,7 @@ commands: description: Audit comment endpoint: method: POST - path: /fme/internal/api/v4/feature-flags/{{ctx.id}}/unarchive + path: /fme/api/v4/feature-flags/{{ctx.id}}/unarchive query_params: account_id: auth.account organization_identifier: auth.org @@ -233,8 +269,9 @@ commands: handler_type: endpoint requires_parentid: true parentid_label: "" + fields_noun: feature_flag_definition endpoint: - path: /fme/internal/api/v4/feature-flag-definitions + path: /fme/api/v4/feature-flag-definitions items_expr: it.data item_item_expr: it get_id_expr: it.environment.id @@ -251,21 +288,6 @@ commands: page_size_max: 100 total_expr: it.totalCount countable: true - fields_extra: - - id: environment - expr: it.environment.name - - id: default_treatment - label: Default Treatment - expr: it.defaultTreatment - - id: traffic_allocation - label: Traffic Allocation - expr: string(it.trafficAllocation) - - id: is_killed - label: Killed - expr: string(it.isKilled) - - id: created - expr: epochMs(it.createdAt * 1000) - field_type: ts columns: [environment, default_treatment, traffic_allocation, is_killed, created] - command: get feature_flag:definition @@ -274,12 +296,13 @@ commands: noun_variant: definition short: "Get a flag definition for a specific environment: harness get ff:definition --env " handler_type: endpoint + fields_noun: feature_flag_definition flags: - name: env description: Environment identifier (required) required: true endpoint: - path: /fme/internal/api/v4/feature-flag-definitions/{{ctx.id}} + path: /fme/api/v4/feature-flag-definitions/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org @@ -294,6 +317,7 @@ commands: noun_variant: definition short: "Create a flag definition in an environment: harness create ff:definition --env -f def.json" handler_type: endpoint + fields_noun: feature_flag_definition flags: - name: env description: Environment identifier (required) @@ -301,7 +325,7 @@ commands: endpoint: method: POST file_body: required - path: /fme/internal/api/v4/feature-flag-definitions/{{ctx.id}} + path: /fme/api/v4/feature-flag-definitions/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org @@ -316,6 +340,7 @@ commands: noun_variant: definition short: "Update a flag definition: harness update ff:definition --env --set trafficAllocation=80" handler_type: endpoint + fields_noun: feature_flag_definition flags: - name: env description: Environment identifier (required) @@ -325,8 +350,8 @@ commands: del: true endpoint: method: PATCH - path: /fme/internal/api/v4/feature-flag-definitions/{{ctx.id}} - get_path: /fme/internal/api/v4/feature-flag-definitions/{{ctx.id}} + path: /fme/api/v4/feature-flag-definitions/{{ctx.id}} + get_path: /fme/api/v4/feature-flag-definitions/{{ctx.id}} update_strategy: get-then-patch update_body_pick: it update_body_wrap: "" @@ -351,7 +376,7 @@ commands: required: true endpoint: method: DELETE - path: /fme/internal/api/v4/feature-flag-definitions/{{ctx.id}} + path: /fme/api/v4/feature-flag-definitions/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org @@ -364,6 +389,7 @@ commands: noun_variant: kill short: "Kill a flag in an environment (all traffic → defaultTreatment): harness execute ff:kill --env " handler_type: endpoint + fields_noun: feature_flag_definition flags: - name: env description: Environment identifier (required) @@ -372,7 +398,7 @@ commands: description: Audit comment endpoint: method: POST - path: /fme/internal/api/v4/feature-flag-definitions/{{ctx.id}}/kill + path: /fme/api/v4/feature-flag-definitions/{{ctx.id}}/kill query_params: account_id: auth.account organization_identifier: auth.org @@ -389,6 +415,7 @@ commands: noun_variant: restore short: Restore a killed flag definition handler_type: endpoint + fields_noun: feature_flag_definition flags: - name: env description: Environment identifier (required) @@ -397,7 +424,7 @@ commands: description: Audit comment endpoint: method: POST - path: /fme/internal/api/v4/feature-flag-definitions/{{ctx.id}}/restore + path: /fme/api/v4/feature-flag-definitions/{{ctx.id}}/restore query_params: account_id: auth.account organization_identifier: auth.org @@ -414,6 +441,7 @@ commands: noun_variant: reallocate short: Re-hash traffic distribution for a flag definition handler_type: endpoint + fields_noun: feature_flag_definition flags: - name: env description: Environment identifier (required) @@ -422,7 +450,7 @@ commands: description: Audit comment endpoint: method: POST - path: /fme/internal/api/v4/feature-flag-definitions/{{ctx.id}}/reallocate + path: /fme/api/v4/feature-flag-definitions/{{ctx.id}}/reallocate query_params: account_id: auth.account organization_identifier: auth.org @@ -443,7 +471,7 @@ commands: short: List FME environments handler_type: endpoint endpoint: - path: /fme/internal/api/v4/environments + path: /fme/api/v4/environments items_expr: it.data item_item_expr: it get_id_expr: it.id @@ -467,7 +495,7 @@ commands: short: "Get FME environment details: harness get fme_environment " handler_type: endpoint endpoint: - path: /fme/internal/api/v4/environments/{{ctx.id}} + path: /fme/api/v4/environments/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org @@ -488,7 +516,7 @@ commands: set: true endpoint: method: POST - path: /fme/internal/api/v4/environments + path: /fme/api/v4/environments query_params: account_id: auth.account organization_identifier: auth.org @@ -511,8 +539,8 @@ commands: del: true endpoint: method: PATCH - path: /fme/internal/api/v4/environments/{{ctx.id}} - get_path: /fme/internal/api/v4/environments/{{ctx.id}} + path: /fme/api/v4/environments/{{ctx.id}} + get_path: /fme/api/v4/environments/{{ctx.id}} update_strategy: get-then-patch update_body_pick: it update_body_wrap: "" @@ -531,7 +559,7 @@ commands: handler_type: endpoint endpoint: method: DELETE - path: /fme/internal/api/v4/environments/{{ctx.id}} + path: /fme/api/v4/environments/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org @@ -545,7 +573,7 @@ commands: short: List segments handler_type: endpoint endpoint: - path: /fme/internal/api/v4/segments + path: /fme/api/v4/segments items_expr: it.data item_item_expr: it get_id_expr: it.name @@ -569,7 +597,7 @@ commands: short: "Get segment details: harness get segment " handler_type: endpoint endpoint: - path: /fme/internal/api/v4/segments/{{ctx.id}} + path: /fme/api/v4/segments/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org @@ -592,7 +620,7 @@ commands: set: true endpoint: method: POST - path: /fme/internal/api/v4/segments + path: /fme/api/v4/segments query_params: account_id: auth.account organization_identifier: auth.org @@ -616,8 +644,8 @@ commands: del: true endpoint: method: PATCH - path: /fme/internal/api/v4/segments/{{ctx.id}} - get_path: /fme/internal/api/v4/segments/{{ctx.id}} + path: /fme/api/v4/segments/{{ctx.id}} + get_path: /fme/api/v4/segments/{{ctx.id}} update_strategy: get-then-patch update_body_pick: it update_body_wrap: "" @@ -636,7 +664,7 @@ commands: handler_type: endpoint endpoint: method: DELETE - path: /fme/internal/api/v4/segments/{{ctx.id}} + path: /fme/api/v4/segments/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org @@ -650,12 +678,13 @@ commands: noun_variant: definition short: "List segment definitions in an environment: harness list segment:definition --env " handler_type: endpoint + fields_noun: segment_definition flags: - name: env description: Environment identifier (required) required: true endpoint: - path: /fme/internal/api/v4/segment-definitions + path: /fme/api/v4/segment-definitions items_expr: it.data item_item_expr: it get_id_expr: it.segment.name @@ -672,18 +701,6 @@ commands: page_size_max: 100 total_expr: it.totalCount countable: true - fields_extra: - - id: segment - expr: it.segment.name - - id: environment - expr: it.environment.name - - id: description - expr: it.description ?? "" - - id: status - expr: it.status - - id: created - expr: epochMs(it.createdAt * 1000) - field_type: ts columns: [segment, environment, description, status, created] - command: get segment:definition @@ -692,12 +709,13 @@ commands: noun_variant: definition short: "Get a segment definition: harness get segment:definition --env " handler_type: endpoint + fields_noun: segment_definition flags: - name: env description: Environment identifier (required) required: true endpoint: - path: /fme/internal/api/v4/segment-definitions/{{ctx.id}} + path: /fme/api/v4/segment-definitions/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org @@ -712,6 +730,7 @@ commands: noun_variant: definition short: "Create a segment definition in an environment: harness create segment:definition --env [--description desc]" handler_type: endpoint + fields_noun: segment_definition flags: - name: env description: Environment identifier (required) @@ -720,7 +739,7 @@ commands: description: Definition description endpoint: method: POST - path: /fme/internal/api/v4/segment-definitions/{{ctx.id}} + path: /fme/api/v4/segment-definitions/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org @@ -737,6 +756,7 @@ commands: noun_variant: definition short: "Update a segment definition: harness update segment:definition --env --set description=foo" handler_type: endpoint + fields_noun: segment_definition flags: - name: env description: Environment identifier (required) @@ -746,10 +766,10 @@ commands: del: true endpoint: method: PATCH - path: /fme/internal/api/v4/segment-definitions/{{ctx.id}} - get_path: /fme/internal/api/v4/segment-definitions/{{ctx.id}} + path: /fme/api/v4/segment-definitions/{{ctx.id}} + get_path: /fme/api/v4/segment-definitions/{{ctx.id}} update_strategy: get-then-patch - update_body_pick: it + update_body_pick: "{description: it.description}" update_body_wrap: "" query_params: account_id: auth.account @@ -772,7 +792,7 @@ commands: required: true endpoint: method: DELETE - path: /fme/internal/api/v4/segment-definitions/{{ctx.id}} + path: /fme/api/v4/segment-definitions/{{ctx.id}} query_params: account_id: auth.account organization_identifier: auth.org @@ -795,7 +815,7 @@ commands: description: Environment identifier (required) required: true endpoint: - path: /fme/internal/api/v4/segment-definitions/{{ctx.parentId}}/keys + path: /fme/api/v4/segment-definitions/{{ctx.parentId}}/keys items_expr: it.data item_item_expr: it get_id_expr: "-" @@ -820,6 +840,7 @@ commands: noun_variant: definition_keys_add short: "Add (or replace) targeting keys in a segment definition: harness execute segment:definition_keys_add --env --keys k1,k2 [--replace]" handler_type: endpoint + fields_noun: segment_definition_keys flags: - name: env description: Environment identifier (required) @@ -836,7 +857,7 @@ commands: description: Audit change title endpoint: method: POST - path: /fme/internal/api/v4/segment-definitions/{{ctx.id}}/keys + path: /fme/api/v4/segment-definitions/{{ctx.id}}/keys query_params: account_id: auth.account organization_identifier: auth.org @@ -856,6 +877,7 @@ commands: noun_variant: definition_keys_remove short: "Remove targeting keys from a segment definition: harness execute segment:definition_keys_remove --env --keys k1,k2" handler_type: endpoint + fields_noun: segment_definition_keys flags: - name: env description: Environment identifier (required) @@ -870,7 +892,7 @@ commands: description: Audit change title endpoint: method: POST - path: /fme/internal/api/v4/segment-definitions/{{ctx.id}}/keys/remove + path: /fme/api/v4/segment-definitions/{{ctx.id}}/keys/remove query_params: account_id: auth.account organization_identifier: auth.org diff --git a/pkg/specloader/fme_spec_test.go b/pkg/specloader/fme_spec_test.go index 637975f..b17f3b0 100644 --- a/pkg/specloader/fme_spec_test.go +++ b/pkg/specloader/fme_spec_test.go @@ -74,7 +74,7 @@ func fmeReadOut(t *testing.T, ctx *cmdctx.Ctx) string { // TestFMESpec_ListFeatureFlag drives the real embedded fme.spec.yaml "list feature_flag" // command against a mock server returning the flat (no "entity" wrapper) shape that the -// live FME v4 API returns, and asserts the request hits /fme/internal/api/v4/feature-flags +// live FME v4 API returns, and asserts the request hits /fme/api/v4/feature-flags // and that fields resolve directly off the item (it.name, it.trafficType.name, ...). func TestFMESpec_ListFeatureFlag(t *testing.T) { reg := registry.New() @@ -98,8 +98,8 @@ func TestFMESpec_ListFeatureFlag(t *testing.T) { t.Fatalf("RunListEndpoint: %v", err) } - if !strings.HasPrefix(*path, "/fme/internal/api/v4/feature-flags") { - t.Fatalf("request path = %q, want prefix /fme/internal/api/v4/feature-flags", *path) + if !strings.HasPrefix(*path, "/fme/api/v4/feature-flags") { + t.Fatalf("request path = %q, want prefix /fme/api/v4/feature-flags", *path) } body := fmeReadOut(t, ctx) @@ -136,8 +136,8 @@ func TestFMESpec_GetFeatureFlag(t *testing.T) { t.Fatalf("RunEndpoint: %v", err) } - if *path != "/fme/internal/api/v4/feature-flags/my-flag" { - t.Fatalf("request path = %q, want /fme/internal/api/v4/feature-flags/my-flag", *path) + if *path != "/fme/api/v4/feature-flags/my-flag" { + t.Fatalf("request path = %q, want /fme/api/v4/feature-flags/my-flag", *path) } body := fmeReadOut(t, ctx) @@ -152,7 +152,7 @@ func TestFMESpec_GetFeatureFlag(t *testing.T) { } // TestFMESpec_ListFMEEnvironment drives "list fme_environment" and asserts it -// hits /fme/internal/api/v4/environments and that get_id_expr resolves off +// hits /fme/api/v4/environments and that get_id_expr resolves off // it.id (environments are addressed by UUID, not name, unlike segment/feature_flag). func TestFMESpec_ListFMEEnvironment(t *testing.T) { reg := registry.New() @@ -176,8 +176,8 @@ func TestFMESpec_ListFMEEnvironment(t *testing.T) { t.Fatalf("RunListEndpoint: %v", err) } - if !strings.HasPrefix(*path, "/fme/internal/api/v4/environments") { - t.Fatalf("request path = %q, want prefix /fme/internal/api/v4/environments", *path) + if !strings.HasPrefix(*path, "/fme/api/v4/environments") { + t.Fatalf("request path = %q, want prefix /fme/api/v4/environments", *path) } body := fmeReadOut(t, ctx) @@ -213,8 +213,8 @@ func TestFMESpec_GetFMEEnvironment(t *testing.T) { t.Fatalf("RunEndpoint: %v", err) } - if *path != "/fme/internal/api/v4/environments/env-uuid-1" { - t.Fatalf("request path = %q, want /fme/internal/api/v4/environments/env-uuid-1", *path) + if *path != "/fme/api/v4/environments/env-uuid-1" { + t.Fatalf("request path = %q, want /fme/api/v4/environments/env-uuid-1", *path) } } @@ -242,8 +242,8 @@ func TestFMESpec_ListSegment(t *testing.T) { t.Fatalf("RunListEndpoint: %v", err) } - if !strings.HasPrefix(*path, "/fme/internal/api/v4/segments") { - t.Fatalf("request path = %q, want prefix /fme/internal/api/v4/segments", *path) + if !strings.HasPrefix(*path, "/fme/api/v4/segments") { + t.Fatalf("request path = %q, want prefix /fme/api/v4/segments", *path) } body := fmeReadOut(t, ctx) @@ -280,8 +280,8 @@ func TestFMESpec_ListSegmentDefinition(t *testing.T) { t.Fatalf("RunListEndpoint: %v", err) } - if !strings.HasPrefix(*path, "/fme/internal/api/v4/segment-definitions") { - t.Fatalf("request path = %q, want prefix /fme/internal/api/v4/segment-definitions", *path) + if !strings.HasPrefix(*path, "/fme/api/v4/segment-definitions") { + t.Fatalf("request path = %q, want prefix /fme/api/v4/segment-definitions", *path) } if !strings.Contains(*query, "environment_id=env-uuid-1") { t.Fatalf("query = %q, want environment_id=env-uuid-1 (from --env flag)", *query) @@ -323,8 +323,8 @@ func TestFMESpec_ListSegmentDefinitionKeys(t *testing.T) { t.Fatalf("RunListEndpoint: %v", err) } - if *path != "/fme/internal/api/v4/segment-definitions/my-segment/keys" { - t.Fatalf("request path = %q, want /fme/internal/api/v4/segment-definitions/my-segment/keys", *path) + if *path != "/fme/api/v4/segment-definitions/my-segment/keys" { + t.Fatalf("request path = %q, want /fme/api/v4/segment-definitions/my-segment/keys", *path) } if !strings.Contains(*query, "environment_id=env-uuid-1") { t.Fatalf("query = %q, want environment_id=env-uuid-1 (from --env flag)", *query)