← 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
A internal/websniff/schema.goA internal/websniff/schema_test.go
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 →