← back to Cli Printing Press
fix(openapi): deep sub-resource detection with common prefix collapse
08dc4eef606c0b5e161401d49e4796a83b6e0b08 · 2026-03-23 23:26:03 -0700 · Matt Van Horn
Detect when >90% of paths share a common prefix (e.g., /gmail/v1/users/{userId}/)
and collapse it, using the next segment as the real resource name. Also strips
common prefix segments from operationID-based endpoint names. Bumps endpoint-per-
resource cap from 20 to 50 to accommodate large resources like Gmail settings.
Files touched
M internal/openapi/parser.goM internal/openapi/parser_test.go
Diff
commit 08dc4eef606c0b5e161401d49e4796a83b6e0b08
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Mon Mar 23 23:26:03 2026 -0700
fix(openapi): deep sub-resource detection with common prefix collapse
Detect when >90% of paths share a common prefix (e.g., /gmail/v1/users/{userId}/)
and collapse it, using the next segment as the real resource name. Also strips
common prefix segments from operationID-based endpoint names. Bumps endpoint-per-
resource cap from 20 to 50 to accommodate large resources like Gmail settings.
---
internal/openapi/parser.go | 116 +++++++++++++++++++++++++++++++++++++---
internal/openapi/parser_test.go | 2 +-
2 files changed, 109 insertions(+), 9 deletions(-)
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 000781db..e4890840 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -16,7 +16,7 @@ import (
const (
maxResources = 50
- maxEndpointsPerResource = 20
+ maxEndpointsPerResource = 50
)
func Parse(data []byte) (*spec.APISpec, error) {
@@ -224,6 +224,7 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
pathKeys = append(pathKeys, path)
}
sort.Strings(pathKeys)
+ commonPrefix := detectCommonPrefix(pathKeys, basePath)
for _, path := range pathKeys {
pathItem := doc.Paths.Value(path)
@@ -238,7 +239,7 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
continue
}
- primaryName, subName := resourceAndSubFromPath(path, basePath)
+ primaryName, subName := resourceAndSubFromPath(path, basePath, commonPrefix)
if primaryName == "" {
warnf("skipping path %q: could not derive resource name", path)
continue
@@ -293,7 +294,7 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
continue
}
- endpointName := resolveEndpointName(method, path, op, targetEndpoints, targetResourceName, basePath)
+ endpointName := resolveEndpointName(method, path, op, targetEndpoints, targetResourceName, basePath, commonPrefix)
summary := strings.TrimSpace(op.Summary)
desc := strings.TrimSpace(op.Description)
description := selectDescription(summary, desc)
@@ -401,8 +402,8 @@ func tagDescriptionKeys(name string) []string {
return keys
}
-func resolveEndpointName(method, path string, op *openapi3.Operation, existing map[string]spec.Endpoint, resourceName, basePath string) string {
- name := operationIDToName(operationID(op), resourceName)
+func resolveEndpointName(method, path string, op *openapi3.Operation, existing map[string]spec.Endpoint, resourceName, basePath string, commonPrefix []string) string {
+ name := operationIDToName(operationID(op), resourceName, commonPrefix)
if name == "" {
name = defaultEndpointName(method, path)
}
@@ -961,12 +962,21 @@ func firstJSONMediaType(content openapi3.Content) *openapi3.MediaType {
}
func resourceNameFromPath(path, basePath string) string {
- primary, _ := resourceAndSubFromPath(path, basePath)
+ primary, _ := resourceAndSubFromPath(path, basePath, nil)
return primary
}
-func resourceAndSubFromPath(path, basePath string) (string, string) {
+func resourceAndSubFromPath(path, basePath string, commonPrefix []string) (string, string) {
segments := pathSegmentsAfterBase(path, basePath)
+ if len(commonPrefix) > 0 && hasSegmentPrefix(segments, commonPrefix) {
+ if primary, sub := resourceAndSubFromSegments(segments[len(commonPrefix):]); primary != "" {
+ return primary, sub
+ }
+ }
+ return resourceAndSubFromSegments(segments)
+}
+
+func resourceAndSubFromSegments(segments []string) (string, string) {
if len(segments) == 0 {
return "", ""
}
@@ -995,6 +1005,87 @@ func resourceAndSubFromPath(path, basePath string) (string, string) {
return primary, sub
}
+func detectCommonPrefix(paths []string, basePath string) []string {
+ segmentLists := make([][]string, 0, len(paths))
+ for _, path := range paths {
+ segments := pathSegmentsAfterBase(path, basePath)
+ if len(segments) > 0 {
+ segmentLists = append(segmentLists, segments)
+ }
+ }
+ if len(segmentLists) == 0 {
+ return nil
+ }
+
+ total := len(segmentLists)
+ prefix := make([]string, 0)
+
+ for idx := 0; ; idx++ {
+ counts := map[string]int{}
+ examples := map[string]string{}
+
+ for _, segments := range segmentLists {
+ if len(segments) <= idx || !hasSegmentPrefix(segments, prefix) {
+ continue
+ }
+
+ key := canonicalPrefixSegment(segments[idx])
+ counts[key]++
+ if _, ok := examples[key]; !ok {
+ examples[key] = segments[idx]
+ }
+ }
+
+ bestKey := ""
+ bestCount := 0
+ for key, count := range counts {
+ if count > bestCount {
+ bestKey = key
+ bestCount = count
+ }
+ }
+
+ if bestCount*10 <= total*9 {
+ break
+ }
+
+ prefix = append(prefix, examples[bestKey])
+ }
+
+ lastParam := -1
+ for i, segment := range prefix {
+ if isPathParamSegment(segment) {
+ lastParam = i
+ }
+ }
+ if lastParam == -1 {
+ return nil
+ }
+
+ return prefix[:lastParam+1]
+}
+
+func hasSegmentPrefix(segments, prefix []string) bool {
+ if len(prefix) > len(segments) {
+ return false
+ }
+
+ for i, prefixSegment := range prefix {
+ if canonicalPrefixSegment(segments[i]) != canonicalPrefixSegment(prefixSegment) {
+ return false
+ }
+ }
+
+ return true
+}
+
+func canonicalPrefixSegment(segment string) string {
+ if isPathParamSegment(segment) {
+ return "{}"
+ }
+ return segment
+}
+
func endpointCollisionSuffix(path, resourceName, basePath string) string {
segments := pathSegmentsAfterBase(path, basePath)
if len(segments) == 0 {
@@ -1088,7 +1179,7 @@ func baseURLPath(baseURL string) string {
return parsed.Path
}
-func operationIDToName(operationID, resourceName string) string {
+func operationIDToName(operationID, resourceName string, commonPrefix []string) string {
if strings.TrimSpace(operationID) == "" {
return ""
}
@@ -1100,6 +1191,15 @@ func operationIDToName(operationID, resourceName string) string {
name := strings.TrimPrefix(original, "api_")
resourceVariants := operationIDResourceVariants(resourceName)
+ // Also strip common prefix segments (e.g., "gmail", "users" from "gmail_users_messages_list")
+ for _, seg := range commonPrefix {
+ if isPathParamSegment(seg) {
+ continue
+ }
+ prefixVariants := operationIDResourceVariants(seg)
+ name = stripOperationIDResourcePrefix(name, prefixVariants)
+ }
+
name = stripOperationIDVersionPrefix(name)
name = stripOperationIDResourcePrefix(name, resourceVariants)
name = stripOperationIDVersionPrefix(name)
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index c08ea6ff..fd588868 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -244,7 +244,7 @@ func TestOperationIDToName(t *testing.T) {
for _, tt := range tests {
t.Run(tt.operationID+"_"+tt.resourceName, func(t *testing.T) {
- got := operationIDToName(tt.operationID, tt.resourceName)
+ got := operationIDToName(tt.operationID, tt.resourceName, nil)
assert.Equal(t, tt.want, got)
})
}
← 332fe595 feat(generator): auto-detect pagination and generate --limit
·
back to Cli Printing Press
·
fix(openapi): filter global query params that appear on >80% babd0c66 →