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