← back to Cli Printing Press
fix(generator): 6 dogfooding fixes for production-quality CLI output
399c9678ec6e5bd7082dd15a9e87de232928d051 · 2026-03-23 17:36:04 -0700 · Matt Van Horn
- Handle relative base URLs (warn instead of crash)
- Clean spec names (strip swagger/openapi/version noise words)
- Skip complex object params as CLI flags (warn and skip)
- Map tag descriptions to resources with plural/singular matching
- Humanize CamelCase descriptions (insert spaces)
- Truncate descriptions at word boundaries (not mid-word)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Files touched
M internal/generator/generator.goM internal/openapi/parser.goM internal/openapi/parser_test.go
Diff
commit 399c9678ec6e5bd7082dd15a9e87de232928d051
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date: Mon Mar 23 17:36:04 2026 -0700
fix(generator): 6 dogfooding fixes for production-quality CLI output
- Handle relative base URLs (warn instead of crash)
- Clean spec names (strip swagger/openapi/version noise words)
- Skip complex object params as CLI flags (warn and skip)
- Map tag descriptions to resources with plural/singular matching
- Humanize CamelCase descriptions (insert spaces)
- Truncate descriptions at word boundaries (not mid-word)
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
---
internal/generator/generator.go | 7 +-
internal/openapi/parser.go | 205 ++++++++++++++++++++++++++++++++++++++--
internal/openapi/parser_test.go | 40 +++++++-
3 files changed, 240 insertions(+), 12 deletions(-)
diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index 4ce60df8..b964f7d6 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -261,7 +261,12 @@ func oneline(s string) string {
}
s = strings.TrimSpace(s)
if len(s) > 120 {
- s = s[:117] + "..."
+ cut := s[:117]
+ if idx := strings.LastIndex(cut, " "); idx > 60 {
+ s = cut[:idx] + "..."
+ } else {
+ s = cut + "..."
+ }
}
return s
}
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 8f5a1bb9..4029d035 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -32,7 +32,7 @@ func Parse(data []byte) (*spec.APISpec, error) {
description := ""
version := ""
if doc.Info != nil {
- if v := toKebabCase(doc.Info.Title); v != "" {
+ if v := cleanSpecName(doc.Info.Title); v != "" {
name = v
}
description = strings.TrimSpace(doc.Info.Description)
@@ -42,6 +42,12 @@ func Parse(data []byte) (*spec.APISpec, error) {
baseURL := ""
if len(doc.Servers) > 0 && doc.Servers[0] != nil {
baseURL = strings.TrimRight(strings.TrimSpace(doc.Servers[0].URL), "/")
+ if baseURL != "" {
+ lowerBaseURL := strings.ToLower(baseURL)
+ if !strings.HasPrefix(lowerBaseURL, "http://") && !strings.HasPrefix(lowerBaseURL, "https://") {
+ warnf("server URL %q has no http scheme; generated CLI may require manual base_url config", baseURL)
+ }
+ }
}
result := &spec.APISpec{
@@ -249,10 +255,15 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
}
endpointName := resolveEndpointName(method, path, op, resource.Endpoints, resourceName, basePath)
+ description := firstNonEmpty(strings.TrimSpace(op.Summary), strings.TrimSpace(op.Description))
+ if shouldHumanizeDescription(description) {
+ description = humanizeDescription(description)
+ }
+
endpoint := spec.Endpoint{
Method: strings.ToUpper(method),
Path: path,
- Description: firstNonEmpty(strings.TrimSpace(op.Summary), strings.TrimSpace(op.Description)),
+ Description: description,
Params: mapParameters(pathItem, op),
Body: mapRequestBody(op.RequestBody, method, path),
}
@@ -274,12 +285,10 @@ func mapTagDescriptions(tags openapi3.Tags) map[string]string {
if tag == nil {
continue
}
- name := toSnakeCase(tag.Name)
- if name == "" {
- continue
- }
if desc := strings.TrimSpace(tag.Description); desc != "" {
- out[name] = desc
+ for _, key := range tagDescriptionKeys(tag.Name) {
+ out[key] = desc
+ }
}
}
return out
@@ -290,13 +299,52 @@ func resourceDescription(op *openapi3.Operation, tagDescriptions map[string]stri
return ""
}
for _, tag := range op.Tags {
- if desc := tagDescriptions[toSnakeCase(tag)]; desc != "" {
- return desc
+ for _, key := range tagDescriptionKeys(tag) {
+ if desc := tagDescriptions[key]; desc != "" {
+ return desc
+ }
}
}
return ""
}
+func tagDescriptionKeys(name string) []string {
+ name = strings.TrimSpace(name)
+ if name == "" {
+ return nil
+ }
+
+ seen := map[string]struct{}{}
+ keys := make([]string, 0, 6)
+
+ add := func(key string) {
+ key = strings.TrimSpace(key)
+ if key == "" {
+ return
+ }
+ if _, ok := seen[key]; ok {
+ return
+ }
+ seen[key] = struct{}{}
+ keys = append(keys, key)
+ }
+
+ bases := []string{
+ toSnakeCase(name),
+ strings.ToLower(name),
+ }
+ for _, base := range bases {
+ add(base)
+ if strings.HasSuffix(base, "s") && len(base) > 1 {
+ add(strings.TrimSuffix(base, "s"))
+ } else {
+ add(base + "s")
+ }
+ }
+
+ return keys
+}
+
func resolveEndpointName(method, path string, op *openapi3.Operation, existing map[string]spec.Endpoint, resourceName, basePath string) string {
name := operationIDToName(operationID(op), resourceName)
name = strings.ReplaceAll(name, "-", "_")
@@ -456,6 +504,10 @@ func mapRequestBody(requestBodyRef *openapi3.RequestBodyRef, method, path string
body := make([]spec.Param, 0, len(names))
for _, name := range names {
schema := schemaRefValue(properties[name])
+ if isComplexBodyFieldSchema(schema) {
+ warnf("skipping body field %q: complex type not supported as CLI flag", name)
+ continue
+ }
param := spec.Param{
Name: name,
Type: mapSchemaType(schema),
@@ -764,6 +816,10 @@ func isObjectSchema(schema *openapi3.Schema) bool {
return len(schema.Properties) > 0 || len(schema.AllOf) > 0
}
+func isComplexBodyFieldSchema(schema *openapi3.Schema) bool {
+ return isObjectSchema(schema) || isArraySchema(schema)
+}
+
func schemaTypeName(schemaRef *openapi3.SchemaRef, fallback string) string {
if schemaRef == nil {
return toTypeName(fallback)
@@ -1140,6 +1196,137 @@ func toKebabCase(input string) string {
return strings.Trim(b.String(), "-")
}
+func cleanSpecName(title string) string {
+ title = strings.ToLower(strings.TrimSpace(title))
+ if title == "" {
+ return "api"
+ }
+
+ title = strings.ReplaceAll(title, "open api", " ")
+
+ var normalized strings.Builder
+ lastSpace := true
+ for _, r := range title {
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
+ normalized.WriteRune(r)
+ lastSpace = false
+ continue
+ }
+ if !lastSpace {
+ normalized.WriteByte(' ')
+ lastSpace = true
+ }
+ }
+
+ tokens := strings.Fields(normalized.String())
+ if len(tokens) == 0 {
+ return "api"
+ }
+
+ noiseWords := map[string]struct{}{
+ "swagger": {},
+ "openapi": {},
+ "rest": {},
+ "api": {},
+ "spec": {},
+ "specification": {},
+ "preview": {},
+ "http": {},
+ }
+
+ filtered := make([]string, 0, len(tokens))
+ for _, token := range tokens {
+ if _, ok := noiseWords[token]; ok {
+ continue
+ }
+ if isVersionToken(token) {
+ continue
+ }
+ filtered = append(filtered, token)
+ }
+
+ name := toKebabCase(strings.Join(filtered, " "))
+ if name == "" {
+ return "api"
+ }
+ return name
+}
+
+func isVersionToken(token string) bool {
+ token = strings.TrimSpace(strings.ToLower(token))
+ if token == "" {
+ return false
+ }
+
+ if strings.HasPrefix(token, "v") {
+ token = strings.TrimPrefix(token, "v")
+ if token == "" {
+ return false
+ }
+ }
+
+ hasDigit := false
+ for _, r := range token {
+ if unicode.IsDigit(r) {
+ hasDigit = true
+ continue
+ }
+ if r == '.' {
+ continue
+ }
+ return false
+ }
+
+ return hasDigit
+}
+
+func shouldHumanizeDescription(description string) bool {
+ description = strings.TrimSpace(description)
+ if description == "" || strings.ContainsAny(description, " \t\r\n") {
+ return false
+ }
+ for i, r := range description {
+ if i > 0 && unicode.IsUpper(r) {
+ return true
+ }
+ }
+ return false
+}
+
+func humanizeDescription(description string) string {
+ description = strings.TrimSpace(description)
+ if description == "" {
+ return ""
+ }
+
+ var b strings.Builder
+ var prev rune
+ for i, r := range description {
+ if i > 0 && unicode.IsUpper(r) && unicode.IsLower(prev) {
+ b.WriteByte(' ')
+ }
+ b.WriteRune(r)
+ prev = r
+ }
+
+ words := strings.Fields(b.String())
+ if len(words) == 1 {
+ word := strings.ToLower(words[0])
+ if strings.HasSuffix(word, "apps") && len(word) > len("apps") {
+ words = []string{word[:len(word)-len("apps")], "apps"}
+ }
+ }
+
+ if len(words) == 0 {
+ return ""
+ }
+
+ sentence := strings.ToLower(strings.Join(words, " "))
+ runes := []rune(sentence)
+ runes[0] = unicode.ToUpper(runes[0])
+ return string(runes)
+}
+
func toTypeName(input string) string {
input = strings.TrimSpace(input)
if input == "" {
diff --git a/internal/openapi/parser_test.go b/internal/openapi/parser_test.go
index eff3512f..2dae754d 100644
--- a/internal/openapi/parser_test.go
+++ b/internal/openapi/parser_test.go
@@ -20,7 +20,7 @@ func TestParsePetstore(t *testing.T) {
parsed, err := Parse(data)
require.NoError(t, err)
- assert.Equal(t, "swagger-petstore-openapi-30", parsed.Name)
+ assert.Equal(t, "petstore", parsed.Name)
assert.Equal(t, "/api/v3", parsed.BaseURL)
assert.NotEmpty(t, parsed.Resources)
@@ -46,7 +46,7 @@ func TestParseStytchOpenAPI(t *testing.T) {
parsed, err := Parse(data)
require.NoError(t, err)
- assert.Equal(t, "stytch-api", parsed.Name)
+ assert.Equal(t, "stytch", parsed.Name)
assert.NotEmpty(t, parsed.BaseURL)
assert.NotEmpty(t, parsed.Resources)
assert.NotEmpty(t, parsed.Types)
@@ -245,3 +245,39 @@ func TestOperationIDToName(t *testing.T) {
})
}
}
+
+func TestCleanSpecName(t *testing.T) {
+ tests := []struct {
+ title string
+ want string
+ }{
+ {title: "Swagger Petstore - OpenAPI 3.0", want: "petstore"},
+ {title: "Discord HTTP API (Preview)", want: "discord"},
+ {title: "Stytch API", want: "stytch"},
+ {title: "GitHub REST API", want: "github"},
+ {title: "", want: "api"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.title, func(t *testing.T) {
+ assert.Equal(t, tt.want, cleanSpecName(tt.title))
+ })
+ }
+}
+
+func TestHumanizeDescription(t *testing.T) {
+ tests := []struct {
+ input string
+ want string
+ }{
+ {input: "Connectedapps", want: "Connected apps"},
+ {input: "DeleteBiometricRegistration", want: "Delete biometric registration"},
+ {input: "Already normal text", want: "Already normal text"},
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.input, func(t *testing.T) {
+ assert.Equal(t, tt.want, humanizeDescription(tt.input))
+ })
+ }
+}
← c8942bc3 docs: add Steinberger parity plan
·
back to Cli Printing Press
·
docs: add dogfood quality plan 21a7c6aa →