[object Object]

← back to Cli Printing Press

fix(cli): sync sibling list endpoints (#756)

745f2e75f2bc9aff5079ca6de3f232930341c28e · 2026-05-08 17:32:56 -0700 · Trevin Chow

Files touched

Diff

commit 745f2e75f2bc9aff5079ca6de3f232930341c28e
Author: Trevin Chow <trevin@trevinchow.com>
Date:   Fri May 8 17:32:56 2026 -0700

    fix(cli): sync sibling list endpoints (#756)
---
 internal/generator/generator_test.go |  53 +++++++++++++++
 internal/profiler/profiler.go        | 127 +++++++++++++++++++++++++++++------
 internal/profiler/profiler_test.go   |  41 +++++++++++
 3 files changed, 202 insertions(+), 19 deletions(-)

diff --git a/internal/generator/generator_test.go b/internal/generator/generator_test.go
index 7f6277cb..9db7f1cb 100644
--- a/internal/generator/generator_test.go
+++ b/internal/generator/generator_test.go
@@ -6186,6 +6186,59 @@ func TestGenerateSyncRejectsUnknownResourcePath(t *testing.T) {
 		"sync must not request fake /<resource> paths")
 }
 
+func TestGenerateSyncIncludesSiblingListResources(t *testing.T) {
+	t.Parallel()
+
+	apiSpec := &spec.APISpec{
+		Name:    "trading",
+		Version: "0.1.0",
+		BaseURL: "https://api.example.com",
+		Auth:    spec.AuthConfig{Type: "none"},
+		Config: spec.ConfigSpec{
+			Format: "toml",
+			Path:   "~/.config/trading-pp-cli/config.toml",
+		},
+		Resources: map[string]spec.Resource{
+			"portfolio": {
+				Description: "Portfolio",
+				Endpoints: map[string]spec.Endpoint{
+					"fills": {
+						Method:      "GET",
+						Path:        "/portfolio/fills",
+						Description: "List fills",
+						Response:    spec.ResponseDef{Type: "array"},
+						Pagination:  &spec.Pagination{CursorParam: "cursor", LimitParam: "limit"},
+					},
+					"orders": {
+						Method:      "GET",
+						Path:        "/portfolio/orders",
+						Description: "List orders",
+						Response:    spec.ResponseDef{Type: "array"},
+						Pagination:  &spec.Pagination{CursorParam: "cursor", LimitParam: "limit"},
+					},
+					"settlements": {
+						Method:      "GET",
+						Path:        "/portfolio/settlements",
+						Description: "List settlements",
+						Response:    spec.ResponseDef{Type: "array"},
+						Pagination:  &spec.Pagination{CursorParam: "cursor", LimitParam: "limit"},
+					},
+				},
+			},
+		},
+	}
+
+	outputDir := filepath.Join(t.TempDir(), naming.CLI(apiSpec.Name))
+	require.NoError(t, New(apiSpec, outputDir).Generate())
+
+	syncGo, err := os.ReadFile(filepath.Join(outputDir, "internal", "cli", "sync.go"))
+	require.NoError(t, err)
+	syncContent := string(syncGo)
+	assert.Contains(t, syncContent, `"portfolio": "/portfolio/fills"`)
+	assert.Contains(t, syncContent, `"portfolio-orders": "/portfolio/orders"`)
+	assert.Contains(t, syncContent, `"portfolio-settlements": "/portfolio/settlements"`)
+}
+
 func TestGenerateGraphQLCompiles(t *testing.T) {
 	t.Parallel()
 
diff --git a/internal/profiler/profiler.go b/internal/profiler/profiler.go
index 6c65f11b..5c3843a7 100644
--- a/internal/profiler/profiler.go
+++ b/internal/profiler/profiler.go
@@ -168,6 +168,17 @@ func Profile(s *spec.APISpec) *APIProfile {
 
 	resourceNames, resourceNameIndex := collectResourceNameMetadata(s.Resources)
 	syncable := make(map[string]syncableMeta) // resource name -> chosen list endpoint metadata
+	syncCandidates := make(map[string][]syncableCandidate)
+	addSyncCandidate := func(resourceName string, meta syncableMeta) {
+		for _, candidate := range syncCandidates[resourceName] {
+			if candidate.meta.Path == meta.Path {
+				return
+			}
+		}
+		syncCandidates[resourceName] = append(syncCandidates[resourceName], syncableCandidate{
+			meta: meta,
+		})
+	}
 	// Keyed by "<parent>/<leaf>" so the same leaf under multiple parents
 	// survives instead of first-seen-wins.
 	parameterized := make(map[string]parameterizedEntry)
@@ -304,15 +315,7 @@ func Profile(s *spec.APISpec) *APIProfile {
 				listCapableGETs++
 				listResources[resourceName] = struct{}{}
 
-				// Add to syncable if the endpoint can be fetched without runtime context.
-				// Exclude: (a) paths with unfilled params like {steamid}
-				// (b) endpoints with required non-pagination query params (scoped lists
-				//     like GetFriendList?steamid=REQUIRED that need a parent ID)
-				if !strings.Contains(endpoint.Path, "{") && !hasRequiredScopeParams(endpoint) {
-					if existing, ok := syncable[resourceName]; !ok || len(endpoint.Path) < len(existing.Path) {
-						syncable[resourceName] = metaFromEndpoint(s, r, endpoint, s.Types, resourceNameIndex)
-					}
-				}
+				standaloneList := !strings.Contains(endpoint.Path, "{") && !hasRequiredScopeParams(endpoint)
 
 				if endpoint.Pagination != nil {
 					p.ListEndpoints++
@@ -323,6 +326,7 @@ func Profile(s *spec.APISpec) *APIProfile {
 					// 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 {
+						addSyncCandidate(resourceName, metaFromEndpoint(s, r, endpoint, s.Types, resourceNameIndex))
 						for _, val := range enumParam.Enum {
 							expandedName := strings.ToLower(val)
 							expandedPath := endpoint.Path + "?" + enumParam.Name + "=" + val
@@ -348,13 +352,11 @@ func Profile(s *spec.APISpec) *APIProfile {
 								meta:       metaFromEndpoint(s, r, endpoint, s.Types, resourceNameIndex),
 							}
 						}
-					} else {
-						// Paginated endpoints override the path set above — they have
-						// richer pagination support for full data retrieval.
-						if existing, ok := syncable[resourceName]; !ok || len(endpoint.Path) < len(existing.Path) {
-							syncable[resourceName] = metaFromEndpoint(s, r, endpoint, s.Types, resourceNameIndex)
-						}
+					} else if standaloneList {
+						addSyncCandidate(resourceName, metaFromEndpoint(s, r, endpoint, s.Types, resourceNameIndex))
 					}
+				} else if standaloneList {
+					addSyncCandidate(resourceName, metaFromEndpoint(s, r, endpoint, s.Types, resourceNameIndex))
 				}
 			} else if method == "GET" && !strings.Contains(endpoint.Path, "{") && !hasRequiredScopeParams(endpoint) && looksLikeCollectionEndpoint(endpointNameLower) {
 				// Catch-all for simple GET collection endpoints that isListEndpoint
@@ -362,9 +364,7 @@ func Profile(s *spec.APISpec) *APIProfile {
 				// wrapper field defined in the spec's types map).
 				// Only include endpoints whose name suggests a collection (list, all,
 				// index, etc.) — exclude singular getters like "get" or "show".
-				if existing, ok := syncable[resourceName]; !ok || len(endpoint.Path) < len(existing.Path) {
-					syncable[resourceName] = metaFromEndpoint(s, r, endpoint, s.Types, resourceNameIndex)
-				}
+				addSyncCandidate(resourceName, metaFromEndpoint(s, r, endpoint, s.Types, resourceNameIndex))
 			}
 
 			if endpoint.Pagination != nil {
@@ -431,6 +431,7 @@ func Profile(s *spec.APISpec) *APIProfile {
 	for name, resource := range s.Resources {
 		walk(name, resource, "", "")
 	}
+	applySyncCandidates(syncable, syncCandidates)
 
 	if p.TotalEndpoints > 0 {
 		p.ReadRatio = float64(getEndpoints) / float64(p.TotalEndpoints)
@@ -757,9 +758,11 @@ func findEntityTypeEnum(endpoint spec.Endpoint) *spec.Param {
 // the catch-all syncable-resource heuristic so that singleton getters like
 // "get" or "show" are excluded.
 func looksLikeCollectionEndpoint(nameLower string) bool {
-	return containsAny(nameLower, []string{"list", "all", "index", "search", "query", "browse", "find"})
+	return containsAny(nameLower, collectionEndpointTerms)
 }
 
+var collectionEndpointTerms = []string{"list", "all", "index", "search", "query", "browse", "find"}
+
 func hasLifecycleField(params []spec.Param) bool {
 	for _, param := range params {
 		if isLifecycleParam(param) {
@@ -1028,6 +1031,10 @@ type syncableMeta struct {
 	Discriminator DiscriminatorDispatch
 }
 
+type syncableCandidate struct {
+	meta syncableMeta
+}
+
 // parameterizedEntry pairs a parameterized list endpoint with the parent
 // resource it was discovered under during the spec walk. parentName is
 // empty for top-level resources whose paths happen to be parameterized;
@@ -1052,6 +1059,88 @@ func metaFromEndpoint(s *spec.APISpec, resource spec.Resource, e spec.Endpoint,
 	}
 }
 
+func applySyncCandidates(syncable map[string]syncableMeta, candidates map[string][]syncableCandidate) {
+	resourceNames := sortedKeys(candidates)
+	for _, resourceName := range resourceNames {
+		entries := candidates[resourceName]
+		sort.SliceStable(entries, func(i, j int) bool {
+			if len(entries[i].meta.Path) != len(entries[j].meta.Path) {
+				return len(entries[i].meta.Path) < len(entries[j].meta.Path)
+			}
+			return entries[i].meta.Path < entries[j].meta.Path
+		})
+		if len(entries) == 0 {
+			continue
+		}
+
+		if _, ok := syncable[resourceName]; !ok {
+			syncable[resourceName] = entries[0].meta
+		}
+		canonicalPath := syncable[resourceName].Path
+		for _, entry := range entries {
+			if entry.meta.Path == canonicalPath {
+				continue
+			}
+			name := siblingSyncResourceName(resourceName, entry)
+			if name == "" || name == resourceName {
+				continue
+			}
+			addSyncableIfUnique(syncable, name, entry.meta)
+		}
+	}
+}
+
+func siblingSyncResourceName(resourceName string, candidate syncableCandidate) string {
+	suffix := siblingSyncResourceSuffix(resourceName, candidate.meta.Path)
+	if len(suffix) == 0 || isGenericCollectionSegment(suffix[len(suffix)-1]) {
+		return resourceName
+	}
+	return resourceName + "-" + strings.Join(suffix, "-")
+}
+
+func isGenericCollectionSegment(segment string) bool {
+	return slices.Contains(collectionEndpointTerms, segment)
+}
+
+func siblingSyncResourceSuffix(resourceName, path string) []string {
+	segments := staticPathSegments(path)
+	for i, segment := range segments {
+		if segment == resourceName {
+			return segments[i+1:]
+		}
+	}
+	if len(segments) == 0 {
+		return nil
+	}
+	return segments[len(segments)-1:]
+}
+
+func staticPathSegments(path string) []string {
+	segments := strings.Split(strings.Trim(path, "/"), "/")
+	out := make([]string, 0, len(segments))
+	for _, segment := range segments {
+		segment := strings.TrimSpace(segment)
+		if segment == "" || strings.HasPrefix(segment, "{") && strings.HasSuffix(segment, "}") {
+			continue
+		}
+		if normalized := normalizeSyncResourceSegment(segment); normalized != "" {
+			out = append(out, normalized)
+		}
+	}
+	return out
+}
+
+func normalizeSyncResourceSegment(value string) string {
+	segment := strings.ReplaceAll(spec.ToSnakeCase(value), "_", "-")
+	return strings.Trim(segment, "-")
+}
+
+func addSyncableIfUnique(syncable map[string]syncableMeta, name string, meta syncableMeta) {
+	if existing, ok := syncable[name]; !ok || existing.Path == meta.Path {
+		syncable[name] = meta
+	}
+}
+
 func discriminatorDispatchForEndpoint(endpoint spec.Endpoint, types map[string]spec.TypeDef, resourceNameIndex map[string]string) DiscriminatorDispatch {
 	if endpoint.Response.Discriminator != nil {
 		dispatch := buildDiscriminatorDispatch(endpoint.Response.Discriminator.Field, endpoint.Response.Discriminator.Mapping, resourceNameIndex)
diff --git a/internal/profiler/profiler_test.go b/internal/profiler/profiler_test.go
index e9a2b771..2df2acd1 100644
--- a/internal/profiler/profiler_test.go
+++ b/internal/profiler/profiler_test.go
@@ -134,6 +134,47 @@ func TestProfileEnumExpansion(t *testing.T) {
 	assert.Equal(t, "/v1/api/team", syncPaths["team"])
 }
 
+func TestProfileSiblingListEndpoints(t *testing.T) {
+	s := &spec.APISpec{
+		Name: "trading",
+		Resources: map[string]spec.Resource{
+			"portfolio": {
+				Endpoints: map[string]spec.Endpoint{
+					"fills": {
+						Method:     "GET",
+						Path:       "/portfolio/fills",
+						Response:   spec.ResponseDef{Type: "array"},
+						Pagination: &spec.Pagination{CursorParam: "cursor", LimitParam: "limit"},
+					},
+					"orders": {
+						Method:     "GET",
+						Path:       "/portfolio/orders",
+						Response:   spec.ResponseDef{Type: "array"},
+						Pagination: &spec.Pagination{CursorParam: "cursor", LimitParam: "limit"},
+					},
+					"settlements": {
+						Method:     "GET",
+						Path:       "/portfolio/settlements",
+						Response:   spec.ResponseDef{Type: "array"},
+						Pagination: &spec.Pagination{CursorParam: "cursor", LimitParam: "limit"},
+					},
+				},
+			},
+		},
+	}
+
+	profile := Profile(s)
+
+	syncPaths := make(map[string]string)
+	for _, resource := range profile.SyncableResources {
+		syncPaths[resource.Name] = resource.Path
+	}
+
+	assert.Equal(t, "/portfolio/fills", syncPaths["portfolio"])
+	assert.Equal(t, "/portfolio/orders", syncPaths["portfolio-orders"])
+	assert.Equal(t, "/portfolio/settlements", syncPaths["portfolio-settlements"])
+}
+
 func TestProfileDiscriminatorDispatchFromResponseTypeEnum(t *testing.T) {
 	s := &spec.APISpec{
 		Name: "mixed-network",

← 6348c9f2 fix(cli): unscore large code-orch token catalogs (#755)  ·  back to Cli Printing Press  ·  fix(cli): close redfin retro verification gaps (#762) 172c6c29 →