[object Object]

← back to Cli Printing Press

fix(parser): sanitize schema names, cap title length, handle missing servers, resolve URL templates

3f096bce67197ef073d06199100c397eefad5d2f · 2026-03-24 09:43:04 -0700 · Matt Van Horn

Files touched

Diff

commit 3f096bce67197ef073d06199100c397eefad5d2f
Author: Matt Van Horn <455140+mvanhorn@users.noreply.github.com>
Date:   Tue Mar 24 09:43:04 2026 -0700

    fix(parser): sanitize schema names, cap title length, handle missing servers, resolve URL templates
---
 internal/openapi/parser.go | 77 ++++++++++++++++++++++++++++++++++++++++++++--
 internal/spec/spec.go      |  1 +
 2 files changed, 75 insertions(+), 3 deletions(-)

diff --git a/internal/openapi/parser.go b/internal/openapi/parser.go
index 033e0ee8..02ccbb46 100644
--- a/internal/openapi/parser.go
+++ b/internal/openapi/parser.go
@@ -32,9 +32,18 @@ func Parse(data []byte) (*spec.APISpec, error) {
 	description := ""
 	version := ""
 	if doc.Info != nil {
-		if v := cleanSpecName(doc.Info.Title); v != "" {
+		if v := cleanSpecName(doc.Info.Title); v != "" && v != "api" {
 			name = v
 		}
+		if name == "api" && doc.Info.Extensions != nil {
+			if raw, ok := doc.Info.Extensions["x-api-name"]; ok {
+				if s, ok := raw.(string); ok {
+					if v := cleanSpecName(s); v != "" && v != "api" {
+						name = v
+					}
+				}
+			}
+		}
 		description = strings.TrimSpace(doc.Info.Description)
 		version = strings.TrimSpace(doc.Info.Version)
 	}
@@ -43,6 +52,28 @@ func Parse(data []byte) (*spec.APISpec, error) {
 	basePath := ""
 	if len(doc.Servers) > 0 && doc.Servers[0] != nil {
 		serverURL := strings.TrimRight(strings.TrimSpace(doc.Servers[0].URL), "/")
+		// Resolve server URL template variables using defaults.
+		if strings.Contains(serverURL, "{") && doc.Servers[0].Variables != nil {
+			for varName, variable := range doc.Servers[0].Variables {
+				if variable != nil && variable.Default != "" {
+					serverURL = strings.ReplaceAll(serverURL, "{"+varName+"}", variable.Default)
+				}
+			}
+		}
+		// Strip any remaining unresolved template variables.
+		for strings.Contains(serverURL, "{") {
+			start := strings.Index(serverURL, "{")
+			end := strings.Index(serverURL, "}")
+			if start == -1 || end == -1 || end < start {
+				break
+			}
+			serverURL = serverURL[:start] + serverURL[end+1:]
+		}
+		serverURL = strings.ReplaceAll(serverURL, "//", "/")
+		// Restore protocol double-slash if normalization collapsed it.
+		serverURL = strings.Replace(serverURL, "http:/", "http://", 1)
+		serverURL = strings.Replace(serverURL, "https:/", "https://", 1)
+		serverURL = strings.TrimRight(serverURL, "/")
 		if serverURL != "" {
 			lowerURL := strings.ToLower(serverURL)
 			if strings.HasPrefix(lowerURL, "http://") || strings.HasPrefix(lowerURL, "https://") {
@@ -54,6 +85,10 @@ func Parse(data []byte) (*spec.APISpec, error) {
 			}
 		}
 	}
+	if baseURL == "" && basePath == "" {
+		warnf("no servers defined in spec; generated CLI will require base_url in config")
+		baseURL = "https://api.example.com"
+	}
 
 	result := &spec.APISpec{
 		Name:        name,
@@ -872,6 +907,11 @@ func mapTypes(doc *openapi3.T, out *spec.APISpec) {
 	sort.Strings(names)
 
 	for _, name := range names {
+		goName := sanitizeTypeName(name)
+		if goName == "" {
+			continue
+		}
+
 		schemaRef := schemaMap[name]
 		schema := schemaRefValue(schemaRef)
 		if schema == nil {
@@ -902,7 +942,7 @@ func mapTypes(doc *openapi3.T, out *spec.APISpec) {
 			})
 		}
 
-		out.Types[name] = spec.TypeDef{Fields: fields}
+		out.Types[goName] = spec.TypeDef{Fields: fields}
 	}
 }
 
@@ -924,7 +964,7 @@ func collectTypeProperties(schemaRef *openapi3.SchemaRef, properties map[string]
 		if strings.HasPrefix(name, "_") {
 			continue
 		}
-		properties[name] = prop
+		properties[sanitizeTypeName(name)] = prop
 	}
 	for _, sub := range schema.AllOf {
 		collectTypeProperties(sub, properties, visited)
@@ -1503,6 +1543,22 @@ func sanitizeResourceName(name string) string {
 	return name
 }
 
+func sanitizeTypeName(name string) string {
+	name = strings.TrimLeft(name, "$")
+	name = strings.NewReplacer(".", "_", "/", "_", "\\", "_", "-", "_", " ", "_").Replace(name)
+	var b strings.Builder
+	for _, r := range name {
+		if unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_' {
+			b.WriteRune(r)
+		}
+	}
+	result := b.String()
+	if len(result) > 0 && !unicode.IsLetter(rune(result[0])) {
+		result = "T" + result
+	}
+	return result
+}
+
 func toKebabCase(input string) string {
 	var b strings.Builder
 	lastHyphen := true
@@ -1559,6 +1615,18 @@ func cleanSpecName(title string) string {
 		"specification": {},
 		"preview":       {},
 		"http":          {},
+		"with":          {},
+		"and":           {},
+		"from":          {},
+		"for":           {},
+		"the":           {},
+		"by":            {},
+		"of":            {},
+		"in":            {},
+		"on":            {},
+		"to":            {},
+		"fixes":         {},
+		"improvements":  {},
 	}
 
 	filtered := make([]string, 0, len(tokens))
@@ -1571,6 +1639,9 @@ func cleanSpecName(title string) string {
 		}
 		filtered = append(filtered, token)
 	}
+	if len(filtered) > 3 {
+		filtered = filtered[:3]
+	}
 
 	name := toKebabCase(strings.Join(filtered, " "))
 	if name == "" {
diff --git a/internal/spec/spec.go b/internal/spec/spec.go
index 2d44b49b..dbc83537 100644
--- a/internal/spec/spec.go
+++ b/internal/spec/spec.go
@@ -111,6 +111,7 @@ func (s *APISpec) Validate() error {
 	if s.Name == "" {
 		return fmt.Errorf("name is required")
 	}
+	// Parser fallback may supply a placeholder base_url when the source spec omits servers.
 	if s.BaseURL == "" && s.BasePath == "" {
 		return fmt.Errorf("base_url is required")
 	}

← 0f1beba2 feat(catalog): add telegram, launchdarkly, sentry from dogfo  ·  back to Cli Printing Press  ·  docs(plans): update gauntlet findings after parser fixes - 4 2501e565 →