← back to Cli Printing Press
fix(cli): sync sibling list endpoints (#756)
745f2e75f2bc9aff5079ca6de3f232930341c28e · 2026-05-08 17:32:56 -0700 · Trevin Chow
Files touched
M internal/generator/generator_test.goM internal/profiler/profiler.goM internal/profiler/profiler_test.go
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 →