[object Object]

← back to Cli Printing Press

feat(generator): sub-resource grouping for nested API paths

3d07636b0334ea1699cc223ec1c5529ca4290802 · 2026-03-23 18:43:31 -0700 · Matt Van Horn

Detect sub-resources from path structure: /guilds/{id}/members becomes
guilds -> members instead of flat guilds with 20-endpoint cap. Each
sub-resource gets its own endpoint limit. Discord goes from 60/230 to
230/230 endpoints with organized grouping.

- Add SubResources field to spec.Resource
- Detect sub-resources when path param exists between segments
- Generate separate Go files per sub-resource with unique function prefixes
- Parent commands add sub-resource commands as Cobra children
- Conditional imports to avoid unused-import errors on parent-only files

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

Files touched

Diff

commit 3d07636b0334ea1699cc223ec1c5529ca4290802
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Mon Mar 23 18:43:31 2026 -0700

    feat(generator): sub-resource grouping for nested API paths
    
    Detect sub-resources from path structure: /guilds/{id}/members becomes
    guilds -> members instead of flat guilds with 20-endpoint cap. Each
    sub-resource gets its own endpoint limit. Discord goes from 60/230 to
    230/230 endpoints with organized grouping.
    
    - Add SubResources field to spec.Resource
    - Detect sub-resources when path param exists between segments
    - Generate separate Go files per sub-resource with unique function prefixes
    - Parent commands add sub-resource commands as Cobra children
    - Conditional imports to avoid unused-import errors on parent-only files
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 internal/generator/generator.go              | 21 +++++++
 internal/generator/templates/command.go.tmpl | 11 +++-
 internal/generator/templates/root.go.tmpl    |  2 +-
 internal/openapi/parser.go                   | 82 ++++++++++++++++++++++------
 internal/openapi/parser_test.go              |  3 +
 internal/spec/spec.go                        | 20 ++++++-
 6 files changed, 115 insertions(+), 24 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index cd04ba36..3fed5e92 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -85,10 +85,12 @@ func (g *Generator) Generate() error {
 	for name, resource := range g.Spec.Resources {
 		data := struct {
 			ResourceName string
+			FuncPrefix   string
 			Resource     spec.Resource
 			*spec.APISpec
 		}{
 			ResourceName: name,
+			FuncPrefix:   name,
 			Resource:     resource,
 			APISpec:      g.Spec,
 		}
@@ -96,6 +98,25 @@ func (g *Generator) Generate() error {
 		if err := g.renderTemplate("command.go.tmpl", outPath, data); err != nil {
 			return fmt.Errorf("rendering command %s: %w", name, err)
 		}
+
+		// Generate sub-resource command files
+		for subName, subResource := range resource.SubResources {
+			subData := struct {
+				ResourceName string
+				FuncPrefix   string
+				Resource     spec.Resource
+				*spec.APISpec
+			}{
+				ResourceName: subName,
+				FuncPrefix:   name + "-" + subName,
+				Resource:     subResource,
+				APISpec:      g.Spec,
+			}
+			subOutPath := filepath.Join("internal", "cli", name+"_"+subName+".go")
+			if err := g.renderTemplate("command.go.tmpl", subOutPath, subData); err != nil {
+				return fmt.Errorf("rendering sub-command %s/%s: %w", name, subName, err)
+			}
+		}
 	}
 
 	return nil
diff --git a/internal/generator/templates/command.go.tmpl b/internal/generator/templates/command.go.tmpl
index 62224be9..da45809e 100644
--- a/internal/generator/templates/command.go.tmpl
+++ b/internal/generator/templates/command.go.tmpl
@@ -1,8 +1,10 @@
 package cli
 
 import (
+{{- if .Resource.Endpoints}}
 	"encoding/json"
 	"fmt"
+{{- end}}
 	"strings"
 
 	"github.com/spf13/cobra"
@@ -10,18 +12,21 @@ import (
 
 var _ = strings.ReplaceAll // ensure import
 
-func new{{camel .ResourceName}}Cmd(flags *rootFlags) *cobra.Command {
+func new{{camel .FuncPrefix}}Cmd(flags *rootFlags) *cobra.Command {
 	cmd := &cobra.Command{
 		Use:   "{{.ResourceName}}",
 		Short: "{{oneline .Resource.Description}}",
 	}
 {{range $eName, $endpoint := .Resource.Endpoints}}
-	cmd.AddCommand(new{{camel $.ResourceName}}{{camel $eName}}Cmd(flags))
+	cmd.AddCommand(new{{camel $.FuncPrefix}}{{camel $eName}}Cmd(flags))
+{{- end}}
+{{- range $subName, $sub := .Resource.SubResources}}
+	cmd.AddCommand(new{{camel $.FuncPrefix}}{{camel $subName}}Cmd(flags))
 {{- end}}
 	return cmd
 }
 {{range $eName, $endpoint := .Resource.Endpoints}}
-func new{{camel $.ResourceName}}{{camel $eName}}Cmd(flags *rootFlags) *cobra.Command {
+func new{{camel $.FuncPrefix}}{{camel $eName}}Cmd(flags *rootFlags) *cobra.Command {
 {{- range $endpoint.Params}}
 {{- if not .Positional}}
 	var flag{{camel .Name}} {{goType .Type}}
diff --git a/internal/generator/templates/root.go.tmpl b/internal/generator/templates/root.go.tmpl
index 25d633f8..ad3f58c1 100644
--- a/internal/generator/templates/root.go.tmpl
+++ b/internal/generator/templates/root.go.tmpl
@@ -40,7 +40,7 @@ func Execute() error {
 	rootCmd.PersistentFlags().DurationVar(&flags.timeout, "timeout", 30*time.Second, "Request timeout")
 
 {{- range $name, $resource := .Resources}}
-	rootCmd.AddCommand(new{{camel $name}}Cmd(&flags))
+	rootCmd.AddCommand(new{{camel $name}}Cmd(&flags)) {{/* FuncPrefix matches resource name for top-level */}}
 {{- end}}
 	rootCmd.AddCommand(newDoctorCmd(&flags))
 	rootCmd.AddCommand(newVersionCliCmd())
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 4316b9c1..6ddc4600 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -228,24 +228,43 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
 			continue
 		}
 
-		resourceName := resourceNameFromPath(path, basePath)
-		if resourceName == "" {
+		primaryName, subName := resourceAndSubFromPath(path, basePath)
+		if primaryName == "" {
 			warnf("skipping path %q: could not derive resource name", path)
 			continue
 		}
 
-		resource, ok := out.Resources[resourceName]
+		resource, ok := out.Resources[primaryName]
 		if !ok {
 			if len(out.Resources) >= maxResources {
 				warnf("skipping path %q: resource limit (%d) reached", path, maxResources)
 				continue
 			}
 			resource = spec.Resource{
-				Description: tagDescriptions[resourceName],
-				Endpoints:   map[string]spec.Endpoint{},
+				Description:  tagDescriptions[primaryName],
+				Endpoints:    map[string]spec.Endpoint{},
+				SubResources: map[string]spec.Resource{},
 			}
 		}
 
+		// Determine the target: direct resource endpoints or sub-resource endpoints
+		var targetEndpoints map[string]spec.Endpoint
+		targetResourceName := primaryName
+		if subName != "" {
+			sub, ok := resource.SubResources[subName]
+			if !ok {
+				sub = spec.Resource{
+					Description: tagDescriptions[subName],
+					Endpoints:   map[string]spec.Endpoint{},
+				}
+				resource.SubResources[subName] = sub
+			}
+			targetEndpoints = resource.SubResources[subName].Endpoints
+			targetResourceName = subName
+		} else {
+			targetEndpoints = resource.Endpoints
+		}
+
 		methods := make([]string, 0, len(operations))
 		for method := range operations {
 			methods = append(methods, method)
@@ -259,12 +278,12 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
 				continue
 			}
 
-			if len(resource.Endpoints) >= maxEndpointsPerResource {
-				warnf("skipping %s %q: endpoint limit (%d) reached for resource %q", method, path, maxEndpointsPerResource, resourceName)
+			if len(targetEndpoints) >= maxEndpointsPerResource {
+				warnf("skipping %s %q: endpoint limit (%d) reached for resource %q.%s", method, path, maxEndpointsPerResource, primaryName, targetResourceName)
 				continue
 			}
 
-			endpointName := resolveEndpointName(method, path, op, resource.Endpoints, resourceName, basePath)
+			endpointName := resolveEndpointName(method, path, op, targetEndpoints, targetResourceName, basePath)
 			summary := strings.TrimSpace(op.Summary)
 			desc := strings.TrimSpace(op.Description)
 			description := selectDescription(summary, desc)
@@ -282,16 +301,21 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
 			}
 
 			endpoint.Response, endpoint.ResponsePath = mapResponse(op, endpointName)
-			if resource.Description == "" {
-				resource.Description = resourceDescription(op, tagDescriptions)
-			}
-			resource.Endpoints[endpointName] = endpoint
+			targetEndpoints[endpointName] = endpoint
 		}
 
+		// Update descriptions
+		if subName != "" {
+			sub := resource.SubResources[subName]
+			if sub.Description == "" {
+				sub.Description = humanizeResourceName(subName)
+			}
+			resource.SubResources[subName] = sub
+		}
 		if resource.Description == "" {
-			resource.Description = humanizeResourceName(resourceName)
+			resource.Description = humanizeResourceName(primaryName)
 		}
-		out.Resources[resourceName] = resource
+		out.Resources[primaryName] = resource
 	}
 }
 
@@ -918,14 +942,38 @@ func firstJSONMediaType(content openapi3.Content) *openapi3.MediaType {
 }
 
 func resourceNameFromPath(path, basePath string) string {
+	primary, _ := resourceAndSubFromPath(path, basePath)
+	return primary
+}
+
+func resourceAndSubFromPath(path, basePath string) (string, string) {
 	segments := pathSegmentsAfterBase(path, basePath)
 	if len(segments) == 0 {
-		return ""
+		return "", ""
 	}
 	if isPathParamSegment(segments[0]) {
-		return ""
+		return "", ""
+	}
+	primary := sanitizeResourceName(strings.ReplaceAll(toSnakeCase(segments[0]), "_", "-"))
+	if primary == "" {
+		return "", ""
+	}
+
+	// Look for sub-resource: requires a path param between primary and sub-resource
+	// e.g. /guilds/{guild_id}/members -> sub-resource "members"
+	// but /store/inventory -> NOT a sub-resource (no param between store and inventory)
+	rest := segments[1:]
+	hasParam := false
+	for len(rest) > 0 && isPathParamSegment(rest[0]) {
+		hasParam = true
+		rest = rest[1:]
+	}
+	if !hasParam || len(rest) == 0 {
+		return primary, ""
 	}
-	return sanitizeResourceName(strings.ReplaceAll(toSnakeCase(segments[0]), "_", "-"))
+	// The first non-param segment after the path param is the sub-resource
+	sub := sanitizeResourceName(strings.ReplaceAll(toSnakeCase(rest[0]), "_", "-"))
+	return primary, sub
 }
 
 func endpointCollisionSuffix(path, resourceName, basePath string) string {
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index 870e9ff6..c08ea6ff 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -55,6 +55,9 @@ func TestParseStytchOpenAPI(t *testing.T) {
 	totalEndpoints := 0
 	for _, resource := range parsed.Resources {
 		totalEndpoints += len(resource.Endpoints)
+		for _, sub := range resource.SubResources {
+			totalEndpoints += len(sub.Endpoints)
+		}
 	}
 	assert.Greater(t, totalEndpoints, 10)
 }
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 686641db..e3e71ba6 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -34,8 +34,9 @@ type ConfigSpec struct {
 }
 
 type Resource struct {
-	Description string              `yaml:"description"`
-	Endpoints   map[string]Endpoint `yaml:"endpoints"`
+	Description  string              `yaml:"description"`
+	Endpoints    map[string]Endpoint `yaml:"endpoints"`
+	SubResources map[string]Resource `yaml:"sub_resources,omitempty"`
 }
 
 type Endpoint struct {
@@ -112,7 +113,7 @@ func (s *APISpec) Validate() error {
 		return fmt.Errorf("at least one resource is required")
 	}
 	for name, r := range s.Resources {
-		if len(r.Endpoints) == 0 {
+		if len(r.Endpoints) == 0 && len(r.SubResources) == 0 {
 			return fmt.Errorf("resource %q has no endpoints", name)
 		}
 		for eName, e := range r.Endpoints {
@@ -123,6 +124,19 @@ func (s *APISpec) Validate() error {
 				return fmt.Errorf("resource %q endpoint %q: path is required", name, eName)
 			}
 		}
+		for subName, sub := range r.SubResources {
+			if len(sub.Endpoints) == 0 {
+				return fmt.Errorf("resource %q sub-resource %q has no endpoints", name, subName)
+			}
+			for eName, e := range sub.Endpoints {
+				if e.Method == "" {
+					return fmt.Errorf("resource %q sub-resource %q endpoint %q: method is required", name, subName, eName)
+				}
+				if e.Path == "" {
+					return fmt.Errorf("resource %q sub-resource %q endpoint %q: path is required", name, subName, eName)
+				}
+			}
+		}
 	}
 	return nil
 }

← 54e55a66 fix(generator): dogfood to Steinberger quality across Petsto  ·  back to Cli Printing Press  ·  fix(generator): auth mapping for Discord BotToken scheme 2915ae3e →