[object Object]

← back to Cli Printing Press

feat(websniff): add JSON schema inference from captured payloads

f9b8e141186b4055481f3dd2e46c6e598a435a3c · 2026-03-28 21:29:09 -0700 · Matt Van Horn

Infers field types (string, integer, boolean, array, object) from
JSON response bodies with multi-sample merging for required/optional.
Handles form-encoded request bodies and applies 3-level depth limit.

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

Files touched

Diff

commit f9b8e141186b4055481f3dd2e46c6e598a435a3c
Author: Matt Van Horn <mvanhorn@MacBook-Pro.local>
Date:   Sat Mar 28 21:29:09 2026 -0700

    feat(websniff): add JSON schema inference from captured payloads
    
    Infers field types (string, integer, boolean, array, object) from
    JSON response bodies with multi-sample merging for required/optional.
    Handles form-encoded request bodies and applies 3-level depth limit.
    
    Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
---
 internal/websniff/schema.go      | 289 +++++++++++++++++++++++++++++++++++++++
 internal/websniff/schema_test.go | 144 +++++++++++++++++++
 2 files changed, 433 insertions(+)

diff --git a/internal/websniff/schema.go b/internal/websniff/schema.go
new file mode 100644
index 00000000..c33ff281
--- /dev/null
+++ b/internal/websniff/schema.go
@@ -0,0 +1,289 @@
+package websniff
+
+import (
+	"encoding/json"
+	"math"
+	"net/url"
+	"regexp"
+	"sort"
+	"strconv"
+	"strings"
+	"time"
+
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+)
+
+const maxSchemaDepth = 3
+
+var (
+	uuidPattern  = regexp.MustCompile(`^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$`)
+	emailPattern = regexp.MustCompile(`^[^@\s]+@[^@\s]+\.[^@\s]+$`)
+)
+
+type inferredField struct {
+	count  int
+	param  spec.Param
+	nested map[string]*inferredField
+}
+
+func InferResponseSchema(bodies []string) []spec.Param {
+	parsedSamples := make([]map[string]any, 0, len(bodies))
+	for _, body := range bodies {
+		body = strings.TrimSpace(body)
+		if body == "" {
+			continue
+		}
+
+		var value any
+		if err := json.Unmarshal([]byte(body), &value); err != nil {
+			continue
+		}
+
+		root := topLevelObject(value)
+		if root == nil {
+			continue
+		}
+
+		parsedSamples = append(parsedSamples, root)
+	}
+
+	if len(parsedSamples) == 0 {
+		return nil
+	}
+
+	fields := make(map[string]*inferredField)
+	for _, sample := range parsedSamples {
+		mergeObject(fields, sample, 1)
+	}
+
+	return buildParams(fields, len(parsedSamples))
+}
+
+func InferRequestSchema(body string, contentType string) []spec.Param {
+	body = strings.TrimSpace(body)
+	if body == "" {
+		return nil
+	}
+
+	contentType = strings.ToLower(contentType)
+	switch {
+	case strings.Contains(contentType, "json"):
+		var value any
+		if err := json.Unmarshal([]byte(body), &value); err != nil {
+			return nil
+		}
+
+		root := topLevelObject(value)
+		if root == nil {
+			return nil
+		}
+
+		fields := make(map[string]*inferredField)
+		mergeObject(fields, root, 1)
+		return buildParams(fields, 1)
+	case strings.Contains(contentType, "form-urlencoded"):
+		values := ParseFormBody(body)
+		if len(values) == 0 {
+			return nil
+		}
+
+		params := make([]spec.Param, 0, len(values))
+		for key, value := range values {
+			params = append(params, spec.Param{
+				Name:        key,
+				Type:        inferScalarStringType(value),
+				Required:    true,
+				Description: "",
+			})
+		}
+
+		sort.Slice(params, func(i, j int) bool {
+			return params[i].Name < params[j].Name
+		})
+		return params
+	default:
+		return nil
+	}
+}
+
+func ParseFormBody(body string) map[string]string {
+	values, err := url.ParseQuery(body)
+	if err != nil {
+		return map[string]string{}
+	}
+
+	parsed := make(map[string]string, len(values))
+	for key, vals := range values {
+		if len(vals) == 0 {
+			parsed[key] = ""
+			continue
+		}
+		parsed[key] = vals[0]
+	}
+
+	return parsed
+}
+
+func topLevelObject(value any) map[string]any {
+	switch typed := value.(type) {
+	case map[string]any:
+		return typed
+	case []any:
+		if len(typed) == 0 {
+			return nil
+		}
+
+		object, ok := typed[0].(map[string]any)
+		if !ok {
+			return nil
+		}
+		return object
+	default:
+		return nil
+	}
+}
+
+func mergeObject(fields map[string]*inferredField, object map[string]any, depth int) {
+	for name, value := range object {
+		if value == nil {
+			continue
+		}
+
+		field, ok := fields[name]
+		if !ok {
+			field = &inferredField{}
+			fields[name] = field
+		}
+
+		field.count++
+		field.param = inferParam(name, value, depth)
+
+		if field.param.Type == "object" && len(field.param.Fields) > 0 {
+			if field.nested == nil {
+				field.nested = make(map[string]*inferredField)
+			}
+
+			child, ok := value.(map[string]any)
+			if ok {
+				mergeObject(field.nested, child, depth+1)
+			}
+		}
+	}
+}
+
+func inferParam(name string, value any, depth int) spec.Param {
+	param := spec.Param{
+		Name:        name,
+		Description: "",
+	}
+
+	switch typed := value.(type) {
+	case float64:
+		if math.Trunc(typed) == typed {
+			param.Type = "integer"
+		} else {
+			param.Type = "number"
+		}
+	case string:
+		param.Type = "string"
+		param.Format = inferStringFormat(typed)
+	case bool:
+		param.Type = "boolean"
+	case []any:
+		param.Type = "array"
+		if len(typed) > 0 {
+			if firstObject, ok := typed[0].(map[string]any); ok && depth < maxSchemaDepth {
+				param.Fields = inferObjectFields(firstObject, depth+1)
+			}
+		}
+	case map[string]any:
+		param.Type = "object"
+		if depth < maxSchemaDepth {
+			param.Fields = inferObjectFields(typed, depth+1)
+		}
+	default:
+		param.Type = "string"
+	}
+
+	return param
+}
+
+func inferObjectFields(object map[string]any, depth int) []spec.Param {
+	if depth > maxSchemaDepth {
+		return nil
+	}
+
+	params := make([]spec.Param, 0, len(object))
+	for name, value := range object {
+		if value == nil {
+			continue
+		}
+
+		child := inferParam(name, value, depth)
+		child.Required = true
+		params = append(params, child)
+	}
+
+	sort.Slice(params, func(i, j int) bool {
+		return params[i].Name < params[j].Name
+	})
+	return params
+}
+
+func buildParams(fields map[string]*inferredField, sampleCount int) []spec.Param {
+	params := make([]spec.Param, 0, len(fields))
+	for name, field := range fields {
+		param := field.param
+		param.Name = name
+		param.Required = field.count == sampleCount
+		if field.param.Type == "object" && field.nested != nil {
+			param.Fields = buildParams(field.nested, field.count)
+		}
+		params = append(params, param)
+	}
+
+	sort.Slice(params, func(i, j int) bool {
+		return params[i].Name < params[j].Name
+	})
+	return params
+}
+
+func inferStringFormat(value string) string {
+	switch {
+	case isRFC3339(value):
+		return "date-time"
+	case uuidPattern.MatchString(value):
+		return "uuid"
+	case emailPattern.MatchString(value):
+		return "email"
+	case isURL(value):
+		return "url"
+	default:
+		return ""
+	}
+}
+
+func isRFC3339(value string) bool {
+	_, err := time.Parse(time.RFC3339, value)
+	return err == nil
+}
+
+func isURL(value string) bool {
+	parsed, err := url.Parse(value)
+	if err != nil {
+		return false
+	}
+	return parsed.Scheme != "" && parsed.Host != ""
+}
+
+func inferScalarStringType(value string) string {
+	if _, err := strconv.ParseInt(value, 10, 64); err == nil {
+		return "integer"
+	}
+
+	if value == "true" || value == "false" {
+		return "boolean"
+	}
+
+	return "string"
+}
diff --git a/internal/websniff/schema_test.go b/internal/websniff/schema_test.go
new file mode 100644
index 00000000..2f777e6d
--- /dev/null
+++ b/internal/websniff/schema_test.go
@@ -0,0 +1,144 @@
+package websniff
+
+import (
+	"testing"
+
+	"github.com/mvanhorn/cli-printing-press/internal/spec"
+	"github.com/stretchr/testify/assert"
+)
+
+func TestInferResponseSchema(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name   string
+		bodies []string
+		want   []spec.Param
+	}{
+		{
+			name:   "infers simple object fields",
+			bodies: []string{`{"id":1,"name":"test","created_at":"2026-01-01T00:00:00Z"}`},
+			want: []spec.Param{
+				{Name: "created_at", Type: "string", Required: true, Description: "", Format: "date-time"},
+				{Name: "id", Type: "integer", Required: true, Description: ""},
+				{Name: "name", Type: "string", Required: true, Description: ""},
+			},
+		},
+		{
+			name:   "merges multiple samples and marks optional fields",
+			bodies: []string{`{"a":1,"b":"x"}`, `{"a":2,"c":true}`},
+			want: []spec.Param{
+				{Name: "a", Type: "integer", Required: true, Description: ""},
+				{Name: "b", Type: "string", Required: false, Description: ""},
+				{Name: "c", Type: "boolean", Required: false, Description: ""},
+			},
+		},
+		{
+			name:   "infers from array response using first element",
+			bodies: []string{`[{"id":1},{"id":2}]`},
+			want: []spec.Param{
+				{Name: "id", Type: "integer", Required: true, Description: ""},
+			},
+		},
+		{
+			name:   "returns empty for empty body",
+			bodies: []string{""},
+			want:   nil,
+		},
+		{
+			name:   "returns empty for non json body",
+			bodies: []string{"not json"},
+			want:   nil,
+		},
+		{
+			name:   "limits nested object recursion at depth three",
+			bodies: []string{`{"outer":{"middle":{"inner":{"deep":{"value":1}}}}}`},
+			want: []spec.Param{
+				{
+					Name:        "outer",
+					Type:        "object",
+					Required:    true,
+					Description: "",
+					Fields: []spec.Param{
+						{
+							Name:        "middle",
+							Type:        "object",
+							Required:    true,
+							Description: "",
+							Fields: []spec.Param{
+								{
+									Name:        "inner",
+									Type:        "object",
+									Required:    true,
+									Description: "",
+								},
+							},
+						},
+					},
+				},
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			assert.Equal(t, tt.want, InferResponseSchema(tt.bodies))
+		})
+	}
+}
+
+func TestInferRequestSchema(t *testing.T) {
+	t.Parallel()
+
+	tests := []struct {
+		name        string
+		body        string
+		contentType string
+		want        []spec.Param
+	}{
+		{
+			name:        "infers form body values",
+			body:        "q=hello&page=1&limit=20",
+			contentType: "application/x-www-form-urlencoded",
+			want: []spec.Param{
+				{Name: "limit", Type: "integer", Required: true, Description: ""},
+				{Name: "page", Type: "integer", Required: true, Description: ""},
+				{Name: "q", Type: "string", Required: true, Description: ""},
+			},
+		},
+		{
+			name:        "infers json request body",
+			body:        `{"active":true,"count":1,"name":"test"}`,
+			contentType: "application/json",
+			want: []spec.Param{
+				{Name: "active", Type: "boolean", Required: true, Description: ""},
+				{Name: "count", Type: "integer", Required: true, Description: ""},
+				{Name: "name", Type: "string", Required: true, Description: ""},
+			},
+		},
+		{
+			name:        "returns empty for unsupported content type",
+			body:        "a=b",
+			contentType: "text/plain",
+			want:        nil,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			assert.Equal(t, tt.want, InferRequestSchema(tt.body, tt.contentType))
+		})
+	}
+}
+
+func TestParseFormBody(t *testing.T) {
+	t.Parallel()
+
+	assert.Equal(t, map[string]string{
+		"page":  "1",
+		"q":     "hello world",
+		"token": "abc+123",
+	}, ParseFormBody("q=hello+world&page=1&token=abc%2B123"))
+}

← 07f158f6 feat(websniff): add API endpoint classifier with analytics b  ·  back to Cli Printing Press  ·  feat(websniff): add APISpec generator and sniff CLI command 4f4dd9c4 →