← 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
M internal/generator/generator.goM internal/generator/templates/command.go.tmplM internal/generator/templates/root.go.tmplM internal/openapi/parser.goM internal/openapi/parser_test.goM internal/spec/spec.go
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 →