[object Object]

← back to Cli Printing Press

fix(generator): harden toCamel, flagName, defaultVal and dedup body params

1eda222ad76f17854897626b7726bbdbb29bd942 · 2026-03-24 17:52:31 -0700 · Matt Van Horn

- toCamel now splits on all non-letter/non-digit chars (not just _-. /)
- flagName rewritten to strip everything non-alphanumeric, lowercased
- defaultVal coerces defaults by declared param type (fixes bool/int mismatches)
- Deduplicate body params that collide with query/path params by flag name
- Deduplicate fields in mapRequestBody and mapTypes by camelCase name
- Add HEAD alongside GET for paginated request template
- Refactor IsOpenAPI to use bytes.Contains without 500-byte truncation

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

Files touched

Diff

commit 1eda222ad76f17854897626b7726bbdbb29bd942
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Tue Mar 24 17:52:31 2026 -0700

    fix(generator): harden toCamel, flagName, defaultVal and dedup body params
    
    - toCamel now splits on all non-letter/non-digit chars (not just _-. /)
    - flagName rewritten to strip everything non-alphanumeric, lowercased
    - defaultVal coerces defaults by declared param type (fixes bool/int mismatches)
    - Deduplicate body params that collide with query/path params by flag name
    - Deduplicate fields in mapRequestBody and mapTypes by camelCase name
    - Add HEAD alongside GET for paginated request template
    - Refactor IsOpenAPI to use bytes.Contains without 500-byte truncation
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 internal/generator/generator.go              | 55 +++++++++++++++++++++-------
 internal/generator/templates/command.go.tmpl |  2 +-
 internal/openapi/detect.go                   | 21 +++++++----
 internal/openapi/parser.go                   | 51 +++++++++++++++++++++++++-
 4 files changed, 105 insertions(+), 24 deletions(-)

diff --git a/internal/generator/generator.go b/internal/generator/generator.go
index cb523262..c597b7d3 100644
--- a/internal/generator/generator.go
+++ b/internal/generator/generator.go
@@ -167,7 +167,7 @@ func toCamel(s string) string {
 	// Strip characters that are invalid in Go identifiers
 	s = strings.TrimLeft(s, "$")
 	parts := strings.FieldsFunc(s, func(r rune) bool {
-		return r == '_' || r == '-' || r == ' ' || r == '.' || r == '/' || r == '\\' || r == '$' || r == '#' || r == '@'
+		return !unicode.IsLetter(r) && !unicode.IsDigit(r)
 	})
 	for i, p := range parts {
 		if len(p) > 0 {
@@ -225,18 +225,36 @@ func cobraFlagFunc(t string) string {
 
 func defaultVal(p spec.Param) string {
 	if p.Default != nil {
-		switch v := p.Default.(type) {
-		case string:
-			return fmt.Sprintf("%q", v)
-		case int:
-			return fmt.Sprintf("%d", v)
-		case float64:
-			if v == float64(int(v)) {
+		// Coerce the default value to match the declared param type
+		switch p.Type {
+		case "string":
+			return fmt.Sprintf("%q", fmt.Sprintf("%v", p.Default))
+		case "bool":
+			switch v := p.Default.(type) {
+			case bool:
+				return fmt.Sprintf("%t", v)
+			case string:
+				if v == "true" || v == "false" {
+					return v
+				}
+			}
+			return "false"
+		case "int":
+			switch v := p.Default.(type) {
+			case float64:
 				return fmt.Sprintf("%d", int(v))
+			case int:
+				return fmt.Sprintf("%d", v)
+			}
+			return "0"
+		case "float":
+			switch v := p.Default.(type) {
+			case float64:
+				return fmt.Sprintf("%f", v)
+			case int:
+				return fmt.Sprintf("%f", float64(v))
 			}
-			return fmt.Sprintf("%f", v)
-		case bool:
-			return fmt.Sprintf("%t", v)
+			return "0.0"
 		}
 	}
 	return zeroVal(p.Type)
@@ -343,8 +361,19 @@ func (g *Generator) exampleLine(commandPath, endpointName string, endpoint spec.
 
 func flagName(name string) string {
 	name = strings.TrimLeft(name, "$")
-	name = strings.NewReplacer("_", "-", "/", "-", ".", "-", "\\", "-").Replace(name)
-	return strings.Trim(name, "-")
+	// Replace common separators with hyphens, strip anything not alphanumeric or hyphen
+	var b strings.Builder
+	lastHyphen := true
+	for _, r := range name {
+		if unicode.IsLetter(r) || unicode.IsDigit(r) {
+			b.WriteRune(unicode.ToLower(r))
+			lastHyphen = false
+		} else if !lastHyphen && b.Len() > 0 {
+			b.WriteByte('-')
+			lastHyphen = true
+		}
+	}
+	return strings.Trim(b.String(), "-")
 }
 
 func safeTypeName(name string) string {
diff --git a/internal/generator/templates/command.go.tmpl b/internal/generator/templates/command.go.tmpl
index 9b43f26e..0f3db719 100644
--- a/internal/generator/templates/command.go.tmpl
+++ b/internal/generator/templates/command.go.tmpl
@@ -60,7 +60,7 @@ func new{{camel $.FuncPrefix}}{{camel $eName}}Cmd(flags *rootFlags) *cobra.Comma
 {{- end}}
 {{- end}}
 
-{{- if eq $endpoint.Method "GET"}}
+{{- if or (eq $endpoint.Method "GET") (eq $endpoint.Method "HEAD")}}
 {{- if $endpoint.Pagination}}
 			data, err := paginatedGet(c, path, map[string]string{
 {{- range $endpoint.Params}}
diff --git a/internal/openapi/detect.go b/internal/openapi/detect.go
index aee5b450..93fc8e17 100644
--- a/internal/openapi/detect.go
+++ b/internal/openapi/detect.go
@@ -1,18 +1,23 @@
 package openapi
 
-import "strings"
+import "bytes"
 
 func IsOpenAPI(data []byte) bool {
 	if len(data) == 0 {
 		return false
 	}
-	if len(data) > 500 {
-		data = data[:500]
+
+	// Check for JSON-style keys (case-sensitive, covers 99% of specs)
+	if bytes.Contains(data, []byte(`"openapi"`)) ||
+		bytes.Contains(data, []byte(`"swagger"`)) {
+		return true
+	}
+
+	// Check for YAML-style keys
+	if bytes.Contains(data, []byte("openapi:")) ||
+		bytes.Contains(data, []byte("swagger:")) {
+		return true
 	}
 
-	content := strings.ToLower(string(data))
-	return strings.Contains(content, "openapi:") ||
-		strings.Contains(content, "\"openapi\"") ||
-		strings.Contains(content, "swagger:") ||
-		strings.Contains(content, "\"swagger\"")
+	return false
 }
diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index bca323bb..4bff7ba3 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -364,12 +364,30 @@ func mapResources(doc *openapi3.T, out *spec.APISpec, basePath string) {
 				description = humanizeEndpointName(endpointName)
 			}
 
+			params := mapParameters(pathItem, op)
+			body := mapRequestBody(op.RequestBody, method, path)
+
+			// Deduplicate body params that collide with query/path params by flag name
+			if len(body) > 0 && len(params) > 0 {
+				paramFlags := map[string]bool{}
+				for _, p := range params {
+					paramFlags[toKebabCase(p.Name)] = true
+				}
+				filtered := make([]spec.Param, 0, len(body))
+				for _, b := range body {
+					if !paramFlags[toKebabCase(b.Name)] {
+						filtered = append(filtered, b)
+					}
+				}
+				body = filtered
+			}
+
 			endpoint := spec.Endpoint{
 				Method:      strings.ToUpper(method),
 				Path:        path,
 				Description: description,
-				Params:      mapParameters(pathItem, op),
-				Body:        mapRequestBody(op.RequestBody, method, path),
+				Params:      params,
+				Body:        body,
 			}
 
 			endpoint.Response, endpoint.ResponsePath = mapResponse(op, endpointName)
@@ -822,7 +840,13 @@ func mapRequestBody(requestBodyRef *openapi3.RequestBodyRef, method, path string
 	sort.Strings(names)
 
 	body := make([]spec.Param, 0, len(names))
+	seenCamelNames := map[string]bool{}
 	for _, name := range names {
+		camelName := toCamelCase(name)
+		if seenCamelNames[camelName] {
+			continue
+		}
+		seenCamelNames[camelName] = true
 		schema := schemaRefValue(properties[name])
 		if isComplexBodyFieldSchema(schema) {
 			warnf("skipping body field %q: complex type not supported as CLI flag", name)
@@ -1031,10 +1055,16 @@ func mapTypes(doc *openapi3.T, out *spec.APISpec) {
 		sort.Strings(fieldNames)
 
 		fields := make([]spec.TypeField, 0, len(fieldNames))
+		seenGoNames := map[string]bool{}
 		for _, fieldName := range fieldNames {
 			if strings.HasPrefix(fieldName, "_") {
 				continue
 			}
+			goFieldName := toCamelCase(fieldName)
+			if seenGoNames[goFieldName] {
+				continue
+			}
+			seenGoNames[goFieldName] = true
 			fields = append(fields, spec.TypeField{
 				Name: fieldName,
 				Type: mapSchemaType(schemaRefValue(properties[fieldName])),
@@ -1658,6 +1688,23 @@ func sanitizeTypeName(name string) string {
 	return result
 }
 
+func toCamelCase(s string) string {
+	s = strings.TrimLeft(s, "$")
+	parts := strings.FieldsFunc(s, func(r rune) bool {
+		return r == '_' || r == '-' || r == ' ' || r == '.' || r == '/' || r == '\\' || r == '$' || r == '#' || r == '@'
+	})
+	for i, p := range parts {
+		if len(p) > 0 {
+			parts[i] = strings.ToUpper(p[:1]) + p[1:]
+		}
+	}
+	result := strings.Join(parts, "")
+	if len(result) > 0 && !unicode.IsLetter(rune(result[0])) {
+		result = "V" + result
+	}
+	return result
+}
+
 func toKebabCase(input string) string {
 	var b strings.Builder
 	lastHyphen := true

← 651d11e1 fix(generator): doctor tries health endpoints before reporti  ·  back to Cli Printing Press  ·  docs: update gauntlet findings to 10/10 pass rate 10640caf →