[object Object]

← back to Cli Printing Press

feat(cli): enum sync expansion and generic API prefix stripping (#118)

34e354f6a8d070d98338607e94220722530b4cce · 2026-04-03 17:05:43 -0700 · Trevin Chow

* feat(cli): enum sync expansion and generic API prefix stripping

When a paginated list endpoint has a required enum query param (like
entityType=collection|workspace|api|flow), the profiler now expands
each enum value into a separate sync resource. This produces correct
defaultSyncResources() output (5 resources instead of 1) without
manual intervention.

Strips generic API routing prefixes ("api", "apis", "rest") from path
segments when followed by deeper segments. /v1/api/networkentity now
produces resource name "networkentity" instead of "api", eliminating
naming collisions when multiple endpoints share /v1/api/*.

Verified by regenerating postman-explore-pp-cli:
- defaultSyncResources returns [api, collection, flow, team, workspace]
- syncResourcePath correctly maps each to its API path with enum param
- Resource commands use distinct names: category, networkentity, team

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

* fix(cli): address Codex review findings on enum expansion and prefix stripping

P1: Enum-expanded sync paths now always overwrite colliding resource
names, ensuring deterministic output regardless of Go map iteration
order.

P2a: Removed overly aggressive fallback in findEntityTypeEnum that
treated any single required enum param as entity-type expansion.
Now requires the param name to contain type/kind/entity/resource/
category — filters like status=open|closed no longer trigger.

P2b: Generic API prefix stripping now requires the next segment to
be a concrete resource name, not a path param. /api/{id} correctly
keeps "api" as the resource name.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 34e354f6a8d070d98338607e94220722530b4cce
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Fri Apr 3 17:05:43 2026 -0700

    feat(cli): enum sync expansion and generic API prefix stripping (#118)
    
    * feat(cli): enum sync expansion and generic API prefix stripping
    
    When a paginated list endpoint has a required enum query param (like
    entityType=collection|workspace|api|flow), the profiler now expands
    each enum value into a separate sync resource. This produces correct
    defaultSyncResources() output (5 resources instead of 1) without
    manual intervention.
    
    Strips generic API routing prefixes ("api", "apis", "rest") from path
    segments when followed by deeper segments. /v1/api/networkentity now
    produces resource name "networkentity" instead of "api", eliminating
    naming collisions when multiple endpoints share /v1/api/*.
    
    Verified by regenerating postman-explore-pp-cli:
    - defaultSyncResources returns [api, collection, flow, team, workspace]
    - syncResourcePath correctly maps each to its API path with enum param
    - Resource commands use distinct names: category, networkentity, team
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    * fix(cli): address Codex review findings on enum expansion and prefix stripping
    
    P1: Enum-expanded sync paths now always overwrite colliding resource
    names, ensuring deterministic output regardless of Go map iteration
    order.
    
    P2a: Removed overly aggressive fallback in findEntityTypeEnum that
    treated any single required enum param as entity-type expansion.
    Now requires the param name to contain type/kind/entity/resource/
    category — filters like status=open|closed no longer trigger.
    
    P2b: Generic API prefix stripping now requires the next segment to
    be a concrete resource name, not a path param. /api/{id} correctly
    keeps "api" as the resource name.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
    
    ---------
    
    Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 internal/openapi/parser.go         | 21 +++++++++
 internal/openapi/parser_test.go    | 26 +++++++++++
 internal/profiler/profiler.go      | 46 ++++++++++++++++---
 internal/profiler/profiler_test.go | 90 ++++++++++++++++++++++++++++++++++++++
 4 files changed, 178 insertions(+), 5 deletions(-)

diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index d6bac32b..87a372e8 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -1708,6 +1708,15 @@ func pathSegmentsAfterBase(path, basePath string) []string {
 		segments = segments[1:]
 	}
 
+	// Strip generic API routing prefixes when followed by a concrete resource
+	// segment. "api", "apis", "rest" as the first segment after version are
+	// routing infrastructure, not resource names. /v1/api/users → "users".
+	// Do NOT strip when the next segment is a path param ({id}), because
+	// /api/{id} means "api" IS the resource.
+	if len(segments) >= 2 && isGenericAPIPrefix(segments[0]) && !isPathParamSegment(segments[1]) {
+		segments = segments[1:]
+	}
+
 	return segments
 }
 
@@ -1727,6 +1736,18 @@ func splitPath(path string) []string {
 	return segments
 }
 
+// isGenericAPIPrefix returns true if a path segment is a routing-only prefix
+// that should be stripped when extracting resource names. These segments are
+// API infrastructure ("api", "rest") rather than domain resources.
+func isGenericAPIPrefix(segment string) bool {
+	switch strings.ToLower(segment) {
+	case "api", "apis", "rest":
+		return true
+	default:
+		return false
+	}
+}
+
 func isVersionSegment(segment string) bool {
 	if len(segment) < 2 || segment[0] != 'v' {
 		return false
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 1d64bca3..abe15d33 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -233,6 +233,32 @@ func TestSanitizeResourceName(t *testing.T) {
 	}
 }
 
+func TestPathSegmentsStripsGenericAPIPrefix(t *testing.T) {
+	tests := []struct {
+		name      string
+		path      string
+		basePath  string
+		wantFirst string
+	}{
+		{"strips api prefix", "/v1/api/users", "", "users"},
+		{"strips apis prefix", "/v2/apis/teams", "", "teams"},
+		{"strips rest prefix", "/rest/orders", "", "orders"},
+		{"keeps non-generic prefix", "/v1/billing/invoices", "", "billing"},
+		{"keeps api when no sub-segments", "/api", "", "api"},
+		{"keeps api when followed by path param", "/api/{id}", "", "api"},
+		{"keeps rest when followed by path param", "/rest/{job_id}/runs", "", "rest"},
+		{"strips version then api", "/v1/api/networkentity", "", "networkentity"},
+	}
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			segments := pathSegmentsAfterBase(tt.path, tt.basePath)
+			if len(segments) > 0 {
+				assert.Equal(t, tt.wantFirst, segments[0])
+			}
+		})
+	}
+}
+
 func TestOperationIDToName(t *testing.T) {
 	tests := []struct {
 		operationID  string
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
index bd46a300..8a765a91 100644
--- a/internal/profiler/profiler.go
+++ b/internal/profiler/profiler.go
@@ -144,11 +144,27 @@ func Profile(s *spec.APISpec) *APIProfile {
 				listResources[resourceName] = struct{}{}
 				if endpoint.Pagination != nil {
 					p.ListEndpoints++
-					// Pick the shortest path for determinism when multiple
-					// paginated list endpoints exist for the same resource.
-					// Shorter paths are typically the primary list endpoint.
-					if existing, ok := syncable[resourceName]; !ok || len(endpoint.Path) < len(existing) {
-						syncable[resourceName] = endpoint.Path
+
+					// Check for enum-parameterized list endpoints: when a required
+					// query param has enum values, each value represents a distinct
+					// entity type that should sync independently. Example:
+					// GET /v1/api/networkentity?entityType=collection|workspace|api|flow
+					// → sync resources: collection, workspace, api, flow
+					if enumParam := findEntityTypeEnum(endpoint); enumParam != nil && len(enumParam.Enum) >= 2 {
+						for _, val := range enumParam.Enum {
+							expandedName := strings.ToLower(val)
+							expandedPath := endpoint.Path + "?" + enumParam.Name + "=" + val
+							// Enum-expanded paths are more specific than generic resource
+							// paths, so they always win on name collision. This ensures
+							// deterministic output regardless of Go map iteration order.
+							syncable[expandedName] = expandedPath
+						}
+					} else {
+						// Standard: pick the shortest path for determinism when
+						// multiple paginated list endpoints exist for the same resource.
+						if existing, ok := syncable[resourceName]; !ok || len(endpoint.Path) < len(existing) {
+							syncable[resourceName] = endpoint.Path
+						}
 					}
 				}
 			}
@@ -441,6 +457,26 @@ func isListEndpoint(name string, endpoint spec.Endpoint) bool {
 	return containsAny(name, []string{"list", "all"})
 }
 
+// findEntityTypeEnum returns the first required enum query param on a list endpoint
+// that looks like an entity type selector. Heuristics:
+// 1. Param is required with 2+ enum values
+// 2. Param name contains "type", "kind", "entity", "resource", or "category"
+// Returns nil if no qualifying param is found. Does NOT fall back to arbitrary
+// enum params — filters like status=open|closed should not trigger expansion.
+func findEntityTypeEnum(endpoint spec.Endpoint) *spec.Param {
+	for i := range endpoint.Params {
+		p := &endpoint.Params[i]
+		if len(p.Enum) < 2 || p.PathParam || !p.Required {
+			continue
+		}
+		nameLower := strings.ToLower(p.Name)
+		if containsAny(nameLower, []string{"type", "kind", "entity", "resource", "category"}) {
+			return p
+		}
+	}
+	return nil
+}
+
 func hasLifecycleField(params []spec.Param) bool {
 	for _, param := range params {
 		if isLifecycleParam(param) {
diff --git a/internal/profiler/profiler_test.go b/internal/profiler/profiler_test.go
index b2746470..50183ae8 100644
--- a/internal/profiler/profiler_test.go
+++ b/internal/profiler/profiler_test.go
@@ -46,6 +46,96 @@ func TestProfileMinimal(t *testing.T) {
 	assert.Equal(t, []string{"export", "import"}, profile.RecommendedFeatures())
 }
 
+func TestProfileEnumExpansion(t *testing.T) {
+	// Simulates the postman-explore pattern: one list endpoint serves multiple
+	// entity types via an enum query param (entityType=collection|workspace|api|flow).
+	// The profiler should expand this into separate sync resources.
+	// Uses distinct resource names to test enum expansion independently of naming.
+	s := &spec.APISpec{
+		Name: "postman-explore",
+		Resources: map[string]spec.Resource{
+			"networkentity": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method: "GET",
+						Path:   "/v1/api/networkentity",
+						Params: []spec.Param{
+							{
+								Name:     "entityType",
+								Type:     "string",
+								Required: true,
+								Enum:     []string{"collection", "workspace", "api", "flow"},
+							},
+							{Name: "limit", Type: "integer"},
+							{Name: "offset", Type: "integer"},
+						},
+						Pagination: &spec.Pagination{
+							CursorParam: "offset",
+							LimitParam:  "limit",
+						},
+						Response: spec.ResponseDef{Type: "array"},
+					},
+				},
+			},
+			"team": {
+				Endpoints: map[string]spec.Endpoint{
+					"list": {
+						Method: "GET",
+						Path:   "/v1/api/team",
+						Params: []spec.Param{
+							{Name: "limit", Type: "integer"},
+						},
+						Pagination: &spec.Pagination{
+							CursorParam: "offset",
+							LimitParam:  "limit",
+						},
+						Response: spec.ResponseDef{Type: "array"},
+					},
+				},
+			},
+		},
+	}
+
+	profile := Profile(s)
+
+	syncNames := make([]string, len(profile.SyncableResources))
+	syncPaths := make(map[string]string)
+	for i, sr := range profile.SyncableResources {
+		syncNames[i] = sr.Name
+		syncPaths[sr.Name] = sr.Path
+	}
+
+	// 5 resources: 4 from enum expansion + 1 from teams
+	assert.Len(t, profile.SyncableResources, 5)
+	assert.Contains(t, syncNames, "collection")
+	assert.Contains(t, syncNames, "workspace")
+	assert.Contains(t, syncNames, "api")
+	assert.Contains(t, syncNames, "flow")
+	assert.Contains(t, syncNames, "team")
+
+	// Expanded paths include the enum value as a query param
+	assert.Equal(t, "/v1/api/networkentity?entityType=collection", syncPaths["collection"])
+	assert.Equal(t, "/v1/api/networkentity?entityType=workspace", syncPaths["workspace"])
+	assert.Equal(t, "/v1/api/networkentity?entityType=api", syncPaths["api"])
+	// Teams endpoint keeps its own resource
+	assert.Equal(t, "/v1/api/team", syncPaths["team"])
+}
+
+func TestProfileEnumExpansion_NoExpansionForNonEnum(t *testing.T) {
+	// Standard API without enum params should not be affected
+	profile := Profile(petstoreSpec())
+
+	syncNames := make([]string, len(profile.SyncableResources))
+	for i, sr := range profile.SyncableResources {
+		syncNames[i] = sr.Name
+	}
+
+	// Petstore has no enum query params — should NOT expand
+	assert.NotContains(t, syncNames, "available")
+	assert.NotContains(t, syncNames, "pending")
+	assert.NotContains(t, syncNames, "sold")
+}
+
 func TestToVisionaryPlan(t *testing.T) {
 	profile := Profile(discordSpec())
 	plan := profile.ToVisionaryPlan("discord")

← c29604c0 fix(cli): fix extractKeyURL known-platform check and publish  ·  back to Cli Printing Press  ·  fix(skills): publish skill specifies full PR URL format ff465aac →