mirror of
https://github.com/therootcompany/golib.git
synced 2026-03-13 12:27:59 +00:00
feat(jsontypes): infer types from JSON, generate code in 9 formats
Add tools/jsontypes library and tools/jsontypes/cmd/jsonpaths CLI. Given a JSON sample (file, URL, or stdin), walks the structure, detects maps vs structs, infers optional fields from multiple instances, and produces typed definitions. Output formats (--format): - json-paths: flat type path notation (default) - go: struct definitions with json tags and union support - typescript: interfaces with optional/nullable fields - jsdoc: @typedef annotations - zod: validation schemas with type inference - python: TypedDict classes - sql: CREATE TABLE with FK relationships - json-schema: draft 2020-12 - json-typedef: RFC 8927 Features: - Interactive prompts for ambiguous structure (map vs struct, same vs different types), with --anonymous mode for non-interactive use - Answer replay: saves prompt answers to .answers files for iterative refinement - URL fetching with local caching and sensitive param stripping - Curl-like auth: -H, --bearer, --user, --cookie, --cookie-jar - Discriminated union support with sealed interfaces, unique-field probing, and CHANGE ME comments for type/kind discriminators - Extensive round-trip compilation tests for generated Go code
This commit is contained in:
parent
516b23eac3
commit
89b1191fdd
2
.gitignore
vendored
2
.gitignore
vendored
@ -18,6 +18,8 @@ cmd/sql-migrate/sql-migrate
|
||||
io/transform/gsheet2csv/cmd/gsheet2csv/gsheet2csv
|
||||
io/transform/gsheet2csv/cmd/gsheet2env/gsheet2env
|
||||
io/transform/gsheet2csv/cmd/gsheet2tsv/gsheet2tsv
|
||||
tools/jsontypes/cmd/jsonpaths/jsonpaths
|
||||
tools/jsontypes/jsonpaths
|
||||
|
||||
# Binaries for programs and plugins
|
||||
*.exe
|
||||
|
||||
10
tools/jsontypes/LICENSE
Normal file
10
tools/jsontypes/LICENSE
Normal file
@ -0,0 +1,10 @@
|
||||
Authored in 2026 by AJ ONeal <aj@therootcompany.com>, generated by Claude Opus 4.6.
|
||||
|
||||
To the extent possible under law, the author(s) have dedicated all copyright
|
||||
and related and neighboring rights to this software to the public domain
|
||||
worldwide. This software is distributed without any warranty.
|
||||
|
||||
You should have received a copy of the CC0 Public Domain Dedication along with
|
||||
this software. If not, see <https://creativecommons.org/publicdomain/zero/1.0/>.
|
||||
|
||||
SPDX-License-Identifier: CC0-1.0
|
||||
564
tools/jsontypes/analyzer.go
Normal file
564
tools/jsontypes/analyzer.go
Normal file
@ -0,0 +1,564 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Analyzer struct {
|
||||
Prompter *Prompter
|
||||
anonymous bool
|
||||
askTypes bool
|
||||
typeCounter int
|
||||
// knownTypes maps shape signature → type name
|
||||
knownTypes map[string]*structType
|
||||
// typesByName maps type name → structType for collision detection
|
||||
typesByName map[string]*structType
|
||||
// pendingTypeName is set by the combined map/struct+name prompt
|
||||
// and consumed by decideTypeName to avoid double-prompting
|
||||
pendingTypeName string
|
||||
}
|
||||
|
||||
type structType struct {
|
||||
name string
|
||||
fields map[string]string // field name → value kind ("string", "number", "bool", "null", "object", "array", "mixed")
|
||||
}
|
||||
|
||||
type shapeGroup struct {
|
||||
sig string
|
||||
fields []string
|
||||
members []map[string]any
|
||||
}
|
||||
|
||||
func NewAnalyzer(inputIsStdin, anonymous, askTypes bool) (*Analyzer, error) {
|
||||
p, err := NewPrompter(inputIsStdin, anonymous)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Analyzer{
|
||||
Prompter: p,
|
||||
anonymous: anonymous,
|
||||
askTypes: askTypes,
|
||||
knownTypes: make(map[string]*structType),
|
||||
typesByName: make(map[string]*structType),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *Analyzer) Close() {
|
||||
a.Prompter.Close()
|
||||
}
|
||||
|
||||
// analyze traverses a JSON value depth-first and returns annotated flat paths.
|
||||
func (a *Analyzer) Analyze(path string, val any) []string {
|
||||
switch v := val.(type) {
|
||||
case nil:
|
||||
return []string{path + "{null}"}
|
||||
case bool:
|
||||
return []string{path + "{bool}"}
|
||||
case json.Number:
|
||||
if _, err := v.Int64(); err == nil {
|
||||
return []string{path + "{int}"}
|
||||
}
|
||||
return []string{path + "{float}"}
|
||||
case string:
|
||||
return []string{path + "{string}"}
|
||||
case []any:
|
||||
return a.analyzeArray(path, v)
|
||||
case map[string]any:
|
||||
return a.analyzeObject(path, v)
|
||||
default:
|
||||
return []string{path + "{unknown}"}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Analyzer) analyzeObject(path string, obj map[string]any) []string {
|
||||
if len(obj) == 0 {
|
||||
return []string{path + "{any}"}
|
||||
}
|
||||
|
||||
isMap := a.decideMapOrStruct(path, obj)
|
||||
if isMap {
|
||||
return a.analyzeAsMap(path, obj)
|
||||
}
|
||||
return a.analyzeAsStruct(path, obj)
|
||||
}
|
||||
|
||||
func (a *Analyzer) analyzeAsMap(path string, obj map[string]any) []string {
|
||||
keyName := a.decideKeyName(path, obj)
|
||||
|
||||
// Collect all values and group by shape for type unification
|
||||
values := make([]any, 0, len(obj))
|
||||
for _, v := range obj {
|
||||
values = append(values, v)
|
||||
}
|
||||
|
||||
return a.analyzeCollectionValues(path+"["+keyName+"]", values)
|
||||
}
|
||||
|
||||
func (a *Analyzer) analyzeAsStruct(path string, obj map[string]any) []string {
|
||||
return a.analyzeAsStructMulti(path, []map[string]any{obj})
|
||||
}
|
||||
|
||||
// analyzeAsStructMulti handles one or more instances of the same struct type,
|
||||
// collecting all values for each field across instances for proper unification.
|
||||
func (a *Analyzer) analyzeAsStructMulti(path string, instances []map[string]any) []string {
|
||||
// Collect all field names across all instances
|
||||
merged := mergeObjects(instances)
|
||||
typeName := a.decideTypeName(path, merged)
|
||||
|
||||
prefix := path + "{" + typeName + "}"
|
||||
var paths []string
|
||||
keys := sortedKeys(merged)
|
||||
for _, k := range keys {
|
||||
// Collect all values for this field across instances
|
||||
var fieldValues []any
|
||||
fieldPresent := 0
|
||||
for _, inst := range instances {
|
||||
if v, ok := inst[k]; ok {
|
||||
fieldValues = append(fieldValues, v)
|
||||
fieldPresent++
|
||||
}
|
||||
}
|
||||
// If the field is missing in some instances, it's optional
|
||||
if fieldPresent < len(instances) {
|
||||
paths = append(paths, prefix+"."+k+"{null}")
|
||||
}
|
||||
|
||||
if len(fieldValues) == 1 {
|
||||
childPaths := a.Analyze(prefix+"."+k, fieldValues[0])
|
||||
paths = append(paths, childPaths...)
|
||||
} else if len(fieldValues) > 1 {
|
||||
childPaths := a.analyzeCollectionValues(prefix+"."+k, fieldValues)
|
||||
paths = append(paths, childPaths...)
|
||||
}
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
paths = append(paths, prefix)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
func (a *Analyzer) analyzeArray(path string, arr []any) []string {
|
||||
if len(arr) == 0 {
|
||||
return []string{path + "[]{any}"}
|
||||
}
|
||||
|
||||
// Check for tuple (short array of mixed types)
|
||||
if a.isTupleCandidate(arr) {
|
||||
isTuple := a.decideTupleOrList(path, arr)
|
||||
if isTuple {
|
||||
return a.analyzeAsTuple(path, arr)
|
||||
}
|
||||
}
|
||||
|
||||
return a.analyzeCollectionValues(path+"[]", arr)
|
||||
}
|
||||
|
||||
func (a *Analyzer) analyzeAsTuple(path string, arr []any) []string {
|
||||
var paths []string
|
||||
for i, v := range arr {
|
||||
childPaths := a.Analyze(fmt.Sprintf("%s[%d]", path, i), v)
|
||||
paths = append(paths, childPaths...)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// analyzeCollectionValues handles type unification for a set of values at the
|
||||
// same path position (map values or array elements).
|
||||
func (a *Analyzer) analyzeCollectionValues(path string, values []any) []string {
|
||||
// Group values by kind
|
||||
var (
|
||||
nullCount int
|
||||
objects []map[string]any
|
||||
arrays [][]any
|
||||
primitives []any
|
||||
primTypeSet = make(map[string]bool)
|
||||
)
|
||||
|
||||
for _, v := range values {
|
||||
switch tv := v.(type) {
|
||||
case nil:
|
||||
nullCount++
|
||||
case map[string]any:
|
||||
objects = append(objects, tv)
|
||||
case []any:
|
||||
arrays = append(arrays, tv)
|
||||
default:
|
||||
primitives = append(primitives, v)
|
||||
primTypeSet[primitiveType(v)] = true
|
||||
}
|
||||
}
|
||||
|
||||
var paths []string
|
||||
|
||||
// Handle nulls: indicates the value is optional
|
||||
if nullCount > 0 && (len(objects) > 0 || len(arrays) > 0 || len(primitives) > 0) {
|
||||
paths = append(paths, path+"{null}")
|
||||
} else if nullCount > 0 && len(objects) == 0 && len(arrays) == 0 && len(primitives) == 0 {
|
||||
return []string{path + "{null}"}
|
||||
}
|
||||
|
||||
// Handle primitives
|
||||
for pt := range primTypeSet {
|
||||
paths = append(paths, path+"{"+pt+"}")
|
||||
}
|
||||
|
||||
// Handle objects by grouping by shape and unifying
|
||||
if len(objects) > 0 {
|
||||
paths = append(paths, a.unifyObjects(path, objects)...)
|
||||
}
|
||||
|
||||
// Handle arrays: collect all elements across all array instances
|
||||
if len(arrays) > 0 {
|
||||
var allElements []any
|
||||
for _, arr := range arrays {
|
||||
allElements = append(allElements, arr...)
|
||||
}
|
||||
if len(allElements) > 0 {
|
||||
paths = append(paths, a.analyzeCollectionValues(path+"[]", allElements)...)
|
||||
} else {
|
||||
paths = append(paths, path+"[]{any}")
|
||||
}
|
||||
}
|
||||
|
||||
return paths
|
||||
}
|
||||
|
||||
// unifyObjects groups objects by shape, prompts about type relationships,
|
||||
// and returns the unified paths.
|
||||
func (a *Analyzer) unifyObjects(path string, objects []map[string]any) []string {
|
||||
// Before grouping by shape, check if these objects are really maps by
|
||||
// pooling all keys across all instances. Individual objects may have too
|
||||
// few keys for heuristics, but collectively the pattern is clear.
|
||||
if combined := a.tryAnalyzeAsMaps(path, objects); combined != nil {
|
||||
return combined
|
||||
}
|
||||
|
||||
groups := make(map[string]*shapeGroup)
|
||||
var groupOrder []string
|
||||
|
||||
for _, obj := range objects {
|
||||
sig := shapeSignature(obj)
|
||||
if g, ok := groups[sig]; ok {
|
||||
g.members = append(g.members, obj)
|
||||
} else {
|
||||
g := &shapeGroup{
|
||||
sig: sig,
|
||||
fields: sortedKeys(obj),
|
||||
members: []map[string]any{obj},
|
||||
}
|
||||
groups[sig] = g
|
||||
groupOrder = append(groupOrder, sig)
|
||||
}
|
||||
}
|
||||
|
||||
if len(groups) == 1 {
|
||||
// All same shape, analyze with all instances for field unification
|
||||
return a.analyzeAsStructMulti(path, objects)
|
||||
}
|
||||
|
||||
// Multiple shapes — in anonymous mode default to same type
|
||||
if a.anonymous {
|
||||
return a.analyzeAsStructMulti(path, objects)
|
||||
}
|
||||
return a.promptTypeUnification(path, groups, groupOrder)
|
||||
}
|
||||
|
||||
// tryAnalyzeAsMaps pools all keys from multiple objects and checks if they
|
||||
// collectively look like map keys (e.g., many objects each with 1-2 numeric
|
||||
// keys). Returns nil if they don't look like maps.
|
||||
func (a *Analyzer) tryAnalyzeAsMaps(path string, objects []map[string]any) []string {
|
||||
// Collect all keys across all objects
|
||||
allKeys := make(map[string]bool)
|
||||
for _, obj := range objects {
|
||||
for k := range obj {
|
||||
allKeys[k] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Need enough keys to be meaningful
|
||||
if len(allKeys) < 3 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Build a synthetic object with all keys for heuristic checking
|
||||
combined := make(map[string]any, len(allKeys))
|
||||
for _, obj := range objects {
|
||||
for k, v := range obj {
|
||||
if _, exists := combined[k]; !exists {
|
||||
combined[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
isMap, confident := looksLikeMap(combined)
|
||||
if !isMap || !confident {
|
||||
return nil
|
||||
}
|
||||
|
||||
// These are maps — merge all entries and analyze as one map
|
||||
return a.analyzeAsMap(path, combined)
|
||||
}
|
||||
|
||||
// promptTypeUnification presents shape groups to the user and asks if they
|
||||
// are the same type (with optional fields) or different types.
|
||||
func (a *Analyzer) promptTypeUnification(path string, groups map[string]*shapeGroup, groupOrder []string) []string {
|
||||
const maxFields = 8
|
||||
|
||||
// Compute shared and unique fields across all shapes
|
||||
shared, uniquePerShape := shapeFieldBreakdown(groups, groupOrder)
|
||||
totalInstances := 0
|
||||
for _, sig := range groupOrder {
|
||||
totalInstances += len(groups[sig].members)
|
||||
}
|
||||
|
||||
fmt.Fprintf(a.Prompter.output, "\nAt %s — %d shapes (%d instances):\n",
|
||||
shortPath(path), len(groupOrder), totalInstances)
|
||||
|
||||
// Show shared fields
|
||||
if len(shared) > 0 {
|
||||
preview := shared
|
||||
if len(preview) > maxFields {
|
||||
preview = preview[:maxFields]
|
||||
}
|
||||
fmt.Fprintf(a.Prompter.output, " shared fields (%d): %s", len(shared), strings.Join(preview, ", "))
|
||||
if len(shared) > maxFields {
|
||||
fmt.Fprintf(a.Prompter.output, ", ...")
|
||||
}
|
||||
fmt.Fprintln(a.Prompter.output)
|
||||
} else {
|
||||
fmt.Fprintf(a.Prompter.output, " no shared fields\n")
|
||||
}
|
||||
|
||||
// Show unique fields per shape (truncated)
|
||||
shownShapes := groupOrder
|
||||
if len(shownShapes) > 5 {
|
||||
shownShapes = shownShapes[:5]
|
||||
}
|
||||
for i, sig := range shownShapes {
|
||||
g := groups[sig]
|
||||
unique := uniquePerShape[sig]
|
||||
if len(unique) == 0 {
|
||||
fmt.Fprintf(a.Prompter.output, " shape %d (%d instances): no unique fields\n", i+1, len(g.members))
|
||||
continue
|
||||
}
|
||||
preview := unique
|
||||
if len(preview) > maxFields {
|
||||
preview = preview[:maxFields]
|
||||
}
|
||||
fmt.Fprintf(a.Prompter.output, " shape %d (%d instances): +%d unique: %s",
|
||||
i+1, len(g.members), len(unique), strings.Join(preview, ", "))
|
||||
if len(unique) > maxFields {
|
||||
fmt.Fprintf(a.Prompter.output, ", ...")
|
||||
}
|
||||
fmt.Fprintln(a.Prompter.output)
|
||||
}
|
||||
if len(groupOrder) > 5 {
|
||||
fmt.Fprintf(a.Prompter.output, " ... and %d more shapes\n", len(groupOrder)-5)
|
||||
}
|
||||
|
||||
// Decide default: if unique fields heavily outnumber meaningful shared
|
||||
// fields, default to "different". Ubiquitous fields (id, name, *_at, etc.)
|
||||
// don't count as meaningful shared fields.
|
||||
meaningfulShared := 0
|
||||
for _, f := range shared {
|
||||
if !isUbiquitousField(f) {
|
||||
meaningfulShared++
|
||||
}
|
||||
}
|
||||
totalUnique := 0
|
||||
for _, sig := range groupOrder {
|
||||
totalUnique += len(uniquePerShape[sig])
|
||||
}
|
||||
defaultChoice := "s"
|
||||
if totalUnique >= 2*meaningfulShared {
|
||||
defaultChoice = "d"
|
||||
}
|
||||
|
||||
// Combined prompt: same/different/show full list
|
||||
var choice string
|
||||
for {
|
||||
choice = a.Prompter.ask(
|
||||
"[s]ame type? [d]ifferent? show [f]ull list?",
|
||||
defaultChoice, []string{"s", "d", "f"},
|
||||
)
|
||||
if choice != "f" {
|
||||
break
|
||||
}
|
||||
for i, sig := range groupOrder {
|
||||
g := groups[sig]
|
||||
fmt.Fprintf(a.Prompter.output, " Shape %d (%d instances): %s\n",
|
||||
i+1, len(g.members), strings.Join(g.fields, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
if choice == "s" {
|
||||
// Same type — analyze with all instances for field unification
|
||||
var all []map[string]any
|
||||
for _, sig := range groupOrder {
|
||||
all = append(all, groups[sig].members...)
|
||||
}
|
||||
return a.analyzeAsStructMulti(path, all)
|
||||
}
|
||||
|
||||
// Different types — collect all names first, then analyze
|
||||
names := make([]string, len(groupOrder))
|
||||
for i, sig := range groupOrder {
|
||||
g := groups[sig]
|
||||
inferred := inferTypeName(path)
|
||||
if inferred == "" {
|
||||
a.typeCounter++
|
||||
inferred = fmt.Sprintf("Struct%d", a.typeCounter)
|
||||
}
|
||||
// Pre-resolve collision so the suggested name is valid
|
||||
merged := mergeObjects(g.members)
|
||||
newFields := fieldSet(merged)
|
||||
shapeSig := shapeSignature(merged)
|
||||
inferred = a.preResolveCollision(path, inferred, newFields, shapeSig)
|
||||
|
||||
fmt.Fprintf(a.Prompter.output, " Shape %d (%d instances): %s\n",
|
||||
i+1, len(g.members), strings.Join(g.fields, ", "))
|
||||
name := a.Prompter.askTypeName(
|
||||
fmt.Sprintf(" Name for shape %d?", i+1), inferred)
|
||||
names[i] = name
|
||||
|
||||
// Register early so subsequent shapes see this name as taken
|
||||
a.registerType(shapeSig, name, newFields)
|
||||
}
|
||||
|
||||
// Now analyze each group with its pre-assigned name
|
||||
var paths []string
|
||||
for i, sig := range groupOrder {
|
||||
g := groups[sig]
|
||||
a.pendingTypeName = names[i]
|
||||
paths = append(paths, a.analyzeAsStructMulti(path, g.members)...)
|
||||
}
|
||||
return paths
|
||||
}
|
||||
|
||||
// shapeFieldBreakdown computes the shared fields (present in ALL shapes) and
|
||||
// unique fields (present in only that shape) for display.
|
||||
func shapeFieldBreakdown(groups map[string]*shapeGroup, groupOrder []string) (shared []string, uniquePerShape map[string][]string) {
|
||||
if len(groupOrder) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Count how many shapes each field appears in
|
||||
fieldCount := make(map[string]int)
|
||||
for _, sig := range groupOrder {
|
||||
for _, f := range groups[sig].fields {
|
||||
fieldCount[f]++
|
||||
}
|
||||
}
|
||||
|
||||
total := len(groupOrder)
|
||||
for _, f := range sortedFieldCount(fieldCount) {
|
||||
if fieldCount[f] == total {
|
||||
shared = append(shared, f)
|
||||
}
|
||||
}
|
||||
|
||||
sharedSet := make(map[string]bool, len(shared))
|
||||
for _, f := range shared {
|
||||
sharedSet[f] = true
|
||||
}
|
||||
|
||||
uniquePerShape = make(map[string][]string)
|
||||
for _, sig := range groupOrder {
|
||||
var unique []string
|
||||
for _, f := range groups[sig].fields {
|
||||
if !sharedSet[f] {
|
||||
unique = append(unique, f)
|
||||
}
|
||||
}
|
||||
uniquePerShape[sig] = unique
|
||||
}
|
||||
return shared, uniquePerShape
|
||||
}
|
||||
|
||||
func sortedFieldCount(m map[string]int) []string {
|
||||
keys := make([]string, 0, len(m))
|
||||
for k := range m {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// isTupleCandidate returns true if the array might be a tuple:
|
||||
// short (2-5 elements) with mixed types.
|
||||
func (a *Analyzer) isTupleCandidate(arr []any) bool {
|
||||
if len(arr) < 2 || len(arr) > 5 {
|
||||
return false
|
||||
}
|
||||
types := make(map[string]bool)
|
||||
for _, v := range arr {
|
||||
types[kindOf(v)] = true
|
||||
}
|
||||
return len(types) > 1
|
||||
}
|
||||
|
||||
func primitiveType(v any) string {
|
||||
switch tv := v.(type) {
|
||||
case bool:
|
||||
return "bool"
|
||||
case json.Number:
|
||||
if _, err := tv.Int64(); err == nil {
|
||||
return "int"
|
||||
}
|
||||
return "float"
|
||||
case string:
|
||||
return "string"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func kindOf(v any) string {
|
||||
switch v.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case bool:
|
||||
return "bool"
|
||||
case json.Number:
|
||||
return "number"
|
||||
case string:
|
||||
return "string"
|
||||
case []any:
|
||||
return "array"
|
||||
case map[string]any:
|
||||
return "object"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func shapeSignature(obj map[string]any) string {
|
||||
keys := sortedKeys(obj)
|
||||
return strings.Join(keys, ",")
|
||||
}
|
||||
|
||||
func sortedKeys(obj map[string]any) []string {
|
||||
keys := make([]string, 0, len(obj))
|
||||
for k := range obj {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
// mergeObjects merges multiple objects into one representative that has all
|
||||
// fields from all instances. For each field, picks the first non-null value.
|
||||
func mergeObjects(objects []map[string]any) map[string]any {
|
||||
merged := make(map[string]any)
|
||||
for _, obj := range objects {
|
||||
for k, v := range obj {
|
||||
if existing, ok := merged[k]; !ok || existing == nil {
|
||||
merged[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
736
tools/jsontypes/analyzer_test.go
Normal file
736
tools/jsontypes/analyzer_test.go
Normal file
@ -0,0 +1,736 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// testAnalyzer creates an analyzer in anonymous mode (no prompts).
|
||||
func testAnalyzer(t *testing.T) *Analyzer {
|
||||
t.Helper()
|
||||
a := &Analyzer{
|
||||
Prompter: &Prompter{
|
||||
reader: nil,
|
||||
output: os.Stderr,
|
||||
},
|
||||
anonymous: true,
|
||||
knownTypes: make(map[string]*structType),
|
||||
typesByName: make(map[string]*structType),
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
func sortPaths(paths []string) []string {
|
||||
sorted := make([]string, len(paths))
|
||||
copy(sorted, paths)
|
||||
sort.Strings(sorted)
|
||||
return sorted
|
||||
}
|
||||
|
||||
func TestAnalyzePrimitive(t *testing.T) {
|
||||
a := testAnalyzer(t)
|
||||
tests := []struct {
|
||||
name string
|
||||
json string
|
||||
want string
|
||||
}{
|
||||
{"null", "", ".{null}"},
|
||||
{"bool", "", ".{bool}"},
|
||||
{"int", "", ".{int}"},
|
||||
{"float", "", ".{float}"},
|
||||
{"string", "", ".{string}"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var val any
|
||||
switch tt.name {
|
||||
case "null":
|
||||
val = nil
|
||||
case "bool":
|
||||
val = true
|
||||
case "int":
|
||||
val = jsonNum("42")
|
||||
case "float":
|
||||
val = jsonNum("3.14")
|
||||
case "string":
|
||||
val = "hello"
|
||||
}
|
||||
paths := a.Analyze(".", val)
|
||||
if len(paths) != 1 || paths[0] != tt.want {
|
||||
t.Errorf("got %v, want [%s]", paths, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeSimpleStruct(t *testing.T) {
|
||||
a := testAnalyzer(t)
|
||||
obj := map[string]any{
|
||||
"name": "Alice",
|
||||
"age": jsonNum("30"),
|
||||
}
|
||||
paths := sortPaths(a.Analyze(".", obj))
|
||||
want := sortPaths([]string{
|
||||
".{Root}.age{int}",
|
||||
".{Root}.name{string}",
|
||||
})
|
||||
assertPaths(t, paths, want)
|
||||
}
|
||||
|
||||
func TestAnalyzeMapDetection(t *testing.T) {
|
||||
a := testAnalyzer(t)
|
||||
// Keys with digits + same length → detected as map
|
||||
obj := map[string]any{
|
||||
"abc123": map[string]any{"name": "a"},
|
||||
"def456": map[string]any{"name": "b"},
|
||||
"ghi789": map[string]any{"name": "c"},
|
||||
}
|
||||
paths := sortPaths(a.Analyze(".", obj))
|
||||
want := sortPaths([]string{
|
||||
".[string]{RootItem}.name{string}",
|
||||
})
|
||||
assertPaths(t, paths, want)
|
||||
}
|
||||
|
||||
func TestAnalyzeArrayOfObjects(t *testing.T) {
|
||||
a := testAnalyzer(t)
|
||||
arr := []any{
|
||||
map[string]any{"x": jsonNum("1")},
|
||||
map[string]any{"x": jsonNum("2")},
|
||||
}
|
||||
paths := sortPaths(a.Analyze(".", arr))
|
||||
want := sortPaths([]string{
|
||||
".[]{RootItem}.x{int}",
|
||||
})
|
||||
assertPaths(t, paths, want)
|
||||
}
|
||||
|
||||
func TestAnalyzeOptionalFields(t *testing.T) {
|
||||
a := testAnalyzer(t)
|
||||
// Two objects with different fields → same type with optional fields
|
||||
values := []any{
|
||||
map[string]any{"name": "Alice", "age": jsonNum("30")},
|
||||
map[string]any{"name": "Bob"},
|
||||
}
|
||||
paths := sortPaths(a.analyzeCollectionValues(".[]", values))
|
||||
want := sortPaths([]string{
|
||||
".[]{RootItem}.age{null}",
|
||||
".[]{RootItem}.age{int}",
|
||||
".[]{RootItem}.name{string}",
|
||||
})
|
||||
assertPaths(t, paths, want)
|
||||
}
|
||||
|
||||
func TestAnalyzeNullableField(t *testing.T) {
|
||||
a := testAnalyzer(t)
|
||||
values := []any{
|
||||
map[string]any{"data": nil},
|
||||
map[string]any{"data": "hello"},
|
||||
}
|
||||
paths := sortPaths(a.analyzeCollectionValues(".[]", values))
|
||||
want := sortPaths([]string{
|
||||
".[]{RootItem}.data{null}",
|
||||
".[]{RootItem}.data{string}",
|
||||
})
|
||||
assertPaths(t, paths, want)
|
||||
}
|
||||
|
||||
func TestAnalyzeEmptyArray(t *testing.T) {
|
||||
a := testAnalyzer(t)
|
||||
paths := a.Analyze(".", []any{})
|
||||
want := []string{".[]{any}"}
|
||||
assertPaths(t, paths, want)
|
||||
}
|
||||
|
||||
func TestAnalyzeEmptyObject(t *testing.T) {
|
||||
a := testAnalyzer(t)
|
||||
paths := a.Analyze(".", map[string]any{})
|
||||
want := []string{".{any}"}
|
||||
assertPaths(t, paths, want)
|
||||
}
|
||||
|
||||
func TestHeuristicsMapDetection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
keys []string
|
||||
wantMap bool
|
||||
wantConf bool
|
||||
}{
|
||||
{"numeric keys", []string{"1", "2", "3"}, true, true},
|
||||
{"alphanum IDs", []string{"abc123", "def456", "ghi789"}, true, true},
|
||||
{"field names", []string{"name", "age", "email"}, false, true},
|
||||
{"two keys", []string{"ab", "cd"}, false, false},
|
||||
{"hex IDs", []string{"a1b2c3d4", "e5f6a7b8", "c9d0e1f2"}, true, true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
obj := make(map[string]any)
|
||||
for _, k := range tt.keys {
|
||||
obj[k] = "value"
|
||||
}
|
||||
isMap, confident := looksLikeMap(obj)
|
||||
if isMap != tt.wantMap || confident != tt.wantConf {
|
||||
t.Errorf("looksLikeMap(%v) = (%v, %v), want (%v, %v)",
|
||||
tt.keys, isMap, confident, tt.wantMap, tt.wantConf)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInferTypeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{".[person_id]", "Person"},
|
||||
{".{Root}.friends[]", "Friend"},
|
||||
{".{Root}.address", "Address"},
|
||||
{".", "Root"},
|
||||
{".[]", "RootItem"},
|
||||
{".[string]", "RootItem"},
|
||||
{".[int]", "RootItem"},
|
||||
{".{Root}.json", "RootJSON"},
|
||||
{".{Root}.data", "RootData"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
got := inferTypeName(tt.path)
|
||||
if got != tt.want {
|
||||
t.Errorf("inferTypeName(%q) = %q, want %q", tt.path, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingularize(t *testing.T) {
|
||||
tests := []struct {
|
||||
in, want string
|
||||
}{
|
||||
{"Friends", "Friend"},
|
||||
{"Categories", "Category"},
|
||||
{"Boxes", "Box"},
|
||||
{"Address", "Address"},
|
||||
{"Bus", "Bus"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.in, func(t *testing.T) {
|
||||
got := singularize(tt.in)
|
||||
if got != tt.want {
|
||||
t.Errorf("singularize(%q) = %q, want %q", tt.in, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeNameSubsetExtends(t *testing.T) {
|
||||
// When two objects at the SAME path have overlapping fields (one a subset),
|
||||
// they should get the same type name (via name collision + subset merge).
|
||||
// Objects at DIFFERENT paths get separate types even if fields overlap,
|
||||
// because they represent different domain concepts.
|
||||
a := testAnalyzer(t)
|
||||
arr := []any{
|
||||
map[string]any{"name": "Alice", "age": jsonNum("30")},
|
||||
map[string]any{"name": "Bob", "age": jsonNum("25"), "email": "bob@example.com"},
|
||||
}
|
||||
obj := map[string]any{"people": arr}
|
||||
paths := sortPaths(a.Analyze(".", obj))
|
||||
|
||||
// Both array elements should be unified under the same type
|
||||
typeName := ""
|
||||
for _, p := range paths {
|
||||
if strings.Contains(p, ".people[]") {
|
||||
if idx := strings.Index(p, "{"); idx >= 0 {
|
||||
end := strings.Index(p[idx:], "}")
|
||||
if typeName == "" {
|
||||
typeName = p[idx+1 : idx+end]
|
||||
} else if p[idx+1:idx+end] != typeName && p[idx+1:idx+end] != "Root" {
|
||||
t.Errorf("expected all people paths to use type %q, got %s", typeName, p)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if typeName == "" {
|
||||
t.Fatal("expected a type name for people array elements")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParentTypeName(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{".[id]{Document}.rooms[]{Room}.details", "Room"},
|
||||
{".[id]{Document}.name", "Document"},
|
||||
{".items[]", ""},
|
||||
{".{Root}.data{null}", "Root"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
got := parentTypeName(tt.path)
|
||||
if got != tt.want {
|
||||
t.Errorf("parentTypeName(%q) = %q, want %q", tt.path, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShortPath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
".{RoomsResult}.rooms[]{Room}.room[string][]{RoomRoom}.json{RoomRoomJSON}.feature_types[]",
|
||||
".rooms[].room[string][].json{RoomRoomJSON}.feature_types[]",
|
||||
},
|
||||
{
|
||||
".{Root}.name{string}",
|
||||
".name{string}",
|
||||
},
|
||||
{
|
||||
".",
|
||||
".",
|
||||
},
|
||||
{
|
||||
".{Root}",
|
||||
".{Root}",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
got := shortPath(tt.path)
|
||||
if got != tt.want {
|
||||
t.Errorf("shortPath(%q)\n got: %q\n want: %q", tt.path, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggestAlternativeNameUsesParent(t *testing.T) {
|
||||
a := testAnalyzer(t)
|
||||
// Register a type named "Room"
|
||||
a.registerType("a,b", "Room", map[string]string{"a": "string", "b": "string"})
|
||||
|
||||
// Suggest alternative at a path under {Document}
|
||||
got := a.suggestAlternativeName(".[id]{Document}.rooms[]", "Room")
|
||||
if got != "DocumentRoom" {
|
||||
t.Errorf("got %q, want %q", got, "DocumentRoom")
|
||||
}
|
||||
|
||||
// Register DocumentRoom too, then it should fall back to numbered
|
||||
a.registerType("c,d", "DocumentRoom", map[string]string{"c": "string", "d": "string"})
|
||||
got = a.suggestAlternativeName(".[id]{Document}.rooms[]", "Room")
|
||||
if !strings.HasPrefix(got, "Room") || got == "Room" || got == "DocumentRoom" {
|
||||
t.Errorf("expected numbered fallback, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutoResolveCollision(t *testing.T) {
|
||||
a := testAnalyzer(t)
|
||||
// Register a type named "Room" with fields {a, b}
|
||||
a.registerType("a,b", "Room", map[string]string{"a": "string", "b": "string"})
|
||||
|
||||
// Analyze an object at a path under {Document} that would infer "Room"
|
||||
// but has completely different fields — should auto-resolve to "DocumentRoom"
|
||||
obj := map[string]any{"x": "1", "y": "2"}
|
||||
paths := a.Analyze(".{Document}.room", obj)
|
||||
|
||||
hasDocumentRoom := false
|
||||
for _, p := range paths {
|
||||
if strings.Contains(p, "{DocumentRoom}") {
|
||||
hasDocumentRoom = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasDocumentRoom {
|
||||
t.Errorf("expected DocumentRoom type, got:\n %s", strings.Join(paths, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPooledMapDetection(t *testing.T) {
|
||||
// Multiple objects each with 1-2 numeric keys should be detected as maps
|
||||
// even though individually they have too few keys for heuristics.
|
||||
a := testAnalyzer(t)
|
||||
values := []any{
|
||||
map[string]any{"230108": "a"},
|
||||
map[string]any{"138666": "b"},
|
||||
map[string]any{"162359": "c"},
|
||||
map[string]any{},
|
||||
}
|
||||
paths := sortPaths(a.analyzeCollectionValues(".data", values))
|
||||
// Should detect as maps with numeric keys → [int] (map index, not array)
|
||||
hasMapPath := false
|
||||
for _, p := range paths {
|
||||
if strings.Contains(p, "[int]") || strings.Contains(p, "[string]") {
|
||||
hasMapPath = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasMapPath {
|
||||
t.Errorf("expected map detection (paths with [int] or [string]), got:\n %s",
|
||||
strings.Join(paths, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeFullSample(t *testing.T) {
|
||||
a := testAnalyzer(t)
|
||||
|
||||
data := map[string]any{
|
||||
"abc123": map[string]any{
|
||||
"name": "Alice",
|
||||
"age": jsonNum("30"),
|
||||
"active": true,
|
||||
"friends": []any{
|
||||
map[string]any{"name": "Bob", "identification": nil},
|
||||
map[string]any{"name": "Charlie", "identification": map[string]any{
|
||||
"type": "StateID", "number": "12345", "name": "Charlie C",
|
||||
}},
|
||||
},
|
||||
},
|
||||
"def456": map[string]any{
|
||||
"name": "Dave", "age": jsonNum("25"), "active": false, "friends": []any{},
|
||||
},
|
||||
"ghi789": map[string]any{
|
||||
"name": "Eve", "age": jsonNum("28"), "active": true, "score": jsonNum("95.5"),
|
||||
"friends": []any{
|
||||
map[string]any{"name": "Frank", "identification": map[string]any{
|
||||
"type": "DriverLicense", "id": "DL-999", "name": "Frank F",
|
||||
"restrictions": []any{"corrective lenses"},
|
||||
}},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
paths := sortPaths(a.Analyze(".", data))
|
||||
want := sortPaths([]string{
|
||||
".[string]{RootItem}.active{bool}",
|
||||
".[string]{RootItem}.age{int}",
|
||||
".[string]{RootItem}.friends[]{Friend}.identification{null}",
|
||||
".[string]{RootItem}.friends[]{Friend}.identification{Identification}.id{null}",
|
||||
".[string]{RootItem}.friends[]{Friend}.identification{Identification}.id{string}",
|
||||
".[string]{RootItem}.friends[]{Friend}.identification{Identification}.name{string}",
|
||||
".[string]{RootItem}.friends[]{Friend}.identification{Identification}.number{null}",
|
||||
".[string]{RootItem}.friends[]{Friend}.identification{Identification}.number{string}",
|
||||
".[string]{RootItem}.friends[]{Friend}.identification{Identification}.restrictions{null}",
|
||||
".[string]{RootItem}.friends[]{Friend}.identification{Identification}.restrictions[]{string}",
|
||||
".[string]{RootItem}.friends[]{Friend}.identification{Identification}.type{string}",
|
||||
".[string]{RootItem}.friends[]{Friend}.name{string}",
|
||||
".[string]{RootItem}.name{string}",
|
||||
".[string]{RootItem}.score{null}",
|
||||
".[string]{RootItem}.score{float}",
|
||||
})
|
||||
assertPaths(t, paths, want)
|
||||
}
|
||||
|
||||
// TestDifferentTypesPromptsForNames verifies that when the user chooses
|
||||
// "different" for multiple shapes at the same path:
|
||||
// 1. They are prompted to name each shape group
|
||||
// 2. All names are collected BEFORE recursing into children
|
||||
// 3. The named types appear in the final output
|
||||
func TestDifferentTypesPromptsForNames(t *testing.T) {
|
||||
// Simulate: a Room has items[] containing two distinct shapes, each with
|
||||
// a nested "meta" object. Names should be asked for both shapes before
|
||||
// the meta objects are analyzed.
|
||||
arr := []any{
|
||||
// Shape 1: has "filename" and "is_required"
|
||||
map[string]any{"slug": "a", "filename": "x.pdf", "is_required": true,
|
||||
"meta": map[string]any{"size": jsonNum("100")}},
|
||||
map[string]any{"slug": "b", "filename": "y.pdf", "is_required": false,
|
||||
"meta": map[string]any{"size": jsonNum("200")}},
|
||||
// Shape 2: has "feature" and "archived"
|
||||
map[string]any{"slug": "c", "feature": "upload", "archived": false,
|
||||
"meta": map[string]any{"version": jsonNum("1")}},
|
||||
map[string]any{"slug": "d", "feature": "export", "archived": true,
|
||||
"meta": map[string]any{"version": jsonNum("2")}},
|
||||
}
|
||||
|
||||
var output strings.Builder
|
||||
a := &Analyzer{
|
||||
Prompter: &Prompter{
|
||||
reader: bufio.NewReader(strings.NewReader("")),
|
||||
output: &output,
|
||||
priorAnswers: []string{"d", "FileField", "FeatureField"},
|
||||
},
|
||||
knownTypes: make(map[string]*structType),
|
||||
typesByName: make(map[string]*structType),
|
||||
}
|
||||
paths := sortPaths(a.Analyze(".{Room}.items[]", arr))
|
||||
|
||||
// Verify both named types appear in the paths
|
||||
hasFileField := false
|
||||
hasFeatureField := false
|
||||
for _, p := range paths {
|
||||
if strings.Contains(p, "{FileField}") {
|
||||
hasFileField = true
|
||||
}
|
||||
if strings.Contains(p, "{FeatureField}") {
|
||||
hasFeatureField = true
|
||||
}
|
||||
}
|
||||
if !hasFileField {
|
||||
t.Errorf("expected {FileField} type in paths:\n %s", strings.Join(paths, "\n "))
|
||||
}
|
||||
if !hasFeatureField {
|
||||
t.Errorf("expected {FeatureField} type in paths:\n %s", strings.Join(paths, "\n "))
|
||||
}
|
||||
|
||||
// Verify that both "Name for shape" prompts appear before any deeper prompts
|
||||
out := output.String()
|
||||
name1Idx := strings.Index(out, "Name for shape 1?")
|
||||
name2Idx := strings.Index(out, "Name for shape 2?")
|
||||
if name1Idx < 0 || name2Idx < 0 {
|
||||
t.Fatalf("expected both shape name prompts in output:\n%s", out)
|
||||
}
|
||||
if name1Idx > name2Idx {
|
||||
t.Errorf("shape 1 name prompt should appear before shape 2")
|
||||
}
|
||||
|
||||
// Verify the formatted output includes these types
|
||||
formatted := FormatPaths(paths)
|
||||
foundFileField := false
|
||||
foundFeatureField := false
|
||||
for _, line := range formatted {
|
||||
if strings.Contains(line, "{FileField}") {
|
||||
foundFileField = true
|
||||
}
|
||||
if strings.Contains(line, "{FeatureField}") {
|
||||
foundFeatureField = true
|
||||
}
|
||||
}
|
||||
if !foundFileField {
|
||||
t.Errorf("formatted output missing {FileField}:\n %s", strings.Join(formatted, "\n "))
|
||||
}
|
||||
if !foundFeatureField {
|
||||
t.Errorf("formatted output missing {FeatureField}:\n %s", strings.Join(formatted, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestCombinedPromptShowsTypeName verifies the default-mode prompt shows
|
||||
// [Root/m] (inferred name + map option), not [s/m] or [S/m].
|
||||
func TestCombinedPromptShowsTypeName(t *testing.T) {
|
||||
var output strings.Builder
|
||||
a := &Analyzer{
|
||||
Prompter: &Prompter{
|
||||
reader: bufio.NewReader(strings.NewReader("")),
|
||||
output: &output,
|
||||
priorAnswers: []string{"Root"}, // accept default
|
||||
},
|
||||
knownTypes: make(map[string]*structType),
|
||||
typesByName: make(map[string]*structType),
|
||||
}
|
||||
|
||||
obj := map[string]any{
|
||||
"errors": []any{},
|
||||
"rooms": []any{map[string]any{"name": "foo"}},
|
||||
}
|
||||
a.Analyze(".", obj)
|
||||
|
||||
out := output.String()
|
||||
if !strings.Contains(out, "[Root/m]") {
|
||||
t.Errorf("expected prompt to contain [Root/m], got output:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestCombinedPromptIgnoresOldPriorAnswer verifies that prior answers like
|
||||
// "s" from old answer files don't corrupt the prompt default.
|
||||
func TestCombinedPromptIgnoresOldPriorAnswer(t *testing.T) {
|
||||
var output strings.Builder
|
||||
a := &Analyzer{
|
||||
Prompter: &Prompter{
|
||||
reader: bufio.NewReader(strings.NewReader("")),
|
||||
output: &output,
|
||||
priorAnswers: []string{"s"}, // old-style answer
|
||||
},
|
||||
knownTypes: make(map[string]*structType),
|
||||
typesByName: make(map[string]*structType),
|
||||
}
|
||||
|
||||
obj := map[string]any{
|
||||
"errors": []any{},
|
||||
"rooms": []any{map[string]any{"name": "foo"}},
|
||||
}
|
||||
a.Analyze(".", obj)
|
||||
|
||||
out := output.String()
|
||||
if strings.Contains(out, "[s/m]") {
|
||||
t.Errorf("old prior answer 's' should not appear in prompt, got output:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "[Root/m]") {
|
||||
t.Errorf("expected prompt to contain [Root/m], got output:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestOldAnswerFileDoesNotDesync verifies that an old-format answer "s" for
|
||||
// map/struct is consumed (not skipped), so subsequent answers stay in sync.
|
||||
func TestOldAnswerFileDoesNotDesync(t *testing.T) {
|
||||
// Prior answers: "s" (old struct answer for root), then "s" (same type
|
||||
// for a shape unification prompt). The "s" at position 0 should be consumed
|
||||
// by askMapOrName (treated as "accept default"), and "s" at position 1
|
||||
// should be consumed by the ask() for same/different.
|
||||
a := testInteractiveAnalyzer(t, []string{
|
||||
"s", // old-format: accept struct default → Root
|
||||
"s", // same type for shapes
|
||||
})
|
||||
|
||||
// An array with two shapes that will trigger unification prompt
|
||||
arr := []any{
|
||||
map[string]any{"name": "Alice", "x": jsonNum("1")},
|
||||
map[string]any{"name": "Bob", "y": jsonNum("2")},
|
||||
}
|
||||
obj := map[string]any{"items": arr}
|
||||
paths := sortPaths(a.Analyze(".", obj))
|
||||
|
||||
// Should have Root type (from "s" → accept default) and Item type
|
||||
// unified as same type (from "s" → same)
|
||||
hasRoot := false
|
||||
for _, p := range paths {
|
||||
if strings.Contains(p, "{Root}") {
|
||||
hasRoot = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !hasRoot {
|
||||
t.Errorf("expected {Root} type (old 's' should accept default), got:\n %s",
|
||||
strings.Join(paths, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultDifferentWhenUniqueFieldsDominate verifies that when shapes share
|
||||
// only ubiquitous fields (slug, name, etc.) and have many unique fields, the
|
||||
// prompt defaults to "d" (different) instead of "s" (same).
|
||||
func TestDefaultDifferentWhenUniqueFieldsDominate(t *testing.T) {
|
||||
// Two shapes sharing only "slug" (ubiquitous) with 2+ unique fields each.
|
||||
// With no prior answer for same/different, the default should be "d".
|
||||
// Then we need type names for each shape.
|
||||
// Shape ordering is insertion order: shape 1 = filename,is_required,slug; shape 2 = archived,feature,slug
|
||||
a := testInteractiveAnalyzer(t, []string{
|
||||
"Root", // root object has 1 key → not confident, prompts for struct/map
|
||||
"d", // accept default (should be "d" because unique >> meaningful shared)
|
||||
"FileField", // name for shape 1 (filename, is_required, slug)
|
||||
"FeatureField", // name for shape 2 (archived, feature, slug)
|
||||
})
|
||||
|
||||
arr := []any{
|
||||
map[string]any{"slug": "a", "filename": "x.pdf", "is_required": true},
|
||||
map[string]any{"slug": "b", "feature": "upload", "archived": false},
|
||||
}
|
||||
obj := map[string]any{"items": arr}
|
||||
paths := sortPaths(a.Analyze(".", obj))
|
||||
|
||||
// Should have both FileField and FeatureField as separate types
|
||||
hasFile := false
|
||||
hasFeature := false
|
||||
for _, p := range paths {
|
||||
if strings.Contains(p, "{FileField}") {
|
||||
hasFile = true
|
||||
}
|
||||
if strings.Contains(p, "{FeatureField}") {
|
||||
hasFeature = true
|
||||
}
|
||||
}
|
||||
if !hasFile || !hasFeature {
|
||||
t.Errorf("expected both FileField and FeatureField types, got:\n %s",
|
||||
strings.Join(paths, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestDefaultSameWhenMeaningfulFieldsShared verifies that when shapes share
|
||||
// many non-ubiquitous fields, the prompt defaults to "s" (same).
|
||||
func TestDefaultSameWhenMeaningfulFieldsShared(t *testing.T) {
|
||||
// Two shapes sharing "email", "phone", "address" (non-ubiquitous) with
|
||||
// only 1 unique field each. unique (2) < 2 * meaningful shared (3), so
|
||||
// default should be "s".
|
||||
a := testInteractiveAnalyzer(t, []string{
|
||||
"Root", // root object has 1 key → not confident, prompts for struct/map
|
||||
"s", // accept default (should be "s")
|
||||
})
|
||||
|
||||
arr := []any{
|
||||
map[string]any{"email": "a@b.com", "phone": "555", "address": "123 Main", "vip": true},
|
||||
map[string]any{"email": "c@d.com", "phone": "666", "address": "456 Oak", "score": jsonNum("42")},
|
||||
}
|
||||
obj := map[string]any{"people": arr}
|
||||
paths := sortPaths(a.Analyze(".", obj))
|
||||
|
||||
// Should be unified as one type (People → singular People) with optional fields
|
||||
typeCount := 0
|
||||
for _, p := range paths {
|
||||
if strings.Contains(p, "{People}") {
|
||||
typeCount++
|
||||
}
|
||||
}
|
||||
if typeCount == 0 {
|
||||
t.Errorf("expected People type (same default), got:\n %s",
|
||||
strings.Join(paths, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsUbiquitousField checks the ubiquitous field classifier.
|
||||
func TestIsUbiquitousField(t *testing.T) {
|
||||
ubiquitous := []string{
|
||||
"id", "ID", "Id", "_id",
|
||||
"name", "Name",
|
||||
"type", "Type", "_type",
|
||||
"slug", "Slug",
|
||||
"label", "Label",
|
||||
"title", "Title",
|
||||
"created_at", "updated_at", "deleted_on",
|
||||
"startedAt", "endedOn",
|
||||
}
|
||||
for _, f := range ubiquitous {
|
||||
if !isUbiquitousField(f) {
|
||||
t.Errorf("expected %q to be ubiquitous", f)
|
||||
}
|
||||
}
|
||||
|
||||
notUbiquitous := []string{
|
||||
"email", "phone", "address", "filename", "feature",
|
||||
"is_required", "archived", "score", "vip",
|
||||
"cat", "latitude", "url",
|
||||
}
|
||||
for _, f := range notUbiquitous {
|
||||
if isUbiquitousField(f) {
|
||||
t.Errorf("expected %q to NOT be ubiquitous", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// testInteractiveAnalyzer creates an analyzer with scripted answers (not anonymous).
|
||||
func testInteractiveAnalyzer(t *testing.T, answers []string) *Analyzer {
|
||||
t.Helper()
|
||||
a := &Analyzer{
|
||||
Prompter: &Prompter{
|
||||
reader: bufio.NewReader(strings.NewReader("")),
|
||||
output: io.Discard,
|
||||
priorAnswers: answers,
|
||||
},
|
||||
knownTypes: make(map[string]*structType),
|
||||
typesByName: make(map[string]*structType),
|
||||
}
|
||||
return a
|
||||
}
|
||||
|
||||
// helpers
|
||||
|
||||
func jsonNum(s string) json.Number {
|
||||
return json.Number(s)
|
||||
}
|
||||
|
||||
func assertPaths(t *testing.T, got, want []string) {
|
||||
t.Helper()
|
||||
if len(got) != len(want) {
|
||||
t.Errorf("got %d paths, want %d:\n got: %s\n want: %s",
|
||||
len(got), len(want), strings.Join(got, "\n "), strings.Join(want, "\n "))
|
||||
return
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("path[%d]: got %q, want %q", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
199
tools/jsontypes/cmd/jsonpaths/README.md
Normal file
199
tools/jsontypes/cmd/jsonpaths/README.md
Normal file
@ -0,0 +1,199 @@
|
||||
# jsonpaths
|
||||
|
||||
<!--
|
||||
Authored in 2026 by AJ ONeal <aj@therootcompany.com>, generated by Claude Opus 4.6.
|
||||
|
||||
SPDX-License-Identifier: CC0-1.0
|
||||
-->
|
||||
|
||||
Infer types from JSON. Generate code.
|
||||
|
||||
`jsonpaths` reads a JSON sample (file, URL, or stdin), walks the structure,
|
||||
and outputs typed definitions in your choice of 9 formats. No schema file
|
||||
needed — just point it at real data.
|
||||
|
||||
```sh
|
||||
go install github.com/therootcompany/golib/tools/jsontypes/cmd/jsonpaths@latest
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
# From a file
|
||||
jsonpaths data.json
|
||||
|
||||
# From a URL (cached locally by default)
|
||||
jsonpaths https://api.example.com/users
|
||||
|
||||
# From stdin
|
||||
curl -s https://api.example.com/users | jsonpaths --anonymous
|
||||
```
|
||||
|
||||
### Output formats
|
||||
|
||||
Use `--format` to choose the output:
|
||||
|
||||
| Format | Flag | Description |
|
||||
| -------------- | ------------------------ | ------------------------------------------ |
|
||||
| json-paths | `--format json-paths` | Flat type paths (default) |
|
||||
| Go | `--format go` | Struct definitions with json tags |
|
||||
| TypeScript | `--format typescript` | Interfaces with optional/nullable fields |
|
||||
| JSDoc | `--format jsdoc` | @typedef annotations |
|
||||
| Zod | `--format zod` | Validation schemas with type inference |
|
||||
| Python | `--format python` | TypedDict classes |
|
||||
| SQL | `--format sql` | CREATE TABLE with foreign keys |
|
||||
| JSON Schema | `--format json-schema` | Draft 2020-12 |
|
||||
| JSON Typedef | `--format json-typedef` | RFC 8927 |
|
||||
|
||||
Short aliases: `ts` for typescript, `py` for python.
|
||||
|
||||
### Example
|
||||
|
||||
Given this JSON:
|
||||
|
||||
```json
|
||||
{
|
||||
"users": [
|
||||
{"id": 1, "name": "Alice", "email": "a@b.com", "active": true},
|
||||
{"id": 2, "name": "Bob", "active": false}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Default output (json-paths):
|
||||
|
||||
```
|
||||
{Root}
|
||||
.users[]{User}
|
||||
.users[].active{bool}
|
||||
.users[].email{string?}
|
||||
.users[].id{int}
|
||||
.users[].name{string}
|
||||
```
|
||||
|
||||
The `?` suffix marks fields that appear in some instances but not all.
|
||||
|
||||
With `--format go`:
|
||||
|
||||
```go
|
||||
type Root struct {
|
||||
Users []User `json:"users"`
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Id int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Active bool `json:"active"`
|
||||
Email *string `json:"email,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
With `--format typescript`:
|
||||
|
||||
```typescript
|
||||
export interface Root {
|
||||
users: User[];
|
||||
}
|
||||
|
||||
export interface User {
|
||||
id: number;
|
||||
name: string;
|
||||
active: boolean;
|
||||
email?: string | null;
|
||||
}
|
||||
```
|
||||
|
||||
### Authentication
|
||||
|
||||
For APIs that require auth, use curl-like flags:
|
||||
|
||||
```sh
|
||||
# Bearer token
|
||||
jsonpaths --bearer $TOKEN https://api.example.com/me
|
||||
|
||||
# Basic auth
|
||||
jsonpaths --user admin:secret https://internal.example.com/data
|
||||
|
||||
# Arbitrary headers
|
||||
jsonpaths -H 'X-API-Key: abc123' https://api.example.com/data
|
||||
|
||||
# Cookie (accepts both Cookie and Set-Cookie format)
|
||||
jsonpaths --cookie 'session=abc123' https://app.example.com/api/me
|
||||
|
||||
# Netscape cookie jar
|
||||
jsonpaths --cookie-jar cookies.txt https://app.example.com/api/me
|
||||
```
|
||||
|
||||
### Interactive vs anonymous mode
|
||||
|
||||
By default, `jsonpaths` prompts when it encounters ambiguous structure
|
||||
(e.g., "is this a map or a struct?"). Use `--anonymous` to skip all
|
||||
prompts and rely on heuristics:
|
||||
|
||||
```sh
|
||||
# Non-interactive (for scripts, CI, AI agents)
|
||||
cat data.json | jsonpaths --anonymous --format go
|
||||
|
||||
# Interactive (from a file or URL)
|
||||
jsonpaths data.json
|
||||
```
|
||||
|
||||
When run interactively against a file or URL, answers are saved to
|
||||
`<basename>.answers` and replayed on subsequent runs. This makes
|
||||
iterative refinement fast — change one answer and re-run.
|
||||
|
||||
### Caching
|
||||
|
||||
URL responses are cached locally as `<slugified-url>.json`. Sensitive
|
||||
query parameters (tokens, keys, passwords) are stripped from the filename.
|
||||
Use `--no-cache` to re-fetch.
|
||||
|
||||
## json-paths format
|
||||
|
||||
The intermediate representation is a flat list of typed paths:
|
||||
|
||||
```
|
||||
{Root} # root type
|
||||
.users[]{User} # array of User
|
||||
.users[].id{int} # field with type
|
||||
.users[].email{string?} # optional (nullable) field
|
||||
.metadata[string]{Meta} # map with string keys
|
||||
.tags[]{string} # array of primitives
|
||||
.data[]{any} # empty array (unknown element type)
|
||||
```
|
||||
|
||||
**Type annotations:** `{string}`, `{int}`, `{float}`, `{bool}`, `{null}`,
|
||||
`{any}`, or a named struct like `{User}`. The `?` suffix means nullable/optional.
|
||||
|
||||
**Index annotations:** `[]` for arrays, `[string]` for maps, `[0]`, `[1]` for tuples.
|
||||
|
||||
This format is designed to be both human-scannable and machine-parseable,
|
||||
making it useful as context for AI agents that need to understand an API's
|
||||
shape before generating code.
|
||||
|
||||
## Library usage
|
||||
|
||||
The core logic is available as a Go package:
|
||||
|
||||
```go
|
||||
import "github.com/therootcompany/golib/tools/jsontypes"
|
||||
|
||||
a, _ := jsontypes.NewAnalyzer(false, true, false) // anonymous mode
|
||||
defer a.Close()
|
||||
|
||||
var data any
|
||||
// ... json.Decode with UseNumber() ...
|
||||
|
||||
paths := jsontypes.FormatPaths(a.Analyze(".", data))
|
||||
fmt.Print(jsontypes.GenerateTypeScript(paths))
|
||||
```
|
||||
|
||||
See the [package documentation](https://pkg.go.dev/github.com/therootcompany/golib/tools/jsontypes)
|
||||
for the full API.
|
||||
|
||||
## License
|
||||
|
||||
Authored by [AJ ONeal](mailto:aj@therootcompany.com), generated by Claude
|
||||
Opus 4.6, with light human guidance.
|
||||
|
||||
[CC0-1.0](https://creativecommons.org/publicdomain/zero/1.0/) — Public Domain.
|
||||
447
tools/jsontypes/cmd/jsonpaths/main.go
Normal file
447
tools/jsontypes/cmd/jsonpaths/main.go
Normal file
@ -0,0 +1,447 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/tls"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/therootcompany/golib/tools/jsontypes"
|
||||
)
|
||||
|
||||
const (
|
||||
name = "jsonpaths"
|
||||
description = "Infer types from JSON. Generate code."
|
||||
)
|
||||
|
||||
var (
|
||||
version = "0.0.0-dev"
|
||||
commit = "0000000"
|
||||
date = "0001-01-01"
|
||||
)
|
||||
|
||||
func printVersion(w io.Writer) {
|
||||
fmt.Fprintf(w, "%s v%s %s (%s)\n", name, version, commit[:7], date)
|
||||
fmt.Fprintf(w, "%s\n", description)
|
||||
}
|
||||
|
||||
// headerList implements flag.Value for repeatable -H flags.
|
||||
type headerList []string
|
||||
|
||||
func (h *headerList) String() string { return strings.Join(*h, ", ") }
|
||||
func (h *headerList) Set(val string) error {
|
||||
if !strings.Contains(val, ":") {
|
||||
return fmt.Errorf("header must be in 'Name: Value' format")
|
||||
}
|
||||
*h = append(*h, val)
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
// Exit cleanly on Ctrl+C
|
||||
sig := make(chan os.Signal, 1)
|
||||
signal.Notify(sig, os.Interrupt)
|
||||
go func() {
|
||||
<-sig
|
||||
fmt.Fprintln(os.Stderr)
|
||||
os.Exit(130)
|
||||
}()
|
||||
|
||||
var headers headerList
|
||||
flag.Var(&headers, "H", "add HTTP header (repeatable, e.g. -H 'X-API-Key: abc')")
|
||||
anonymous := flag.Bool("anonymous", false, "skip all prompts; use heuristics and auto-inferred names")
|
||||
askTypes := flag.Bool("ask-types", false, "prompt for each type name instead of auto-inferring")
|
||||
bearer := flag.String("bearer", "", "set Authorization: Bearer token")
|
||||
cookie := flag.String("cookie", "", "send cookie (name=value or Set-Cookie format)")
|
||||
cookieJar := flag.String("cookie-jar", "", "read cookies from Netscape cookie jar file")
|
||||
format := flag.String("format", "json-paths", "output format: json-paths, go, json-schema, json-typedef, typescript, jsdoc, zod, python, sql")
|
||||
timeout := flag.Duration("timeout", 20*time.Second, "HTTP request timeout for URL inputs")
|
||||
noCache := flag.Bool("no-cache", false, "skip local cache for URL inputs")
|
||||
user := flag.String("user", "", "HTTP basic auth (user:password, like curl)")
|
||||
|
||||
flag.Usage = func() {
|
||||
fmt.Fprintf(os.Stderr, "USAGE\n %s [flags] [file | url]\n\n", name)
|
||||
fmt.Fprintf(os.Stderr, "FLAGS\n")
|
||||
flag.PrintDefaults()
|
||||
}
|
||||
|
||||
// Handle version/help before flag parse
|
||||
if len(os.Args) > 1 {
|
||||
arg := os.Args[1]
|
||||
if arg == "-V" || arg == "--version" || arg == "version" {
|
||||
printVersion(os.Stdout)
|
||||
os.Exit(0)
|
||||
}
|
||||
if arg == "help" || arg == "-help" || arg == "--help" {
|
||||
printVersion(os.Stdout)
|
||||
fmt.Fprintln(os.Stdout)
|
||||
flag.CommandLine.SetOutput(os.Stdout)
|
||||
flag.Usage()
|
||||
os.Exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
flag.Parse()
|
||||
|
||||
var input io.Reader
|
||||
var baseName string // base filename for .paths and .answers files
|
||||
inputIsStdin := true
|
||||
// Build extra HTTP headers from flags
|
||||
var extraHeaders http.Header
|
||||
if *bearer != "" || *user != "" || *cookie != "" || *cookieJar != "" || len(headers) > 0 {
|
||||
extraHeaders = make(http.Header)
|
||||
}
|
||||
for _, h := range headers {
|
||||
name, value, _ := strings.Cut(h, ":")
|
||||
extraHeaders.Add(strings.TrimSpace(name), strings.TrimSpace(value))
|
||||
}
|
||||
if *bearer != "" {
|
||||
extraHeaders.Set("Authorization", "Bearer "+*bearer)
|
||||
}
|
||||
if *user != "" {
|
||||
extraHeaders.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString([]byte(*user)))
|
||||
}
|
||||
if *cookie != "" {
|
||||
extraHeaders.Add("Cookie", parseCookieFlag(*cookie))
|
||||
}
|
||||
if *cookieJar != "" {
|
||||
cookies, err := readCookieJar(*cookieJar)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error reading cookie jar: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
for _, c := range cookies {
|
||||
extraHeaders.Add("Cookie", c)
|
||||
}
|
||||
}
|
||||
|
||||
if args := flag.Args(); len(args) > 0 && args[0] != "-" {
|
||||
arg := args[0]
|
||||
if strings.HasPrefix(arg, "https://") || strings.HasPrefix(arg, "http://") {
|
||||
r, err := fetchOrCache(arg, *timeout, *noCache, extraHeaders)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer r.Close()
|
||||
input = r
|
||||
baseName = stripExt(slugify(arg))
|
||||
} else {
|
||||
f, err := os.Open(arg)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer f.Close()
|
||||
input = f
|
||||
baseName = stripExt(arg)
|
||||
}
|
||||
inputIsStdin = false
|
||||
} else {
|
||||
input = os.Stdin
|
||||
}
|
||||
|
||||
var data any
|
||||
dec := json.NewDecoder(input)
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&data); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error parsing JSON: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
a, err := jsontypes.NewAnalyzer(inputIsStdin, *anonymous, *askTypes)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "error: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
defer a.Close()
|
||||
|
||||
// Load prior answers if available
|
||||
if baseName != "" && !*anonymous {
|
||||
a.Prompter.LoadAnswers(baseName + ".answers")
|
||||
}
|
||||
|
||||
rawPaths := a.Analyze(".", data)
|
||||
formatted := jsontypes.FormatPaths(rawPaths)
|
||||
|
||||
switch *format {
|
||||
case "go":
|
||||
fmt.Print(jsontypes.GenerateGoStructs(formatted))
|
||||
case "json-typedef":
|
||||
fmt.Print(jsontypes.GenerateTypedef(formatted))
|
||||
case "json-schema":
|
||||
fmt.Print(jsontypes.GenerateJSONSchema(formatted))
|
||||
case "typescript", "ts":
|
||||
fmt.Print(jsontypes.GenerateTypeScript(formatted))
|
||||
case "jsdoc":
|
||||
fmt.Print(jsontypes.GenerateJSDoc(formatted))
|
||||
case "zod":
|
||||
fmt.Print(jsontypes.GenerateZod(formatted))
|
||||
case "python", "py":
|
||||
fmt.Print(jsontypes.GeneratePython(formatted))
|
||||
case "sql":
|
||||
fmt.Print(jsontypes.GenerateSQL(formatted))
|
||||
case "json-paths", "paths", "":
|
||||
for _, p := range formatted {
|
||||
fmt.Println(p)
|
||||
}
|
||||
default:
|
||||
fmt.Fprintf(os.Stderr, "error: unknown format %q (use: json-paths, go, json-schema, json-typedef, typescript, jsdoc, zod, python, sql)\n", *format)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Save outputs
|
||||
if baseName != "" {
|
||||
pathsFile := baseName + ".paths"
|
||||
if err := os.WriteFile(pathsFile, []byte(strings.Join(formatted, "\n")+"\n"), 0o644); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: could not write %s: %v\n", pathsFile, err)
|
||||
}
|
||||
|
||||
if !*anonymous {
|
||||
answersFile := baseName + ".answers"
|
||||
if err := a.Prompter.SaveAnswers(answersFile); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: could not write %s: %v\n", answersFile, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stripExt(name string) string {
|
||||
if idx := strings.LastIndexByte(name, '.'); idx > 0 {
|
||||
return name[:idx]
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// slugify converts a URL to a filesystem-safe filename in the current directory.
|
||||
func slugify(rawURL string) string {
|
||||
s := rawURL
|
||||
for _, prefix := range []string{"https://", "http://"} {
|
||||
s = strings.TrimPrefix(s, prefix)
|
||||
}
|
||||
|
||||
path := s
|
||||
query := ""
|
||||
if idx := strings.IndexByte(s, '?'); idx >= 0 {
|
||||
path = s[:idx]
|
||||
query = s[idx+1:]
|
||||
}
|
||||
|
||||
if query != "" {
|
||||
var kept []string
|
||||
for _, param := range strings.Split(query, "&") {
|
||||
name := param
|
||||
if idx := strings.IndexByte(param, '='); idx >= 0 {
|
||||
name = param[:idx]
|
||||
}
|
||||
nameLower := strings.ToLower(name)
|
||||
if isSensitiveParam(nameLower) {
|
||||
continue
|
||||
}
|
||||
if len(param) > len(name)+21 {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, param)
|
||||
}
|
||||
if len(kept) > 0 {
|
||||
path = path + "-" + strings.Join(kept, "-")
|
||||
}
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
lastHyphen := false
|
||||
for _, r := range path {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '.' {
|
||||
buf.WriteRune(r)
|
||||
lastHyphen = false
|
||||
} else if !lastHyphen {
|
||||
buf.WriteByte('-')
|
||||
lastHyphen = true
|
||||
}
|
||||
}
|
||||
name := strings.Trim(buf.String(), "-")
|
||||
if len(name) > 200 {
|
||||
name = name[:200]
|
||||
}
|
||||
return name + ".json"
|
||||
}
|
||||
|
||||
var sensitiveParams = []string{
|
||||
"secret", "token", "code", "key", "apikey", "api_key",
|
||||
"password", "passwd", "auth", "credential", "session",
|
||||
"access_token", "refresh_token", "client_secret",
|
||||
}
|
||||
|
||||
func isSensitiveParam(name string) bool {
|
||||
for _, s := range sensitiveParams {
|
||||
if name == s || strings.Contains(name, s) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func fetchOrCache(rawURL string, timeout time.Duration, noCache bool, extraHeaders http.Header) (io.ReadCloser, error) {
|
||||
if !noCache {
|
||||
path := slugify(rawURL)
|
||||
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
|
||||
f, err := os.Open(path)
|
||||
if err == nil {
|
||||
fmt.Fprintf(os.Stderr, "using cached ./%s\n (use --no-cache to re-fetch)\n", path)
|
||||
return f, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
body, err := fetchURL(rawURL, timeout, extraHeaders)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if noCache {
|
||||
return body, nil
|
||||
}
|
||||
|
||||
path := slugify(rawURL)
|
||||
data, err := io.ReadAll(body)
|
||||
body.Close()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading response: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0o600); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "warning: could not cache response: %v\n", err)
|
||||
} else {
|
||||
fmt.Fprintf(os.Stderr, "cached to ./%s\n", path)
|
||||
}
|
||||
|
||||
return io.NopCloser(strings.NewReader(string(data))), nil
|
||||
}
|
||||
|
||||
func fetchURL(url string, timeout time.Duration, extraHeaders http.Header) (io.ReadCloser, error) {
|
||||
client := &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: &http.Transport{
|
||||
TLSClientConfig: &tls.Config{
|
||||
MinVersion: tls.VersionTLS12,
|
||||
},
|
||||
DialContext: (&net.Dialer{
|
||||
Timeout: 10 * time.Second,
|
||||
KeepAlive: 0,
|
||||
}).DialContext,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
ResponseHeaderTimeout: timeout,
|
||||
MaxIdleConns: 1,
|
||||
DisableKeepAlives: true,
|
||||
},
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 5 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid URL: %w", err)
|
||||
}
|
||||
req.Header.Set("Accept", "application/json")
|
||||
for name, vals := range extraHeaders {
|
||||
for _, v := range vals {
|
||||
req.Header.Add(name, v)
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
if isTimeout(err) {
|
||||
return nil, fmt.Errorf("request timed out after %s (use --timeout 60s to increase timeout for slow APIs)", timeout)
|
||||
}
|
||||
return nil, fmt.Errorf("HTTP request failed: %w", err)
|
||||
}
|
||||
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("HTTP %d %s", resp.StatusCode, resp.Status)
|
||||
}
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if ct != "" && !strings.Contains(ct, "json") && !strings.Contains(ct, "javascript") {
|
||||
resp.Body.Close()
|
||||
return nil, fmt.Errorf("unexpected Content-Type %q (expected JSON)", ct)
|
||||
}
|
||||
|
||||
return struct {
|
||||
io.Reader
|
||||
io.Closer
|
||||
}{
|
||||
Reader: io.LimitReader(resp.Body, 256<<20),
|
||||
Closer: resp.Body,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func isTimeout(err error) bool {
|
||||
if netErr, ok := err.(net.Error); ok {
|
||||
return netErr.Timeout()
|
||||
}
|
||||
return strings.Contains(err.Error(), "deadline exceeded") ||
|
||||
strings.Contains(err.Error(), "timed out")
|
||||
}
|
||||
|
||||
func parseCookieFlag(raw string) string {
|
||||
s := raw
|
||||
for _, prefix := range []string{"Set-Cookie:", "Cookie:"} {
|
||||
if strings.HasPrefix(s, prefix) {
|
||||
s = strings.TrimSpace(s[len(prefix):])
|
||||
break
|
||||
}
|
||||
lower := strings.ToLower(s)
|
||||
lowerPrefix := strings.ToLower(prefix)
|
||||
if strings.HasPrefix(lower, lowerPrefix) {
|
||||
s = strings.TrimSpace(s[len(prefix):])
|
||||
break
|
||||
}
|
||||
}
|
||||
if idx := strings.IndexByte(s, ';'); idx >= 0 {
|
||||
s = strings.TrimSpace(s[:idx])
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func readCookieJar(path string) ([]string, error) {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var cookies []string
|
||||
scanner := bufio.NewScanner(f)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
fields := strings.Split(line, "\t")
|
||||
if len(fields) < 7 {
|
||||
continue
|
||||
}
|
||||
name := fields[5]
|
||||
value := fields[6]
|
||||
cookies = append(cookies, name+"="+value)
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cookies, nil
|
||||
}
|
||||
57
tools/jsontypes/cmd/jsonpaths/slugify_test.go
Normal file
57
tools/jsontypes/cmd/jsonpaths/slugify_test.go
Normal file
@ -0,0 +1,57 @@
|
||||
package main
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestSlugify(t *testing.T) {
|
||||
tests := []struct {
|
||||
url string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"https://api.example.com/v2/rooms",
|
||||
"api.example.com-v2-rooms.json",
|
||||
},
|
||||
{
|
||||
"https://api.example.com/v2/rooms?limit=10&offset=20",
|
||||
"api.example.com-v2-rooms-limit-10-offset-20.json",
|
||||
},
|
||||
{
|
||||
// token param stripped
|
||||
"https://api.example.com/data?token=abc123secret&limit=5",
|
||||
"api.example.com-data-limit-5.json",
|
||||
},
|
||||
{
|
||||
// api_key stripped
|
||||
"https://api.example.com/data?api_key=xyz&format=json",
|
||||
"api.example.com-data-format-json.json",
|
||||
},
|
||||
{
|
||||
// long value stripped (>20 chars)
|
||||
"https://api.example.com/data?hash=abcdefghijklmnopqrstuvwxyz&page=1",
|
||||
"api.example.com-data-page-1.json",
|
||||
},
|
||||
{
|
||||
// access_token stripped
|
||||
"https://api.example.com/me?access_token=foo",
|
||||
"api.example.com-me.json",
|
||||
},
|
||||
{
|
||||
// auth_code contains "code" — stripped
|
||||
"https://example.com/callback?auth_code=xyz&state=ok",
|
||||
"example.com-callback-state-ok.json",
|
||||
},
|
||||
{
|
||||
// no query string
|
||||
"http://localhost:8080/api/v1/users",
|
||||
"localhost-8080-api-v1-users.json",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.url, func(t *testing.T) {
|
||||
got := slugify(tt.url)
|
||||
if got != tt.want {
|
||||
t.Errorf("slugify(%q)\n got: %s\n want: %s", tt.url, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
463
tools/jsontypes/decisions.go
Normal file
463
tools/jsontypes/decisions.go
Normal file
@ -0,0 +1,463 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// decideMapOrStruct determines whether an object is a map or struct.
|
||||
// In anonymous mode, uses heuristics silently.
|
||||
// Otherwise, shows a combined prompt: enter a TypeName or 'm' for map.
|
||||
// In default mode, confident heuristic maps skip the prompt.
|
||||
// In askTypes mode, the prompt is always shown.
|
||||
func (a *Analyzer) decideMapOrStruct(path string, obj map[string]any) bool {
|
||||
isMap, confident := looksLikeMap(obj)
|
||||
if a.anonymous {
|
||||
return isMap
|
||||
}
|
||||
|
||||
// Default mode: skip prompt when heuristics are confident
|
||||
if !a.askTypes && confident {
|
||||
return isMap
|
||||
}
|
||||
|
||||
return a.promptMapOrStructWithName(path, obj, isMap, confident)
|
||||
}
|
||||
|
||||
// promptMapOrStructWithName shows the object's fields and asks a combined question.
|
||||
// The user can type 'm' or 'map' for a map, a name starting with a capital letter
|
||||
// for a struct type, or press Enter to accept the default.
|
||||
func (a *Analyzer) promptMapOrStructWithName(path string, obj map[string]any, heuristicMap, confident bool) bool {
|
||||
keys := sortedKeys(obj)
|
||||
|
||||
inferred := inferTypeName(path)
|
||||
if inferred == "" {
|
||||
a.typeCounter++
|
||||
inferred = fmt.Sprintf("Struct%d", a.typeCounter)
|
||||
}
|
||||
|
||||
defaultVal := inferred
|
||||
if confident && heuristicMap {
|
||||
defaultVal = "m"
|
||||
}
|
||||
|
||||
fmt.Fprintf(a.Prompter.output, "\nAt %s\n", shortPath(path))
|
||||
fmt.Fprintf(a.Prompter.output, " Object with %d keys:\n", len(keys))
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(a.Prompter.output, " %s: %s\n", k, valueSummary(obj[k]))
|
||||
}
|
||||
|
||||
answer := a.Prompter.askMapOrName("Struct name (or 'm' for map)?", defaultVal)
|
||||
if answer == "m" {
|
||||
a.pendingTypeName = ""
|
||||
return true
|
||||
}
|
||||
a.pendingTypeName = answer
|
||||
return false
|
||||
}
|
||||
|
||||
// decideKeyName infers the map key type from the keys.
|
||||
func (a *Analyzer) decideKeyName(_ string, obj map[string]any) string {
|
||||
return inferKeyName(obj)
|
||||
}
|
||||
|
||||
// decideTypeName determines the struct type name, using inference and optionally
|
||||
// prompting the user.
|
||||
func (a *Analyzer) decideTypeName(path string, obj map[string]any) string {
|
||||
// Check if we've already named a type with this exact shape
|
||||
sig := shapeSignature(obj)
|
||||
if existing, ok := a.knownTypes[sig]; ok {
|
||||
a.pendingTypeName = ""
|
||||
return existing.name
|
||||
}
|
||||
|
||||
newFields := fieldSet(obj)
|
||||
|
||||
// Consume pending name from askTypes combined prompt
|
||||
if a.pendingTypeName != "" {
|
||||
name := a.pendingTypeName
|
||||
a.pendingTypeName = ""
|
||||
return a.resolveTypeName(path, name, newFields, sig)
|
||||
}
|
||||
|
||||
inferred := inferTypeName(path)
|
||||
if inferred == "" {
|
||||
a.typeCounter++
|
||||
inferred = fmt.Sprintf("Struct%d", a.typeCounter)
|
||||
}
|
||||
|
||||
// Default and anonymous modes: auto-resolve without prompting
|
||||
if !a.askTypes {
|
||||
return a.autoResolveTypeName(path, inferred, newFields, sig)
|
||||
}
|
||||
|
||||
// askTypes mode: show fields and prompt for name
|
||||
keys := sortedKeys(obj)
|
||||
fmt.Fprintf(a.Prompter.output, "\nAt %s\n", shortPath(path))
|
||||
fmt.Fprintf(a.Prompter.output, " Struct with %d fields:\n", len(keys))
|
||||
for _, k := range keys {
|
||||
fmt.Fprintf(a.Prompter.output, " %s: %s\n", k, valueSummary(obj[k]))
|
||||
}
|
||||
|
||||
name := a.promptName(path, inferred, newFields, sig)
|
||||
return name
|
||||
}
|
||||
|
||||
// autoResolveTypeName registers or resolves a type name without prompting.
|
||||
// On collision, tries the parent-prefix strategy; if that also collides, prompts
|
||||
// (unless anonymous, in which case it uses a numbered fallback).
|
||||
func (a *Analyzer) autoResolveTypeName(path, name string, newFields map[string]string, sig string) string {
|
||||
existing, taken := a.typesByName[name]
|
||||
if !taken {
|
||||
return a.registerType(sig, name, newFields)
|
||||
}
|
||||
|
||||
rel := fieldRelation(existing.fields, newFields)
|
||||
switch rel {
|
||||
case relEqual:
|
||||
a.knownTypes[sig] = existing
|
||||
return name
|
||||
case relSubset, relSuperset:
|
||||
merged := mergeFieldSets(existing.fields, newFields)
|
||||
existing.fields = merged
|
||||
a.knownTypes[sig] = existing
|
||||
return name
|
||||
default:
|
||||
// Collision — try parent-prefix strategy
|
||||
alt := a.suggestAlternativeName(path, name)
|
||||
if _, altTaken := a.typesByName[alt]; !altTaken {
|
||||
return a.registerType(sig, alt, newFields)
|
||||
}
|
||||
// Parent strategy also taken
|
||||
if a.anonymous {
|
||||
a.typeCounter++
|
||||
return a.registerType(sig, fmt.Sprintf("%s%d", name, a.typeCounter), newFields)
|
||||
}
|
||||
// Last resort: prompt
|
||||
return a.promptName(path, alt, newFields, sig)
|
||||
}
|
||||
}
|
||||
|
||||
// resolveTypeName handles a name that came from the combined prompt,
|
||||
// checking for collisions with existing types.
|
||||
func (a *Analyzer) resolveTypeName(path, name string, newFields map[string]string, sig string) string {
|
||||
existing, taken := a.typesByName[name]
|
||||
if !taken {
|
||||
return a.registerType(sig, name, newFields)
|
||||
}
|
||||
|
||||
rel := fieldRelation(existing.fields, newFields)
|
||||
switch rel {
|
||||
case relEqual:
|
||||
a.knownTypes[sig] = existing
|
||||
return name
|
||||
case relSubset, relSuperset:
|
||||
merged := mergeFieldSets(existing.fields, newFields)
|
||||
existing.fields = merged
|
||||
a.knownTypes[sig] = existing
|
||||
return name
|
||||
default:
|
||||
return a.promptName(path, name, newFields, sig)
|
||||
}
|
||||
}
|
||||
|
||||
// promptName asks for a type name and handles collisions with existing types.
|
||||
// Pre-resolves the suggested name so the user sees a valid default.
|
||||
func (a *Analyzer) promptName(path, suggested string, newFields map[string]string, sig string) string {
|
||||
suggested = a.preResolveCollision(path, suggested, newFields, sig)
|
||||
|
||||
for {
|
||||
name := a.Prompter.askFreeform("Name for this type?", suggested)
|
||||
|
||||
existing, taken := a.typesByName[name]
|
||||
if !taken {
|
||||
return a.registerType(sig, name, newFields)
|
||||
}
|
||||
|
||||
rel := fieldRelation(existing.fields, newFields)
|
||||
switch rel {
|
||||
case relEqual:
|
||||
a.knownTypes[sig] = existing
|
||||
return name
|
||||
case relSubset, relSuperset:
|
||||
fmt.Fprintf(a.Prompter.output, " Extending existing type %q (merging fields)\n", name)
|
||||
merged := mergeFieldSets(existing.fields, newFields)
|
||||
existing.fields = merged
|
||||
a.knownTypes[sig] = existing
|
||||
return name
|
||||
case relOverlap:
|
||||
fmt.Fprintf(a.Prompter.output, " Type %q already exists with overlapping fields: %s\n",
|
||||
name, fieldList(existing.fields))
|
||||
choice := a.Prompter.ask(
|
||||
fmt.Sprintf(" [e]xtend %q with merged fields, or use a [d]ifferent name?", name),
|
||||
"e", []string{"e", "d"},
|
||||
)
|
||||
if choice == "e" {
|
||||
merged := mergeFieldSets(existing.fields, newFields)
|
||||
existing.fields = merged
|
||||
a.knownTypes[sig] = existing
|
||||
return name
|
||||
}
|
||||
suggested = a.suggestAlternativeName(path, name)
|
||||
continue
|
||||
case relDisjoint:
|
||||
fmt.Fprintf(a.Prompter.output, " Type %q already exists with different fields: %s\n",
|
||||
name, fieldList(existing.fields))
|
||||
suggested = a.suggestAlternativeName(path, name)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// preResolveCollision checks if the suggested name collides with an existing
|
||||
// type that can't be auto-merged. If so, prints a warning and returns a new
|
||||
// suggested name.
|
||||
func (a *Analyzer) preResolveCollision(path, suggested string, newFields map[string]string, sig string) string {
|
||||
existing, taken := a.typesByName[suggested]
|
||||
if !taken {
|
||||
return suggested
|
||||
}
|
||||
|
||||
rel := fieldRelation(existing.fields, newFields)
|
||||
switch rel {
|
||||
case relEqual, relSubset, relSuperset:
|
||||
return suggested
|
||||
default:
|
||||
alt := a.suggestAlternativeName(path, suggested)
|
||||
fmt.Fprintf(a.Prompter.output, " (type %q already exists with different fields, suggesting %q)\n",
|
||||
suggested, alt)
|
||||
return alt
|
||||
}
|
||||
}
|
||||
|
||||
// suggestAlternativeName generates a better name when a collision occurs,
|
||||
// using the parent type as a prefix (e.g., "DocumentRoom" instead of "Room2").
|
||||
func (a *Analyzer) suggestAlternativeName(path, collided string) string {
|
||||
parent := parentTypeName(path)
|
||||
if parent != "" {
|
||||
candidate := parent + collided
|
||||
if _, taken := a.typesByName[candidate]; !taken {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
// Fall back to numbered suffix
|
||||
a.typeCounter++
|
||||
return fmt.Sprintf("%s%d", collided, a.typeCounter)
|
||||
}
|
||||
|
||||
// shortPath returns the full path but with only the most recent {Type}
|
||||
// annotation kept; all earlier type annotations are stripped. e.g.:
|
||||
// ".{RoomsResult}.rooms[]{Room}.room[string][]{RoomRoom}.json{RoomRoomJSON}.feature_types[]"
|
||||
// → ".rooms[].room[string][].json{RoomRoomJSON}.feature_types[]"
|
||||
func shortPath(path string) string {
|
||||
// Find the last {Type} annotation
|
||||
lastOpen := -1
|
||||
lastClose := -1
|
||||
for i := len(path) - 1; i >= 0; i-- {
|
||||
if path[i] == '}' && lastClose < 0 {
|
||||
lastClose = i
|
||||
}
|
||||
if path[i] == '{' && lastClose >= 0 && lastOpen < 0 {
|
||||
lastOpen = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if lastOpen < 0 {
|
||||
return path
|
||||
}
|
||||
|
||||
// Rebuild: strip all {Type} annotations except the last one
|
||||
var buf strings.Builder
|
||||
i := 0
|
||||
for i < len(path) {
|
||||
if path[i] == '{' {
|
||||
end := strings.IndexByte(path[i:], '}')
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
if i == lastOpen {
|
||||
// Keep this annotation
|
||||
buf.WriteString(path[i : i+end+1])
|
||||
}
|
||||
i = i + end + 1
|
||||
} else {
|
||||
buf.WriteByte(path[i])
|
||||
i++
|
||||
}
|
||||
}
|
||||
|
||||
// Collapse any double dots left by stripping (e.g., ".." → ".")
|
||||
return strings.ReplaceAll(buf.String(), "..", ".")
|
||||
}
|
||||
|
||||
// parentTypeName extracts the most recent {TypeName} from a path.
|
||||
// e.g., ".[id]{Document}.rooms[int]{Room}.details" → "Room"
|
||||
func parentTypeName(path string) string {
|
||||
last := ""
|
||||
for {
|
||||
idx := strings.Index(path, "{")
|
||||
if idx < 0 {
|
||||
break
|
||||
}
|
||||
end := strings.Index(path[idx:], "}")
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
candidate := path[idx+1 : idx+end]
|
||||
if candidate != "null" {
|
||||
last = candidate
|
||||
}
|
||||
path = path[idx+end+1:]
|
||||
}
|
||||
return last
|
||||
}
|
||||
|
||||
func (a *Analyzer) registerType(sig, name string, fields map[string]string) string {
|
||||
st := &structType{name: name, fields: fields}
|
||||
a.knownTypes[sig] = st
|
||||
a.typesByName[name] = st
|
||||
return name
|
||||
}
|
||||
|
||||
type fieldRelationType int
|
||||
|
||||
const (
|
||||
relEqual fieldRelationType = iota
|
||||
relSubset // existing ⊂ new
|
||||
relSuperset // existing ⊃ new
|
||||
relOverlap // some shared, some unique to each
|
||||
relDisjoint // no fields in common
|
||||
)
|
||||
|
||||
func fieldRelation(a, b map[string]string) fieldRelationType {
|
||||
aInB, bInA := 0, 0
|
||||
for k, ak := range a {
|
||||
if bk, ok := b[k]; ok && kindsCompatible(ak, bk) {
|
||||
aInB++
|
||||
}
|
||||
}
|
||||
for k, bk := range b {
|
||||
if ak, ok := a[k]; ok && kindsCompatible(ak, bk) {
|
||||
bInA++
|
||||
}
|
||||
}
|
||||
shared := aInB // same as bInA
|
||||
if shared == 0 {
|
||||
return relDisjoint
|
||||
}
|
||||
if shared == len(a) && shared == len(b) {
|
||||
return relEqual
|
||||
}
|
||||
if shared == len(a) {
|
||||
return relSubset // all of a is in b, b has more
|
||||
}
|
||||
if shared == len(b) {
|
||||
return relSuperset // all of b is in a, a has more
|
||||
}
|
||||
return relOverlap
|
||||
}
|
||||
|
||||
// kindsCompatible returns true if two field value kinds can be considered the
|
||||
// same type. "null" is compatible with anything (it's just an absent value),
|
||||
// and "mixed" is compatible with anything.
|
||||
func kindsCompatible(a, b string) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
if a == "null" || b == "null" || a == "mixed" || b == "mixed" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// fieldsOverlap returns true if one field set is a subset or superset of the other.
|
||||
func fieldsOverlap(a, b map[string]string) bool {
|
||||
rel := fieldRelation(a, b)
|
||||
return rel == relEqual || rel == relSubset || rel == relSuperset
|
||||
}
|
||||
|
||||
func mergeFieldSets(a, b map[string]string) map[string]string {
|
||||
merged := make(map[string]string, len(a)+len(b))
|
||||
for k, v := range a {
|
||||
merged[k] = v
|
||||
}
|
||||
for k, v := range b {
|
||||
if existing, ok := merged[k]; ok && existing != v {
|
||||
merged[k] = "mixed"
|
||||
} else {
|
||||
merged[k] = v
|
||||
}
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
func fieldList(fields map[string]string) string {
|
||||
keys := make([]string, 0, len(fields))
|
||||
for k := range fields {
|
||||
keys = append(keys, k)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
return strings.Join(keys, ", ")
|
||||
}
|
||||
|
||||
// decideTupleOrList asks the user if a short mixed-type array is a tuple or list.
|
||||
func (a *Analyzer) decideTupleOrList(path string, arr []any) bool {
|
||||
if a.anonymous {
|
||||
return false // default to list
|
||||
}
|
||||
fmt.Fprintf(a.Prompter.output, "\nAt %s\n", shortPath(path))
|
||||
fmt.Fprintf(a.Prompter.output, " Short array with %d elements of mixed types:\n", len(arr))
|
||||
for i, v := range arr {
|
||||
fmt.Fprintf(a.Prompter.output, " [%d]: %s\n", i, valueSummary(v))
|
||||
}
|
||||
choice := a.Prompter.ask(
|
||||
"Is this a [l]ist or a [t]uple?",
|
||||
"l", []string{"l", "t"},
|
||||
)
|
||||
return choice == "t"
|
||||
}
|
||||
|
||||
// valueSummary returns a short human-readable summary of a JSON value.
|
||||
func valueSummary(v any) string {
|
||||
switch tv := v.(type) {
|
||||
case nil:
|
||||
return "null"
|
||||
case bool:
|
||||
return fmt.Sprintf("%v", tv)
|
||||
case string:
|
||||
if len(tv) > 40 {
|
||||
return fmt.Sprintf("%q...", tv[:37])
|
||||
}
|
||||
return fmt.Sprintf("%q", tv)
|
||||
case []any:
|
||||
if len(tv) == 0 {
|
||||
return "[]"
|
||||
}
|
||||
return fmt.Sprintf("[...] (%d elements)", len(tv))
|
||||
case map[string]any:
|
||||
if len(tv) == 0 {
|
||||
return "{}"
|
||||
}
|
||||
keys := sortedKeys(tv)
|
||||
preview := keys
|
||||
if len(preview) > 3 {
|
||||
preview = preview[:3]
|
||||
}
|
||||
s := "{" + strings.Join(preview, ", ")
|
||||
if len(keys) > 3 {
|
||||
s += ", ..."
|
||||
}
|
||||
return s + "}"
|
||||
default:
|
||||
return fmt.Sprintf("%v", v)
|
||||
}
|
||||
}
|
||||
|
||||
func fieldSet(obj map[string]any) map[string]string {
|
||||
fs := make(map[string]string, len(obj))
|
||||
for k, v := range obj {
|
||||
fs[k] = kindOf(v)
|
||||
}
|
||||
return fs
|
||||
}
|
||||
52
tools/jsontypes/doc.go
Normal file
52
tools/jsontypes/doc.go
Normal file
@ -0,0 +1,52 @@
|
||||
// Package jsontypes infers type structure from JSON samples and generates
|
||||
// type definitions in multiple output formats.
|
||||
//
|
||||
// Given a JSON value (object, array, or primitive), jsontypes walks the
|
||||
// structure depth-first, detects maps vs structs, infers optional fields
|
||||
// from multiple instances, and produces a flat path notation called
|
||||
// "json-paths" that captures the full type tree:
|
||||
//
|
||||
// {Root}
|
||||
// .users[]{User}
|
||||
// .users[].id{int}
|
||||
// .users[].name{string}
|
||||
// .users[].email{string?}
|
||||
//
|
||||
// These paths can then be rendered into typed definitions for any target:
|
||||
//
|
||||
// - [GenerateGoStructs]: Go struct definitions with json tags
|
||||
// - [GenerateTypeScript]: TypeScript interfaces
|
||||
// - [GenerateJSDoc]: JSDoc @typedef annotations
|
||||
// - [GenerateZod]: Zod validation schemas
|
||||
// - [GeneratePython]: Python TypedDict classes
|
||||
// - [GenerateSQL]: SQL CREATE TABLE with foreign key relationships
|
||||
// - [GenerateJSONSchema]: JSON Schema (draft 2020-12)
|
||||
// - [GenerateTypedef]: JSON Typedef (RFC 8927)
|
||||
//
|
||||
// # Quick start
|
||||
//
|
||||
// For non-interactive use (e.g., from an AI agent or script):
|
||||
//
|
||||
// import "encoding/json"
|
||||
// import "github.com/therootcompany/golib/tools/jsontypes"
|
||||
//
|
||||
// var data any
|
||||
// dec := json.NewDecoder(input)
|
||||
// dec.UseNumber()
|
||||
// dec.Decode(&data)
|
||||
//
|
||||
// a, _ := jsontypes.NewAnalyzer(false, true, false) // anonymous mode
|
||||
// defer a.Close()
|
||||
//
|
||||
// paths := jsontypes.FormatPaths(a.Analyze(".", data))
|
||||
// fmt.Print(jsontypes.GenerateTypeScript(paths))
|
||||
//
|
||||
// # AI tool use
|
||||
//
|
||||
// This package is designed to be callable as an AI skill. Given a JSON
|
||||
// API response, an agent can infer the complete type structure and emit
|
||||
// ready-to-use type definitions — no schema file required. The json-paths
|
||||
// intermediate format is both human-readable and machine-parseable,
|
||||
// making it suitable for tool-use chains where an agent needs to
|
||||
// understand an API's shape before generating code.
|
||||
package jsontypes
|
||||
304
tools/jsontypes/format.go
Normal file
304
tools/jsontypes/format.go
Normal file
@ -0,0 +1,304 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// segment represents one part of a parsed path.
|
||||
type segment struct {
|
||||
name string // field name (empty for root)
|
||||
index string // "[]", "[int]", "[string]", etc. (can be multiple like "[int][]")
|
||||
typ string // type name without braces, e.g. "Room", "string", "null"
|
||||
}
|
||||
|
||||
// parsePath splits a full annotated path into segments.
|
||||
// e.g., ".{RoomsResult}.rooms[]{Room}.name{string}" →
|
||||
//
|
||||
// [{name:"", typ:"RoomsResult"}, {name:"rooms", index:"[]", typ:"Room"}, {name:"name", typ:"string"}]
|
||||
func parsePath(path string) []segment {
|
||||
var segments []segment
|
||||
i := 0
|
||||
for i < len(path) {
|
||||
var seg segment
|
||||
// Skip dot prefix
|
||||
if i < len(path) && path[i] == '.' {
|
||||
i++
|
||||
}
|
||||
// Name: read until [, {, ., or end
|
||||
nameStart := i
|
||||
for i < len(path) && path[i] != '[' && path[i] != '{' && path[i] != '.' {
|
||||
i++
|
||||
}
|
||||
seg.name = path[nameStart:i]
|
||||
|
||||
// Indices: read all [...] sequences
|
||||
for i < len(path) && path[i] == '[' {
|
||||
end := strings.IndexByte(path[i:], ']')
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
seg.index += path[i : i+end+1]
|
||||
i = i + end + 1
|
||||
}
|
||||
|
||||
// Type: read {Type}
|
||||
if i < len(path) && path[i] == '{' {
|
||||
end := strings.IndexByte(path[i:], '}')
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
seg.typ = path[i+1 : i+end]
|
||||
i = i + end + 1
|
||||
}
|
||||
|
||||
segments = append(segments, seg)
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
// formatPaths converts fully-annotated flat paths into the display format where:
|
||||
// - The root type appears alone on the first line (no leading dot)
|
||||
// - Each type introduction gets its own line
|
||||
// - Type annotations only appear on the rightmost (new) segment of each line
|
||||
// - When multiple types share a path position, child fields include the
|
||||
// parent type to disambiguate (e.g., .items[]{FileField}.slug{string})
|
||||
func FormatPaths(paths []string) []string {
|
||||
// First pass: find bare positions where multiple types are introduced.
|
||||
// These need parent type disambiguation in their child lines.
|
||||
typeIntros := make(map[string]map[string]bool) // bare → set of type names
|
||||
for _, path := range paths {
|
||||
segs := parsePath(path)
|
||||
for depth := range segs {
|
||||
if segs[depth].typ == "" {
|
||||
continue
|
||||
}
|
||||
bare := buildBare(segs[:depth+1])
|
||||
if typeIntros[bare] == nil {
|
||||
typeIntros[bare] = make(map[string]bool)
|
||||
}
|
||||
typeIntros[bare][segs[depth].typ] = true
|
||||
}
|
||||
}
|
||||
// Collect bare paths with multiple types (excluding primitives/null)
|
||||
multiType := make(map[string]bool)
|
||||
for bare, types := range typeIntros {
|
||||
named := 0
|
||||
for typ := range types {
|
||||
if typ != "null" && typ != "string" && typ != "int" &&
|
||||
typ != "float" && typ != "bool" && typ != "unknown" {
|
||||
named++
|
||||
}
|
||||
}
|
||||
if named > 1 {
|
||||
multiType[bare] = true
|
||||
}
|
||||
}
|
||||
|
||||
seen := make(map[string]bool)
|
||||
var lines []outputLine
|
||||
|
||||
for _, path := range paths {
|
||||
segs := parsePath(path)
|
||||
|
||||
for depth := range segs {
|
||||
if segs[depth].typ == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if the parent position has multiple types
|
||||
parentIdx := -1
|
||||
if depth > 0 {
|
||||
parentBare := buildBare(segs[:depth])
|
||||
if multiType[parentBare] {
|
||||
// Find the parent segment that has a type
|
||||
for j := depth - 1; j >= 0; j-- {
|
||||
if segs[j].typ != "" {
|
||||
parentIdx = j
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check if this position itself has multiple types (type intro line)
|
||||
selfBare := buildBare(segs[:depth+1])
|
||||
selfMulti := multiType[selfBare]
|
||||
|
||||
var display string
|
||||
if parentIdx >= 0 {
|
||||
display = buildDisplayWithParent(segs[:depth+1], depth, parentIdx)
|
||||
} else {
|
||||
display = buildDisplay(segs[:depth+1], depth)
|
||||
}
|
||||
if !seen[display] {
|
||||
seen[display] = true
|
||||
var bare string
|
||||
if parentIdx >= 0 {
|
||||
bare = buildBareWithParent(segs[:depth+1], parentIdx)
|
||||
} else if selfMulti {
|
||||
// This is a type intro at a multi-type position;
|
||||
// include own type in bare so children sort under it.
|
||||
bare = buildBareWithParent(segs[:depth+1], depth)
|
||||
} else {
|
||||
bare = buildBare(segs[:depth+1])
|
||||
}
|
||||
lines = append(lines, outputLine{bare, display})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Merge {null} with sibling types: if a bare path has both {null} and
|
||||
// {SomeType}, replace with {SomeType?} and drop the {null} line.
|
||||
lines = mergeNullables(lines)
|
||||
|
||||
sort.SliceStable(lines, func(i, j int) bool {
|
||||
if lines[i].bare != lines[j].bare {
|
||||
return lines[i].bare < lines[j].bare
|
||||
}
|
||||
return lines[i].display < lines[j].display
|
||||
})
|
||||
|
||||
result := make([]string, len(lines))
|
||||
for i, l := range lines {
|
||||
result[i] = l.display
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// buildDisplay builds a display line where only the segment at typeIdx shows
|
||||
// its type. Parent segments are bare. The root segment has no leading dot.
|
||||
func buildDisplay(segs []segment, typeIdx int) string {
|
||||
var buf strings.Builder
|
||||
for i, seg := range segs {
|
||||
if seg.name != "" {
|
||||
buf.WriteByte('.')
|
||||
buf.WriteString(seg.name)
|
||||
}
|
||||
buf.WriteString(seg.index)
|
||||
if i == typeIdx {
|
||||
buf.WriteByte('{')
|
||||
buf.WriteString(seg.typ)
|
||||
buf.WriteByte('}')
|
||||
}
|
||||
}
|
||||
s := buf.String()
|
||||
if s == "" && len(segs) > 0 && segs[0].typ != "" {
|
||||
return "{" + segs[0].typ + "}"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
type outputLine struct {
|
||||
bare string // path without types, for sorting
|
||||
display string
|
||||
}
|
||||
|
||||
// mergeNullables finds bare paths that have both a {null} line and typed
|
||||
// lines. It adds ? to the typed lines and drops the {null} line.
|
||||
// e.g., ".score{null}" + ".score{float}" → ".score{float?}"
|
||||
func mergeNullables(lines []outputLine) []outputLine {
|
||||
// Group lines by bare path
|
||||
byBare := make(map[string][]int) // bare → indices into lines
|
||||
for i, l := range lines {
|
||||
byBare[l.bare] = append(byBare[l.bare], i)
|
||||
}
|
||||
|
||||
drop := make(map[int]bool)
|
||||
for _, indices := range byBare {
|
||||
if len(indices) < 2 {
|
||||
continue
|
||||
}
|
||||
// Check if any line in this group is {null}
|
||||
nullIdx := -1
|
||||
hasNonNull := false
|
||||
for _, idx := range indices {
|
||||
if strings.HasSuffix(lines[idx].display, "{null}") {
|
||||
nullIdx = idx
|
||||
} else {
|
||||
hasNonNull = true
|
||||
}
|
||||
}
|
||||
if nullIdx < 0 || !hasNonNull {
|
||||
continue
|
||||
}
|
||||
// Drop the {null} line and add ? to the others
|
||||
drop[nullIdx] = true
|
||||
for _, idx := range indices {
|
||||
if idx == nullIdx {
|
||||
continue
|
||||
}
|
||||
d := lines[idx].display
|
||||
// Replace trailing } with ?}
|
||||
if strings.HasSuffix(d, "}") {
|
||||
lines[idx].display = d[:len(d)-1] + "?}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(drop) == 0 {
|
||||
return lines
|
||||
}
|
||||
result := make([]outputLine, 0, len(lines)-len(drop))
|
||||
for i, l := range lines {
|
||||
if !drop[i] {
|
||||
result = append(result, l)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// buildDisplayWithParent builds a display line showing type annotations at both
|
||||
// parentIdx (for disambiguation) and typeIdx (for the new type introduction).
|
||||
func buildDisplayWithParent(segs []segment, typeIdx, parentIdx int) string {
|
||||
var buf strings.Builder
|
||||
for i, seg := range segs {
|
||||
if seg.name != "" {
|
||||
buf.WriteByte('.')
|
||||
buf.WriteString(seg.name)
|
||||
}
|
||||
buf.WriteString(seg.index)
|
||||
if i == parentIdx || i == typeIdx {
|
||||
buf.WriteByte('{')
|
||||
buf.WriteString(seg.typ)
|
||||
buf.WriteByte('}')
|
||||
}
|
||||
}
|
||||
s := buf.String()
|
||||
if s == "" && len(segs) > 0 && segs[0].typ != "" {
|
||||
return "{" + segs[0].typ + "}"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// buildBareWithParent builds a bare path that includes the parent type for
|
||||
// sorting/grouping, so children of different parent types sort separately.
|
||||
func buildBareWithParent(segs []segment, parentIdx int) string {
|
||||
var buf strings.Builder
|
||||
for i, seg := range segs {
|
||||
if seg.name != "" {
|
||||
buf.WriteByte('.')
|
||||
buf.WriteString(seg.name)
|
||||
}
|
||||
buf.WriteString(seg.index)
|
||||
if i == parentIdx {
|
||||
buf.WriteByte('{')
|
||||
buf.WriteString(seg.typ)
|
||||
buf.WriteByte('}')
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// buildBare builds a path string without any type annotations, for sorting.
|
||||
func buildBare(segs []segment) string {
|
||||
var buf strings.Builder
|
||||
for _, seg := range segs {
|
||||
if seg.name != "" {
|
||||
buf.WriteByte('.')
|
||||
buf.WriteString(seg.name)
|
||||
}
|
||||
buf.WriteString(seg.index)
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
197
tools/jsontypes/format_test.go
Normal file
197
tools/jsontypes/format_test.go
Normal file
@ -0,0 +1,197 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestParsePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
path string
|
||||
want []segment
|
||||
}{
|
||||
{
|
||||
".{RoomsResult}.rooms[]{Room}.name{string}",
|
||||
[]segment{
|
||||
{name: "", typ: "RoomsResult"},
|
||||
{name: "rooms", index: "[]", typ: "Room"},
|
||||
{name: "name", typ: "string"},
|
||||
},
|
||||
},
|
||||
{
|
||||
".[string]{Person}.friends[]{Friend}.name{string}",
|
||||
[]segment{
|
||||
{name: "", index: "[string]", typ: "Person"},
|
||||
{name: "friends", index: "[]", typ: "Friend"},
|
||||
{name: "name", typ: "string"},
|
||||
},
|
||||
},
|
||||
{
|
||||
".{Root}.data[int][]{ResourceData}.x{string}",
|
||||
[]segment{
|
||||
{name: "", typ: "Root"},
|
||||
{name: "data", index: "[int][]", typ: "ResourceData"},
|
||||
{name: "x", typ: "string"},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.path, func(t *testing.T) {
|
||||
got := parsePath(tt.path)
|
||||
if len(got) != len(tt.want) {
|
||||
t.Fatalf("got %d segments, want %d: %+v", len(got), len(tt.want), got)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tt.want[i] {
|
||||
t.Errorf("segment[%d]: got %+v, want %+v", i, got[i], tt.want[i])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPaths(t *testing.T) {
|
||||
input := []string{
|
||||
".[person_id]{Person}.name{string}",
|
||||
".[person_id]{Person}.age{int}",
|
||||
".[person_id]{Person}.friends[]{Friend}.name{string}",
|
||||
".[person_id]{Person}.friends[]{Friend}.identification{null}",
|
||||
".[person_id]{Person}.friends[]{Friend}.identification{StateID}.number{string}",
|
||||
}
|
||||
got := FormatPaths(input)
|
||||
want := []string{
|
||||
"[person_id]{Person}",
|
||||
"[person_id].age{int}",
|
||||
"[person_id].friends[]{Friend}",
|
||||
"[person_id].friends[].identification{StateID?}",
|
||||
"[person_id].friends[].identification.number{string}",
|
||||
"[person_id].friends[].name{string}",
|
||||
"[person_id].name{string}",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d lines, want %d:\n got: %s\n want: %s",
|
||||
len(got), len(want),
|
||||
strings.Join(got, "\n "),
|
||||
strings.Join(want, "\n "))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("line[%d]:\n got: %s\n want: %s", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestFormatPathsDifferentTypes verifies that when two different types exist
|
||||
// at the same path position, their fields are grouped under the parent type
|
||||
// and don't get deduplicated together.
|
||||
func TestFormatPathsDifferentTypes(t *testing.T) {
|
||||
// Raw paths as produced by the analyzer when choosing "different" types
|
||||
input := []string{
|
||||
".{Root}.items[]{FileField}.slug{string}",
|
||||
".{Root}.items[]{FileField}.filename{string}",
|
||||
".{Root}.items[]{FileField}.is_required{bool}",
|
||||
".{Root}.items[]{FeatureField}.slug{string}",
|
||||
".{Root}.items[]{FeatureField}.feature{string}",
|
||||
".{Root}.items[]{FeatureField}.archived{bool}",
|
||||
}
|
||||
got := FormatPaths(input)
|
||||
want := []string{
|
||||
"{Root}",
|
||||
".items[]{FeatureField}",
|
||||
".items[]{FeatureField}.archived{bool}",
|
||||
".items[]{FeatureField}.feature{string}",
|
||||
".items[]{FeatureField}.slug{string}",
|
||||
".items[]{FileField}",
|
||||
".items[]{FileField}.filename{string}",
|
||||
".items[]{FileField}.is_required{bool}",
|
||||
".items[]{FileField}.slug{string}",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d lines, want %d:\n got: %s\n want: %s",
|
||||
len(got), len(want),
|
||||
strings.Join(got, "\n "),
|
||||
strings.Join(want, "\n "))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("line[%d]:\n got: %s\n want: %s", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestDifferentTypesEndToEnd tests the full pipeline from JSON data through
|
||||
// analysis with "different" type selection to formatted output.
|
||||
func TestDifferentTypesEndToEnd(t *testing.T) {
|
||||
arr := []any{
|
||||
map[string]any{"slug": "a", "filename": "x.pdf", "is_required": true},
|
||||
map[string]any{"slug": "b", "filename": "y.pdf", "is_required": false},
|
||||
map[string]any{"slug": "c", "feature": "upload", "archived": false},
|
||||
map[string]any{"slug": "d", "feature": "export", "archived": true},
|
||||
}
|
||||
obj := map[string]any{"items": arr, "count": jsonNum("4"), "status": "ok"}
|
||||
|
||||
a := &Analyzer{
|
||||
Prompter: &Prompter{
|
||||
reader: bufio.NewReader(strings.NewReader("")),
|
||||
output: io.Discard,
|
||||
// Root has 3 field-like keys → confident struct, no prompt needed.
|
||||
// Then items[] has 2 shapes → unification prompt: "d" for different,
|
||||
// then names for each shape.
|
||||
priorAnswers: []string{"d", "FileField", "FeatureField"},
|
||||
},
|
||||
knownTypes: make(map[string]*structType),
|
||||
typesByName: make(map[string]*structType),
|
||||
}
|
||||
rawPaths := a.Analyze(".", obj)
|
||||
formatted := FormatPaths(rawPaths)
|
||||
|
||||
// FileField and FeatureField should each have their own fields listed
|
||||
// under their type, not merged together
|
||||
fileFieldLines := 0
|
||||
featureFieldLines := 0
|
||||
for _, line := range formatted {
|
||||
if strings.Contains(line, "{FileField}") {
|
||||
fileFieldLines++
|
||||
}
|
||||
if strings.Contains(line, "{FeatureField}") {
|
||||
featureFieldLines++
|
||||
}
|
||||
}
|
||||
// FileField: intro + slug + filename + is_required = 4
|
||||
if fileFieldLines < 4 {
|
||||
t.Errorf("expected at least 4 FileField lines (intro + 3 fields), got %d:\n %s",
|
||||
fileFieldLines, strings.Join(formatted, "\n "))
|
||||
}
|
||||
// FeatureField: intro + slug + feature + archived = 4
|
||||
if featureFieldLines < 4 {
|
||||
t.Errorf("expected at least 4 FeatureField lines (intro + 3 fields), got %d:\n %s",
|
||||
featureFieldLines, strings.Join(formatted, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatPathsRootStruct(t *testing.T) {
|
||||
input := []string{
|
||||
".{RoomsResult}.rooms[]{Room}.name{string}",
|
||||
".{RoomsResult}.errors[]{string}",
|
||||
}
|
||||
got := FormatPaths(input)
|
||||
want := []string{
|
||||
"{RoomsResult}",
|
||||
".errors[]{string}",
|
||||
".rooms[]{Room}",
|
||||
".rooms[].name{string}",
|
||||
}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("got %d lines, want %d:\n got: %s\n want: %s",
|
||||
len(got), len(want),
|
||||
strings.Join(got, "\n "),
|
||||
strings.Join(want, "\n "))
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != want[i] {
|
||||
t.Errorf("line[%d]:\n got: %s\n want: %s", i, got[i], want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
3
tools/jsontypes/go.mod
Normal file
3
tools/jsontypes/go.mod
Normal file
@ -0,0 +1,3 @@
|
||||
module github.com/therootcompany/golib/tool/jsontypes
|
||||
|
||||
go 1.25.0
|
||||
558
tools/jsontypes/gostruct.go
Normal file
558
tools/jsontypes/gostruct.go
Normal file
@ -0,0 +1,558 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// goType represents a Go struct being built from flat paths.
|
||||
type goType struct {
|
||||
name string
|
||||
fields []goField
|
||||
}
|
||||
|
||||
type goField struct {
|
||||
goName string // PascalCase Go field name
|
||||
jsonName string // original JSON key
|
||||
goType string // Go type string
|
||||
optional bool // nullable/optional field
|
||||
}
|
||||
|
||||
// goUnion represents a discriminated union — multiple concrete struct types
|
||||
// at the same JSON position (e.g., an array with different shaped objects).
|
||||
type goUnion struct {
|
||||
name string // interface name, e.g., "Item"
|
||||
concreteTypes []string // ordered concrete type names
|
||||
sharedFields []goField // fields common to ALL concrete types
|
||||
uniqueFields map[string][]string // typeName → json field names unique to it
|
||||
typeFieldJSON string // "type"/"kind" if present in shared, else ""
|
||||
index string // "[]", "[string]", etc.
|
||||
fieldName string // json field name in parent struct
|
||||
}
|
||||
|
||||
func (u *goUnion) markerMethod() string {
|
||||
return "is" + u.name
|
||||
}
|
||||
|
||||
func (u *goUnion) unmarshalFuncName() string {
|
||||
return "unmarshal" + u.name
|
||||
}
|
||||
|
||||
func (u *goUnion) wrapperTypeName() string {
|
||||
if u.index == "[]" {
|
||||
return u.name + "Slice"
|
||||
}
|
||||
if strings.HasPrefix(u.index, "[") {
|
||||
return u.name + "Map"
|
||||
}
|
||||
return u.name
|
||||
}
|
||||
|
||||
// generateGoStructs converts formatted flat paths into Go struct definitions
|
||||
// with json tags. When multiple types share an array/map position, it generates
|
||||
// a sealed interface, discriminator function, and wrapper type.
|
||||
func GenerateGoStructs(paths []string) string {
|
||||
types, unions := buildGoTypes(paths)
|
||||
|
||||
var buf strings.Builder
|
||||
|
||||
if len(unions) > 0 {
|
||||
buf.WriteString("import (\n\t\"encoding/json\"\n\t\"fmt\"\n)\n\n")
|
||||
}
|
||||
|
||||
for i, t := range types {
|
||||
if i > 0 {
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("type %s struct {\n", t.name))
|
||||
maxNameLen := 0
|
||||
maxTypeLen := 0
|
||||
for _, f := range t.fields {
|
||||
if len(f.goName) > maxNameLen {
|
||||
maxNameLen = len(f.goName)
|
||||
}
|
||||
if len(f.goType) > maxTypeLen {
|
||||
maxTypeLen = len(f.goType)
|
||||
}
|
||||
}
|
||||
for _, f := range t.fields {
|
||||
tag := fmt.Sprintf("`json:\"%s\"`", f.jsonName)
|
||||
if f.optional {
|
||||
tag = fmt.Sprintf("`json:\"%s,omitempty\"`", f.jsonName)
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("\t%-*s %-*s %s\n",
|
||||
maxNameLen, f.goName,
|
||||
maxTypeLen, f.goType,
|
||||
tag))
|
||||
}
|
||||
buf.WriteString("}\n")
|
||||
}
|
||||
|
||||
for _, u := range unions {
|
||||
buf.WriteByte('\n')
|
||||
writeUnionCode(&buf, u)
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// buildGoTypes parses the formatted paths and groups fields by type.
|
||||
// It also detects union positions (bare prefixes with multiple named types)
|
||||
// and returns goUnion descriptors for them.
|
||||
func buildGoTypes(paths []string) ([]goType, []*goUnion) {
|
||||
// First pass: collect type intros per bare prefix.
|
||||
type prefixInfo struct {
|
||||
types []string // type names at this position
|
||||
name string // field name (e.g., "items")
|
||||
index string // index part (e.g., "[]")
|
||||
}
|
||||
prefixes := make(map[string]*prefixInfo)
|
||||
typeOrder := []string{}
|
||||
typeSeen := make(map[string]bool)
|
||||
typeFields := make(map[string][]goField)
|
||||
|
||||
for _, path := range paths {
|
||||
segs := parsePath(path)
|
||||
if len(segs) == 0 {
|
||||
continue
|
||||
}
|
||||
last := segs[len(segs)-1]
|
||||
if last.typ == "" {
|
||||
continue
|
||||
}
|
||||
typeName := cleanTypeName(last.typ)
|
||||
if isPrimitiveType(typeName) {
|
||||
continue
|
||||
}
|
||||
bare := buildBare(segs)
|
||||
pi := prefixes[bare]
|
||||
if pi == nil {
|
||||
pi = &prefixInfo{name: last.name, index: last.index}
|
||||
prefixes[bare] = pi
|
||||
}
|
||||
// Add type if not already present at this prefix
|
||||
found := false
|
||||
for _, t := range pi.types {
|
||||
if t == typeName {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
pi.types = append(pi.types, typeName)
|
||||
}
|
||||
if !typeSeen[typeName] {
|
||||
typeSeen[typeName] = true
|
||||
typeOrder = append(typeOrder, typeName)
|
||||
}
|
||||
}
|
||||
|
||||
// Build prefixToType for parent lookups (first type at each position).
|
||||
prefixToType := make(map[string]string)
|
||||
for bare, pi := range prefixes {
|
||||
prefixToType[bare] = pi.types[0]
|
||||
}
|
||||
|
||||
// Identify union positions (>1 named type at the same bare prefix).
|
||||
unionsByBare := make(map[string]*goUnion)
|
||||
var unions []*goUnion
|
||||
for bare, pi := range prefixes {
|
||||
if len(pi.types) <= 1 {
|
||||
continue
|
||||
}
|
||||
ifaceName := singularize(snakeToPascal(pi.name))
|
||||
if ifaceName == "" {
|
||||
ifaceName = "RootItem"
|
||||
}
|
||||
// Avoid collision with concrete type names
|
||||
for _, t := range pi.types {
|
||||
if t == ifaceName {
|
||||
ifaceName += "Variant"
|
||||
break
|
||||
}
|
||||
}
|
||||
u := &goUnion{
|
||||
name: ifaceName,
|
||||
concreteTypes: pi.types,
|
||||
index: pi.index,
|
||||
fieldName: pi.name,
|
||||
uniqueFields: make(map[string][]string),
|
||||
}
|
||||
unionsByBare[bare] = u
|
||||
unions = append(unions, u)
|
||||
}
|
||||
|
||||
// Second pass: assign fields to their owning types.
|
||||
for _, path := range paths {
|
||||
segs := parsePath(path)
|
||||
if len(segs) == 0 {
|
||||
continue
|
||||
}
|
||||
last := segs[len(segs)-1]
|
||||
if last.typ == "" || last.name == "" {
|
||||
continue
|
||||
}
|
||||
typeName := cleanTypeName(last.typ)
|
||||
|
||||
// Find the parent type.
|
||||
parentType := ""
|
||||
if len(segs) == 1 {
|
||||
if pt, ok := prefixToType[""]; ok {
|
||||
parentType = pt
|
||||
}
|
||||
} else {
|
||||
for depth := len(segs) - 2; depth >= 0; depth-- {
|
||||
// Prefer explicit type annotation on segment (handles multi-type).
|
||||
if segs[depth].typ != "" && !isPrimitiveType(cleanTypeName(segs[depth].typ)) {
|
||||
parentType = cleanTypeName(segs[depth].typ)
|
||||
break
|
||||
}
|
||||
// Fall back to bare prefix lookup.
|
||||
prefix := buildBare(segs[:depth+1])
|
||||
if pt, ok := prefixToType[prefix]; ok {
|
||||
parentType = pt
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if parentType == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Determine the Go type for this field.
|
||||
lastBare := buildBare(segs)
|
||||
var goTyp string
|
||||
if u, isUnion := unionsByBare[lastBare]; isUnion && !isPrimitiveType(typeName) {
|
||||
goTyp = u.wrapperTypeName()
|
||||
} else {
|
||||
goTyp = flatTypeToGo(typeName, last.index)
|
||||
}
|
||||
|
||||
optional := strings.HasSuffix(last.typ, "?")
|
||||
if optional {
|
||||
goTyp = makePointer(goTyp)
|
||||
}
|
||||
|
||||
field := goField{
|
||||
goName: snakeToPascal(last.name),
|
||||
jsonName: last.name,
|
||||
goType: goTyp,
|
||||
optional: optional,
|
||||
}
|
||||
|
||||
// Deduplicate (union fields appear once per concrete type but the
|
||||
// parent field should only be added once with the wrapper type).
|
||||
existing := typeFields[parentType]
|
||||
dup := false
|
||||
for _, ef := range existing {
|
||||
if ef.jsonName == field.jsonName {
|
||||
dup = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !dup {
|
||||
typeFields[parentType] = append(existing, field)
|
||||
}
|
||||
}
|
||||
|
||||
// Compute shared and unique fields for each union.
|
||||
for _, u := range unions {
|
||||
fieldCounts := make(map[string]int)
|
||||
fieldByJSON := make(map[string]goField)
|
||||
|
||||
for _, typeName := range u.concreteTypes {
|
||||
for _, f := range typeFields[typeName] {
|
||||
fieldCounts[f.jsonName]++
|
||||
if _, exists := fieldByJSON[f.jsonName]; !exists {
|
||||
fieldByJSON[f.jsonName] = f
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
nTypes := len(u.concreteTypes)
|
||||
for jsonName, count := range fieldCounts {
|
||||
if count == nTypes {
|
||||
u.sharedFields = append(u.sharedFields, fieldByJSON[jsonName])
|
||||
if jsonName == "type" || jsonName == "kind" || jsonName == "_type" {
|
||||
u.typeFieldJSON = jsonName
|
||||
}
|
||||
}
|
||||
}
|
||||
sortGoFields(u.sharedFields)
|
||||
|
||||
for _, typeName := range u.concreteTypes {
|
||||
typeFieldSet := make(map[string]bool)
|
||||
for _, f := range typeFields[typeName] {
|
||||
typeFieldSet[f.jsonName] = true
|
||||
}
|
||||
var unique []string
|
||||
for name := range typeFieldSet {
|
||||
if fieldCounts[name] == 1 {
|
||||
unique = append(unique, name)
|
||||
}
|
||||
}
|
||||
sort.Strings(unique)
|
||||
u.uniqueFields[typeName] = unique
|
||||
}
|
||||
}
|
||||
|
||||
var types []goType
|
||||
for _, name := range typeOrder {
|
||||
fields := typeFields[name]
|
||||
sortGoFields(fields)
|
||||
types = append(types, goType{name: name, fields: fields})
|
||||
}
|
||||
return types, unions
|
||||
}
|
||||
|
||||
// writeUnionCode generates the interface, discriminator, marker methods,
|
||||
// getters, and wrapper type for a union.
|
||||
func writeUnionCode(buf *strings.Builder, u *goUnion) {
|
||||
marker := u.markerMethod()
|
||||
|
||||
// Interface
|
||||
buf.WriteString(fmt.Sprintf("// %s can be one of: %s.\n",
|
||||
u.name, strings.Join(u.concreteTypes, ", ")))
|
||||
if u.typeFieldJSON != "" {
|
||||
buf.WriteString(fmt.Sprintf(
|
||||
"// CHANGE ME: the shared %q field is likely a discriminator — see %s below.\n",
|
||||
u.typeFieldJSON, u.unmarshalFuncName()))
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("type %s interface {\n", u.name))
|
||||
buf.WriteString(fmt.Sprintf("\t%s()\n", marker))
|
||||
for _, f := range u.sharedFields {
|
||||
buf.WriteString(fmt.Sprintf("\tGet%s() %s\n", f.goName, f.goType))
|
||||
}
|
||||
buf.WriteString("}\n\n")
|
||||
|
||||
// Marker methods
|
||||
for _, t := range u.concreteTypes {
|
||||
buf.WriteString(fmt.Sprintf("func (*%s) %s() {}\n", t, marker))
|
||||
}
|
||||
buf.WriteByte('\n')
|
||||
|
||||
// Getter implementations
|
||||
if len(u.sharedFields) > 0 {
|
||||
for _, t := range u.concreteTypes {
|
||||
for _, f := range u.sharedFields {
|
||||
buf.WriteString(fmt.Sprintf("func (v *%s) Get%s() %s { return v.%s }\n",
|
||||
t, f.goName, f.goType, f.goName))
|
||||
}
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
}
|
||||
|
||||
// Unmarshal function
|
||||
writeUnmarshalFunc(buf, u)
|
||||
|
||||
// Wrapper type
|
||||
writeWrapperType(buf, u)
|
||||
}
|
||||
|
||||
func writeUnmarshalFunc(buf *strings.Builder, u *goUnion) {
|
||||
buf.WriteString(fmt.Sprintf("// %s decodes a JSON value into the matching %s variant.\n",
|
||||
u.unmarshalFuncName(), u.name))
|
||||
buf.WriteString(fmt.Sprintf("func %s(data json.RawMessage) (%s, error) {\n",
|
||||
u.unmarshalFuncName(), u.name))
|
||||
|
||||
// CHANGE ME comment
|
||||
if u.typeFieldJSON != "" {
|
||||
goFieldName := snakeToPascal(u.typeFieldJSON)
|
||||
buf.WriteString(fmt.Sprintf(
|
||||
"\t// CHANGE ME: switch on the %q discriminator instead of probing unique keys:\n",
|
||||
u.typeFieldJSON))
|
||||
buf.WriteString(fmt.Sprintf(
|
||||
"\t// var probe struct{ %s string `json:\"%s\"` }\n", goFieldName, u.typeFieldJSON))
|
||||
buf.WriteString("\t// if err := json.Unmarshal(data, &probe); err == nil {\n")
|
||||
buf.WriteString(fmt.Sprintf("\t// switch probe.%s {\n", goFieldName))
|
||||
for _, t := range u.concreteTypes {
|
||||
buf.WriteString(fmt.Sprintf(
|
||||
"\t// case \"???\":\n\t// var v %s\n\t// return &v, json.Unmarshal(data, &v)\n", t))
|
||||
}
|
||||
buf.WriteString("\t// }\n\t// }\n\n")
|
||||
} else {
|
||||
buf.WriteString(
|
||||
"\t// CHANGE ME: if the variants share a \"type\" or \"kind\" field,\n" +
|
||||
"\t// switch on its value instead of probing for unique keys.\n\n")
|
||||
}
|
||||
|
||||
buf.WriteString("\tvar keys map[string]json.RawMessage\n")
|
||||
buf.WriteString("\tif err := json.Unmarshal(data, &keys); err != nil {\n")
|
||||
buf.WriteString("\t\treturn nil, err\n")
|
||||
buf.WriteString("\t}\n")
|
||||
|
||||
// Pick fallback type (the one with fewest unique fields).
|
||||
fallbackType := u.concreteTypes[0]
|
||||
fallbackCount := len(u.uniqueFields[fallbackType])
|
||||
for _, t := range u.concreteTypes[1:] {
|
||||
if len(u.uniqueFields[t]) < fallbackCount {
|
||||
fallbackType = t
|
||||
fallbackCount = len(u.uniqueFields[t])
|
||||
}
|
||||
}
|
||||
|
||||
// Probe unique fields for each non-fallback type.
|
||||
for _, t := range u.concreteTypes {
|
||||
if t == fallbackType {
|
||||
continue
|
||||
}
|
||||
unique := u.uniqueFields[t]
|
||||
if len(unique) == 0 {
|
||||
buf.WriteString(fmt.Sprintf(
|
||||
"\t// CHANGE ME: %s has no unique fields — add a discriminator.\n", t))
|
||||
continue
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("\tif _, ok := keys[%q]; ok {\n", unique[0]))
|
||||
buf.WriteString(fmt.Sprintf("\t\tvar v %s\n", t))
|
||||
buf.WriteString("\t\treturn &v, json.Unmarshal(data, &v)\n")
|
||||
buf.WriteString("\t}\n")
|
||||
}
|
||||
|
||||
buf.WriteString(fmt.Sprintf("\tvar v %s\n", fallbackType))
|
||||
buf.WriteString("\treturn &v, json.Unmarshal(data, &v)\n")
|
||||
buf.WriteString("}\n\n")
|
||||
}
|
||||
|
||||
func writeWrapperType(buf *strings.Builder, u *goUnion) {
|
||||
wrapper := u.wrapperTypeName()
|
||||
unmarshalFunc := u.unmarshalFuncName()
|
||||
|
||||
if u.index == "[]" {
|
||||
buf.WriteString(fmt.Sprintf("// %s handles JSON unmarshaling of %s union values.\n",
|
||||
wrapper, u.name))
|
||||
buf.WriteString(fmt.Sprintf("type %s []%s\n\n", wrapper, u.name))
|
||||
buf.WriteString(fmt.Sprintf("func (s *%s) UnmarshalJSON(data []byte) error {\n", wrapper))
|
||||
buf.WriteString("\tvar raw []json.RawMessage\n")
|
||||
buf.WriteString("\tif err := json.Unmarshal(data, &raw); err != nil {\n")
|
||||
buf.WriteString("\t\treturn err\n")
|
||||
buf.WriteString("\t}\n")
|
||||
buf.WriteString(fmt.Sprintf("\t*s = make(%s, len(raw))\n", wrapper))
|
||||
buf.WriteString("\tfor i, msg := range raw {\n")
|
||||
buf.WriteString(fmt.Sprintf("\t\tv, err := %s(msg)\n", unmarshalFunc))
|
||||
buf.WriteString("\t\tif err != nil {\n")
|
||||
buf.WriteString(fmt.Sprintf("\t\t\treturn fmt.Errorf(\"%s[%%d]: %%w\", i, err)\n", u.fieldName))
|
||||
buf.WriteString("\t\t}\n")
|
||||
buf.WriteString("\t\t(*s)[i] = v\n")
|
||||
buf.WriteString("\t}\n")
|
||||
buf.WriteString("\treturn nil\n")
|
||||
buf.WriteString("}\n")
|
||||
} else if strings.HasPrefix(u.index, "[") {
|
||||
keyType := u.index[1 : len(u.index)-1]
|
||||
buf.WriteString(fmt.Sprintf("// %s handles JSON unmarshaling of %s union values.\n",
|
||||
wrapper, u.name))
|
||||
buf.WriteString(fmt.Sprintf("type %s map[%s]%s\n\n", wrapper, keyType, u.name))
|
||||
buf.WriteString(fmt.Sprintf("func (m *%s) UnmarshalJSON(data []byte) error {\n", wrapper))
|
||||
buf.WriteString(fmt.Sprintf("\tvar raw map[%s]json.RawMessage\n", keyType))
|
||||
buf.WriteString("\tif err := json.Unmarshal(data, &raw); err != nil {\n")
|
||||
buf.WriteString("\t\treturn err\n")
|
||||
buf.WriteString("\t}\n")
|
||||
buf.WriteString(fmt.Sprintf("\t*m = make(%s, len(raw))\n", wrapper))
|
||||
buf.WriteString("\tfor k, msg := range raw {\n")
|
||||
buf.WriteString(fmt.Sprintf("\t\tv, err := %s(msg)\n", unmarshalFunc))
|
||||
buf.WriteString("\t\tif err != nil {\n")
|
||||
buf.WriteString(fmt.Sprintf("\t\t\treturn fmt.Errorf(\"%s[%%v]: %%w\", k, err)\n", u.fieldName))
|
||||
buf.WriteString("\t\t}\n")
|
||||
buf.WriteString("\t\t(*m)[k] = v\n")
|
||||
buf.WriteString("\t}\n")
|
||||
buf.WriteString("\treturn nil\n")
|
||||
buf.WriteString("}\n")
|
||||
}
|
||||
}
|
||||
|
||||
// flatTypeToGo converts a flat path type annotation to a Go type string.
|
||||
func flatTypeToGo(typ, index string) string {
|
||||
base := primitiveToGo(typ)
|
||||
|
||||
if index == "" {
|
||||
return base
|
||||
}
|
||||
|
||||
// Parse index segments right-to-left to build the type inside-out
|
||||
var indices []string
|
||||
i := 0
|
||||
for i < len(index) {
|
||||
if index[i] != '[' {
|
||||
break
|
||||
}
|
||||
end := strings.IndexByte(index[i:], ']')
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
indices = append(indices, index[i:i+end+1])
|
||||
i = i + end + 1
|
||||
}
|
||||
|
||||
result := base
|
||||
for j := len(indices) - 1; j >= 0; j-- {
|
||||
idx := indices[j]
|
||||
switch idx {
|
||||
case "[]":
|
||||
result = "[]" + result
|
||||
case "[int]":
|
||||
result = "map[int]" + result
|
||||
case "[string]":
|
||||
result = "map[string]" + result
|
||||
default:
|
||||
key := idx[1 : len(idx)-1]
|
||||
result = "map[" + key + "]" + result
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func primitiveToGo(typ string) string {
|
||||
switch typ {
|
||||
case "string":
|
||||
return "string"
|
||||
case "int":
|
||||
return "int64"
|
||||
case "float":
|
||||
return "float64"
|
||||
case "bool":
|
||||
return "bool"
|
||||
case "null", "unknown":
|
||||
return "any"
|
||||
default:
|
||||
return typ
|
||||
}
|
||||
}
|
||||
|
||||
func isPrimitiveType(typ string) bool {
|
||||
switch typ {
|
||||
case "string", "int", "float", "bool", "null", "unknown", "any":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func makePointer(typ string) string {
|
||||
if strings.HasPrefix(typ, "[]") || strings.HasPrefix(typ, "map[") {
|
||||
return typ
|
||||
}
|
||||
return "*" + typ
|
||||
}
|
||||
|
||||
func cleanTypeName(typ string) string {
|
||||
return strings.TrimSuffix(typ, "?")
|
||||
}
|
||||
|
||||
func sortGoFields(fields []goField) {
|
||||
priority := map[string]int{
|
||||
"id": 0, "name": 1, "type": 2, "slug": 3, "label": 4,
|
||||
}
|
||||
sort.SliceStable(fields, func(i, j int) bool {
|
||||
pi, oki := priority[fields[i].jsonName]
|
||||
pj, okj := priority[fields[j].jsonName]
|
||||
if oki && okj {
|
||||
return pi < pj
|
||||
}
|
||||
if oki {
|
||||
return true
|
||||
}
|
||||
if okj {
|
||||
return false
|
||||
}
|
||||
return fields[i].jsonName < fields[j].jsonName
|
||||
})
|
||||
}
|
||||
1076
tools/jsontypes/gostruct_test.go
Normal file
1076
tools/jsontypes/gostruct_test.go
Normal file
File diff suppressed because it is too large
Load Diff
370
tools/jsontypes/heuristics.go
Normal file
370
tools/jsontypes/heuristics.go
Normal file
@ -0,0 +1,370 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// looksLikeMap uses heuristics to guess whether an object is a map (keyed
|
||||
// collection) rather than a struct. Returns true/false and a confidence hint.
|
||||
// If confidence is low, the caller should prompt the user.
|
||||
func looksLikeMap(obj map[string]any) (isMap bool, confident bool) {
|
||||
keys := sortedKeys(obj)
|
||||
n := len(keys)
|
||||
if n < 3 {
|
||||
// Too few keys to be confident about anything
|
||||
return false, false
|
||||
}
|
||||
|
||||
// All keys are integers?
|
||||
allInts := true
|
||||
for _, k := range keys {
|
||||
if _, err := strconv.ParseInt(k, 10, 64); err != nil {
|
||||
allInts = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allInts {
|
||||
return true, true
|
||||
}
|
||||
|
||||
// All keys same length and contain mixed letters+digits → likely IDs
|
||||
if allSameLength(keys) && allAlphanumericWithDigits(keys) {
|
||||
return true, true
|
||||
}
|
||||
|
||||
// All keys same length and look like base64/hex IDs
|
||||
if allSameLength(keys) && allLookLikeIDs(keys) {
|
||||
return true, true
|
||||
}
|
||||
|
||||
// Keys look like typical struct field names (camelCase, snake_case, short words)
|
||||
// This must be checked before value-shape heuristics: a struct with many
|
||||
// fields whose values happen to share a shape is still a struct.
|
||||
if allLookLikeFieldNames(keys) {
|
||||
return false, true
|
||||
}
|
||||
|
||||
// Large number of keys where most values have the same shape — likely a map
|
||||
if n > 20 && valuesHaveSimilarShape(obj) {
|
||||
return true, true
|
||||
}
|
||||
|
||||
return false, false
|
||||
}
|
||||
|
||||
func allSameLength(keys []string) bool {
|
||||
if len(keys) == 0 {
|
||||
return true
|
||||
}
|
||||
l := len(keys[0])
|
||||
for _, k := range keys[1:] {
|
||||
if len(k) != l {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// allLookLikeIDs checks if keys look like identifiers/tokens rather than field
|
||||
// names: no spaces, alphanumeric/base64/hex, and not common English field names.
|
||||
func allLookLikeIDs(keys []string) bool {
|
||||
for _, k := range keys {
|
||||
if strings.ContainsAny(k, " \t\n") {
|
||||
return false
|
||||
}
|
||||
// Hex or base64 strings of any length ≥ 4
|
||||
if len(k) >= 4 && (isHex(k) || isAlphanumeric(k) || isBase64(k)) {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
// Additional check: IDs typically don't look like field names.
|
||||
// If ALL of them look like field names (e.g., camelCase), not IDs.
|
||||
if allLookLikeFieldNames(keys) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isAlphanumeric(s string) bool {
|
||||
for _, r := range s {
|
||||
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// allAlphanumericWithDigits checks if all keys are alphanumeric and each
|
||||
// contains at least one digit (distinguishing IDs like "abc123" from field
|
||||
// names like "name").
|
||||
func allAlphanumericWithDigits(keys []string) bool {
|
||||
for _, k := range keys {
|
||||
hasDigit := false
|
||||
for _, r := range k {
|
||||
if unicode.IsDigit(r) {
|
||||
hasDigit = true
|
||||
} else if !unicode.IsLetter(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if !hasDigit {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func isBase64(s string) bool {
|
||||
// Try standard and URL-safe base64
|
||||
if _, err := base64.StdEncoding.DecodeString(s); err == nil {
|
||||
return true
|
||||
}
|
||||
if _, err := base64.URLEncoding.DecodeString(s); err == nil {
|
||||
return true
|
||||
}
|
||||
if _, err := base64.RawURLEncoding.DecodeString(s); err == nil {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isHex(s string) bool {
|
||||
for _, r := range s {
|
||||
if !((r >= '0' && r <= '9') || (r >= 'a' && r <= 'f') || (r >= 'A' && r <= 'F')) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// allLookLikeFieldNames checks if keys look like typical struct field names:
|
||||
// camelCase, snake_case, PascalCase, or short lowercase words.
|
||||
func allLookLikeFieldNames(keys []string) bool {
|
||||
fieldLike := 0
|
||||
for _, k := range keys {
|
||||
if looksLikeFieldName(k) {
|
||||
fieldLike++
|
||||
}
|
||||
}
|
||||
// If >80% look like field names, probably a struct
|
||||
return fieldLike > len(keys)*4/5
|
||||
}
|
||||
|
||||
func looksLikeFieldName(k string) bool {
|
||||
if len(k) == 0 || len(k) > 40 {
|
||||
return false
|
||||
}
|
||||
// Must start with a letter
|
||||
runes := []rune(k)
|
||||
if !unicode.IsLetter(runes[0]) {
|
||||
return false
|
||||
}
|
||||
// Only letters, digits, underscores
|
||||
for _, r := range runes {
|
||||
if !unicode.IsLetter(r) && !unicode.IsDigit(r) && r != '_' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// valuesHaveSimilarShape checks if most values in the object are objects with
|
||||
// similar key sets.
|
||||
func valuesHaveSimilarShape(obj map[string]any) bool {
|
||||
shapes := make(map[string]int)
|
||||
total := 0
|
||||
for _, v := range obj {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
shapes[shapeSignature(m)]++
|
||||
total++
|
||||
}
|
||||
}
|
||||
if total == 0 {
|
||||
return false
|
||||
}
|
||||
// Find most common shape
|
||||
maxCount := 0
|
||||
for _, count := range shapes {
|
||||
if count > maxCount {
|
||||
maxCount = count
|
||||
}
|
||||
}
|
||||
return maxCount > total/2
|
||||
}
|
||||
|
||||
// inferKeyName tries to infer a meaningful key name from the map's keys.
|
||||
func inferKeyName(obj map[string]any) string {
|
||||
keys := sortedKeys(obj)
|
||||
if len(keys) == 0 {
|
||||
return "string"
|
||||
}
|
||||
|
||||
// All numeric?
|
||||
allNum := true
|
||||
for _, k := range keys {
|
||||
if _, err := strconv.ParseInt(k, 10, 64); err != nil {
|
||||
allNum = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if allNum {
|
||||
return "int"
|
||||
}
|
||||
|
||||
// Check if all values are objects with a common field that matches the
|
||||
// key (e.g., keys are "abc123" and objects have an "id" field with "abc123").
|
||||
// This suggests the key name is "id".
|
||||
for _, fieldName := range []string{"id", "ID", "Id", "_id"} {
|
||||
match := true
|
||||
for k, v := range obj {
|
||||
if m, ok := v.(map[string]any); ok {
|
||||
if val, exists := m[fieldName]; exists {
|
||||
if fmt.Sprintf("%v", val) == k {
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
match = false
|
||||
break
|
||||
}
|
||||
if match && len(obj) > 0 {
|
||||
return fieldName
|
||||
}
|
||||
}
|
||||
|
||||
return "string"
|
||||
}
|
||||
|
||||
// ambiguousTypeNames maps lowercase inferred names to their canonical form.
|
||||
// When one of these is inferred, the parent type name is prepended and the
|
||||
// canonical form is used (e.g., "json" in any casing → ParentJSON).
|
||||
var ambiguousTypeNames = map[string]string{
|
||||
"json": "JSON",
|
||||
"data": "Data",
|
||||
"item": "Item",
|
||||
"value": "Value",
|
||||
"result": "Result",
|
||||
}
|
||||
|
||||
// inferTypeName tries to guess a struct name from the path context.
|
||||
func inferTypeName(path string) string {
|
||||
// Root path → "Root"
|
||||
if path == "." {
|
||||
return "Root"
|
||||
}
|
||||
|
||||
// Root-level collection items (no parent type yet)
|
||||
// e.g., ".[]", ".[string]", ".[int]"
|
||||
if !strings.Contains(path, "{") {
|
||||
name := inferTypeNameFromSegments(path)
|
||||
if name == "" {
|
||||
return "RootItem"
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
return inferTypeNameFromSegments(path)
|
||||
}
|
||||
|
||||
func inferTypeNameFromSegments(path string) string {
|
||||
// Extract the last meaningful segment from the path
|
||||
// e.g., ".friends[int]" → "Friend", ".{Person}.address" → "Address"
|
||||
parts := strings.FieldsFunc(path, func(r rune) bool {
|
||||
return r == '.' || r == '[' || r == ']' || r == '{' || r == '}'
|
||||
})
|
||||
if len(parts) == 0 {
|
||||
return ""
|
||||
}
|
||||
last := parts[len(parts)-1]
|
||||
// Skip index-like segments
|
||||
if last == "int" || last == "string" || last == "id" {
|
||||
if len(parts) >= 2 {
|
||||
last = parts[len(parts)-2]
|
||||
} else {
|
||||
return ""
|
||||
}
|
||||
}
|
||||
// Strip common suffixes like _id, _key, Id
|
||||
last = strings.TrimSuffix(last, "_id")
|
||||
last = strings.TrimSuffix(last, "_key")
|
||||
last = strings.TrimSuffix(last, "Id")
|
||||
last = strings.TrimSuffix(last, "Key")
|
||||
if last == "" {
|
||||
return ""
|
||||
}
|
||||
name := singularize(snakeToPascal(last))
|
||||
|
||||
// If the inferred name is too generic, use canonical form and prepend parent
|
||||
if canonical, ok := ambiguousTypeNames[strings.ToLower(name)]; ok {
|
||||
parent := parentTypeName(path)
|
||||
if parent != "" {
|
||||
return parent + canonical
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
|
||||
return name
|
||||
}
|
||||
|
||||
// isUbiquitousField returns true if a field name is so common across all
|
||||
// domains (databases, APIs, languages) that sharing it doesn't imply the
|
||||
// objects are the same type. These are excluded when deciding whether to
|
||||
// default to "same" or "different" types.
|
||||
func isUbiquitousField(name string) bool {
|
||||
// Exact matches
|
||||
switch name {
|
||||
case "id", "ID", "Id", "_id",
|
||||
"name", "Name",
|
||||
"type", "Type", "_type",
|
||||
"kind", "Kind",
|
||||
"slug", "Slug",
|
||||
"label", "Label",
|
||||
"title", "Title",
|
||||
"description", "Description":
|
||||
return true
|
||||
}
|
||||
// Suffix patterns: *_at, *_on, *At, *On (timestamps/dates)
|
||||
if strings.HasSuffix(name, "_at") || strings.HasSuffix(name, "_on") ||
|
||||
strings.HasSuffix(name, "At") || strings.HasSuffix(name, "On") {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// snakeToPascal converts snake_case or camelCase to PascalCase.
|
||||
func snakeToPascal(s string) string {
|
||||
parts := strings.Split(s, "_")
|
||||
for i, p := range parts {
|
||||
parts[i] = capitalize(p)
|
||||
}
|
||||
return strings.Join(parts, "")
|
||||
}
|
||||
|
||||
func capitalize(s string) string {
|
||||
if len(s) == 0 {
|
||||
return s
|
||||
}
|
||||
return strings.ToUpper(s[:1]) + s[1:]
|
||||
}
|
||||
|
||||
// singularize does a naive singularization for common English plurals.
|
||||
func singularize(s string) string {
|
||||
if strings.HasSuffix(s, "ies") && len(s) > 4 {
|
||||
return s[:len(s)-3] + "y"
|
||||
}
|
||||
if strings.HasSuffix(s, "ses") || strings.HasSuffix(s, "xes") || strings.HasSuffix(s, "zes") {
|
||||
return s[:len(s)-2]
|
||||
}
|
||||
if strings.HasSuffix(s, "ss") || strings.HasSuffix(s, "us") || strings.HasSuffix(s, "is") {
|
||||
return s // not plural
|
||||
}
|
||||
if strings.HasSuffix(s, "s") && len(s) > 3 {
|
||||
return s[:len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
56
tools/jsontypes/jsdoc.go
Normal file
56
tools/jsontypes/jsdoc.go
Normal file
@ -0,0 +1,56 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// generateJSDoc converts formatted flat paths into JSDoc @typedef annotations.
|
||||
func GenerateJSDoc(paths []string) string {
|
||||
types, _ := buildGoTypes(paths)
|
||||
if len(types) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
for i, t := range types {
|
||||
if i > 0 {
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("/**\n * @typedef {Object} %s\n", t.name))
|
||||
for _, f := range t.fields {
|
||||
jsType := goTypeToJSDoc(f.goType)
|
||||
if f.optional {
|
||||
buf.WriteString(fmt.Sprintf(" * @property {%s} [%s]\n", jsType, f.jsonName))
|
||||
} else {
|
||||
buf.WriteString(fmt.Sprintf(" * @property {%s} %s\n", jsType, f.jsonName))
|
||||
}
|
||||
}
|
||||
buf.WriteString(" */\n")
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func goTypeToJSDoc(goTyp string) string {
|
||||
goTyp = strings.TrimPrefix(goTyp, "*")
|
||||
|
||||
if strings.HasPrefix(goTyp, "[]") {
|
||||
return goTypeToJSDoc(goTyp[2:]) + "[]"
|
||||
}
|
||||
if strings.HasPrefix(goTyp, "map[string]") {
|
||||
return "Object<string, " + goTypeToJSDoc(goTyp[11:]) + ">"
|
||||
}
|
||||
|
||||
switch goTyp {
|
||||
case "string":
|
||||
return "string"
|
||||
case "int64", "float64":
|
||||
return "number"
|
||||
case "bool":
|
||||
return "boolean"
|
||||
case "any":
|
||||
return "*"
|
||||
default:
|
||||
return goTyp
|
||||
}
|
||||
}
|
||||
101
tools/jsontypes/jsdoc_test.go
Normal file
101
tools/jsontypes/jsdoc_test.go
Normal file
@ -0,0 +1,101 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateJSDocFlat(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".name{string}",
|
||||
".age{int}",
|
||||
".active{bool}",
|
||||
}
|
||||
out := GenerateJSDoc(paths)
|
||||
assertContainsAll(t, out,
|
||||
"@typedef {Object} Root",
|
||||
"@property {string} name",
|
||||
"@property {number} age",
|
||||
"@property {boolean} active",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerateJSDocOptional(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".name{string}",
|
||||
".bio{string?}",
|
||||
}
|
||||
out := GenerateJSDoc(paths)
|
||||
assertContainsAll(t, out,
|
||||
"@property {string} name",
|
||||
"@property {string} [bio]",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerateJSDocNested(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".addr{Address}",
|
||||
".addr.city{string}",
|
||||
}
|
||||
out := GenerateJSDoc(paths)
|
||||
assertContainsAll(t, out,
|
||||
"@typedef {Object} Root",
|
||||
"@property {Address} addr",
|
||||
"@typedef {Object} Address",
|
||||
"@property {string} city",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerateJSDocArray(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".items[]{Item}",
|
||||
".items[].id{string}",
|
||||
}
|
||||
out := GenerateJSDoc(paths)
|
||||
assertContainsAll(t, out,
|
||||
"@property {Item[]} items",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerateJSDocMap(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".scores[string]{Score}",
|
||||
".scores[string].value{int}",
|
||||
}
|
||||
out := GenerateJSDoc(paths)
|
||||
assertContainsAll(t, out,
|
||||
"@property {Object<string, Score>} scores",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerateJSDocEmpty(t *testing.T) {
|
||||
out := GenerateJSDoc(nil)
|
||||
if out != "" {
|
||||
t.Errorf("expected empty output, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateJSDocEndToEnd(t *testing.T) {
|
||||
jsonStr := `{"name":"Alice","age":30,"tags":["a"],"meta":{"key":"val"}}`
|
||||
paths := analyzeAndFormat(t, jsonStr)
|
||||
out := GenerateJSDoc(paths)
|
||||
assertContainsAll(t, out,
|
||||
"@typedef {Object}",
|
||||
"@property {string} name",
|
||||
"@property {number} age",
|
||||
)
|
||||
}
|
||||
|
||||
func assertContainsAll(t *testing.T, got string, wants ...string) {
|
||||
t.Helper()
|
||||
for _, want := range wants {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output missing %q\ngot:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
115
tools/jsontypes/jsonschema.go
Normal file
115
tools/jsontypes/jsonschema.go
Normal file
@ -0,0 +1,115 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// generateJSONSchema converts formatted flat paths into a JSON Schema (draft 2020-12) document.
|
||||
func GenerateJSONSchema(paths []string) string {
|
||||
types, _ := buildGoTypes(paths)
|
||||
|
||||
typeMap := make(map[string]goType)
|
||||
for _, t := range types {
|
||||
typeMap[t.name] = t
|
||||
}
|
||||
|
||||
if len(types) == 0 {
|
||||
return "{}\n"
|
||||
}
|
||||
|
||||
root := types[0]
|
||||
defs := make(map[string]any)
|
||||
result := structToJSONSchema(root, typeMap, defs)
|
||||
result["$schema"] = "https://json-schema.org/draft/2020-12/schema"
|
||||
|
||||
if len(defs) > 0 {
|
||||
result["$defs"] = defs
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(result, "", " ")
|
||||
return string(data) + "\n"
|
||||
}
|
||||
|
||||
func structToJSONSchema(t goType, typeMap map[string]goType, defs map[string]any) map[string]any {
|
||||
props := make(map[string]any)
|
||||
var required []string
|
||||
|
||||
for _, f := range t.fields {
|
||||
schema := goTypeToJSONSchema(f.goType, f.optional, typeMap, defs)
|
||||
props[f.jsonName] = schema
|
||||
if !f.optional {
|
||||
required = append(required, f.jsonName)
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]any{
|
||||
"type": "object",
|
||||
"properties": props,
|
||||
}
|
||||
if len(required) > 0 {
|
||||
result["required"] = required
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func goTypeToJSONSchema(goTyp string, nullable bool, typeMap map[string]goType, defs map[string]any) map[string]any {
|
||||
result := goTypeToJSONSchemaInner(goTyp, typeMap, defs)
|
||||
if nullable {
|
||||
// JSON Schema nullable: anyOf with null
|
||||
return map[string]any{
|
||||
"anyOf": []any{
|
||||
result,
|
||||
map[string]any{"type": "null"},
|
||||
},
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func goTypeToJSONSchemaInner(goTyp string, typeMap map[string]goType, defs map[string]any) map[string]any {
|
||||
goTyp = strings.TrimPrefix(goTyp, "*")
|
||||
|
||||
// Slice
|
||||
if strings.HasPrefix(goTyp, "[]") {
|
||||
elemType := goTyp[2:]
|
||||
return map[string]any{
|
||||
"type": "array",
|
||||
"items": goTypeToJSONSchemaInner(elemType, typeMap, defs),
|
||||
}
|
||||
}
|
||||
|
||||
// Map
|
||||
if strings.HasPrefix(goTyp, "map[string]") {
|
||||
valType := goTyp[11:]
|
||||
return map[string]any{
|
||||
"type": "object",
|
||||
"additionalProperties": goTypeToJSONSchemaInner(valType, typeMap, defs),
|
||||
}
|
||||
}
|
||||
|
||||
// Primitives
|
||||
switch goTyp {
|
||||
case "string":
|
||||
return map[string]any{"type": "string"}
|
||||
case "int64":
|
||||
return map[string]any{"type": "integer"}
|
||||
case "float64":
|
||||
return map[string]any{"type": "number"}
|
||||
case "bool":
|
||||
return map[string]any{"type": "boolean"}
|
||||
case "any":
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
// Named struct — emit as $ref, add to $defs
|
||||
if t, ok := typeMap[goTyp]; ok {
|
||||
if _, exists := defs[goTyp]; !exists {
|
||||
defs[goTyp] = nil // placeholder
|
||||
defs[goTyp] = structToJSONSchema(t, typeMap, defs)
|
||||
}
|
||||
return map[string]any{"$ref": "#/$defs/" + goTyp}
|
||||
}
|
||||
|
||||
return map[string]any{}
|
||||
}
|
||||
167
tools/jsontypes/jsonschema_test.go
Normal file
167
tools/jsontypes/jsonschema_test.go
Normal file
@ -0,0 +1,167 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateJSONSchemaFlat(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".name{string}",
|
||||
".age{int}",
|
||||
".active{bool}",
|
||||
}
|
||||
out := GenerateJSONSchema(paths)
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &doc); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, out)
|
||||
}
|
||||
if doc["$schema"] != "https://json-schema.org/draft/2020-12/schema" {
|
||||
t.Errorf("missing or wrong $schema")
|
||||
}
|
||||
if doc["type"] != "object" {
|
||||
t.Errorf("expected type=object, got %v", doc["type"])
|
||||
}
|
||||
props := doc["properties"].(map[string]any)
|
||||
assertJSType(t, props, "name", "string")
|
||||
assertJSType(t, props, "age", "integer")
|
||||
assertJSType(t, props, "active", "boolean")
|
||||
|
||||
// Check required
|
||||
req := doc["required"].([]any)
|
||||
reqSet := make(map[string]bool)
|
||||
for _, r := range req {
|
||||
reqSet[r.(string)] = true
|
||||
}
|
||||
for _, f := range []string{"name", "age", "active"} {
|
||||
if !reqSet[f] {
|
||||
t.Errorf("expected %q in required", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateJSONSchemaNested(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".addr{Address}",
|
||||
".addr.city{string}",
|
||||
".addr.zip{string}",
|
||||
}
|
||||
out := GenerateJSONSchema(paths)
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &doc); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, out)
|
||||
}
|
||||
props := doc["properties"].(map[string]any)
|
||||
addr := props["addr"].(map[string]any)
|
||||
if addr["$ref"] != "#/$defs/Address" {
|
||||
t.Errorf("expected $ref=#/$defs/Address, got %v", addr)
|
||||
}
|
||||
defs := doc["$defs"].(map[string]any)
|
||||
addrDef := defs["Address"].(map[string]any)
|
||||
addrProps := addrDef["properties"].(map[string]any)
|
||||
assertJSType(t, addrProps, "city", "string")
|
||||
}
|
||||
|
||||
func TestGenerateJSONSchemaArray(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".items[]{Item}",
|
||||
".items[].id{string}",
|
||||
}
|
||||
out := GenerateJSONSchema(paths)
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &doc); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, out)
|
||||
}
|
||||
props := doc["properties"].(map[string]any)
|
||||
items := props["items"].(map[string]any)
|
||||
if items["type"] != "array" {
|
||||
t.Errorf("expected type=array, got %v", items["type"])
|
||||
}
|
||||
itemsItems := items["items"].(map[string]any)
|
||||
if itemsItems["$ref"] != "#/$defs/Item" {
|
||||
t.Errorf("expected items.$ref=#/$defs/Item, got %v", itemsItems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateJSONSchemaOptional(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".name{string}",
|
||||
".bio{string?}",
|
||||
}
|
||||
out := GenerateJSONSchema(paths)
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &doc); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, out)
|
||||
}
|
||||
props := doc["properties"].(map[string]any)
|
||||
bio := props["bio"].(map[string]any)
|
||||
anyOf := bio["anyOf"].([]any)
|
||||
if len(anyOf) != 2 {
|
||||
t.Fatalf("expected 2 anyOf entries, got %d", len(anyOf))
|
||||
}
|
||||
// bio should not be in required
|
||||
req := doc["required"].([]any)
|
||||
for _, r := range req {
|
||||
if r.(string) == "bio" {
|
||||
t.Errorf("bio should not be in required")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateJSONSchemaMap(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".scores[string]{Score}",
|
||||
".scores[string].value{int}",
|
||||
}
|
||||
out := GenerateJSONSchema(paths)
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &doc); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, out)
|
||||
}
|
||||
props := doc["properties"].(map[string]any)
|
||||
scores := props["scores"].(map[string]any)
|
||||
if scores["type"] != "object" {
|
||||
t.Errorf("expected type=object for map, got %v", scores["type"])
|
||||
}
|
||||
addl := scores["additionalProperties"].(map[string]any)
|
||||
if addl["$ref"] != "#/$defs/Score" {
|
||||
t.Errorf("expected additionalProperties.$ref=#/$defs/Score, got %v", addl)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateJSONSchemaEmpty(t *testing.T) {
|
||||
out := GenerateJSONSchema(nil)
|
||||
if out != "{}\n" {
|
||||
t.Errorf("expected empty schema, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateJSONSchemaEndToEnd(t *testing.T) {
|
||||
jsonStr := `{"name":"Alice","age":30,"tags":["a","b"],"meta":{"key":"val"}}`
|
||||
paths := analyzeAndFormat(t, jsonStr)
|
||||
out := GenerateJSONSchema(paths)
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &doc); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, out)
|
||||
}
|
||||
if doc["type"] != "object" {
|
||||
t.Errorf("expected type=object at root: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func assertJSType(t *testing.T, props map[string]any, field, expected string) {
|
||||
t.Helper()
|
||||
f, ok := props[field].(map[string]any)
|
||||
if !ok {
|
||||
t.Errorf("field %q not found in properties", field)
|
||||
return
|
||||
}
|
||||
if f["type"] != expected {
|
||||
t.Errorf("field %q: expected type=%q, got %v", field, expected, f["type"])
|
||||
}
|
||||
}
|
||||
234
tools/jsontypes/prompt.go
Normal file
234
tools/jsontypes/prompt.go
Normal file
@ -0,0 +1,234 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Prompter struct {
|
||||
reader *bufio.Reader
|
||||
output io.Writer
|
||||
tty *os.File // non-nil if we opened /dev/tty
|
||||
|
||||
// Answer replay/recording
|
||||
priorAnswers []string // loaded from .answers file
|
||||
priorIdx int // next prior answer to use
|
||||
answers []string // all answers this session (for saving)
|
||||
}
|
||||
|
||||
// newPrompter creates a prompter. If the JSON input comes from stdin, we open
|
||||
// /dev/tty for interactive prompts so they don't conflict.
|
||||
func NewPrompter(inputIsStdin, anonymous bool) (*Prompter, error) {
|
||||
p := &Prompter{output: os.Stderr}
|
||||
if inputIsStdin {
|
||||
if anonymous {
|
||||
// No prompts needed — use a closed reader that returns EOF
|
||||
p.reader = bufio.NewReader(strings.NewReader(""))
|
||||
} else {
|
||||
tty, err := os.Open("/dev/tty")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("cannot open /dev/tty for prompts (input is stdin): %w", err)
|
||||
}
|
||||
p.tty = tty
|
||||
p.reader = bufio.NewReader(tty)
|
||||
}
|
||||
} else {
|
||||
p.reader = bufio.NewReader(os.Stdin)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// loadAnswers reads prior answers from a file to use as defaults.
|
||||
func (p *Prompter) LoadAnswers(path string) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
lines := strings.Split(strings.TrimRight(string(data), "\n"), "\n")
|
||||
// Filter out empty trailing lines
|
||||
for len(lines) > 0 && lines[len(lines)-1] == "" {
|
||||
lines = lines[:len(lines)-1]
|
||||
}
|
||||
if len(lines) > 0 {
|
||||
fmt.Fprintf(p.output, "using prior answers from %s\n", path)
|
||||
p.priorAnswers = lines
|
||||
}
|
||||
}
|
||||
|
||||
// saveAnswers writes this session's answers to a file.
|
||||
func (p *Prompter) SaveAnswers(path string) error {
|
||||
if len(p.answers) == 0 {
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(path, []byte(strings.Join(p.answers, "\n")+"\n"), 0o600)
|
||||
}
|
||||
|
||||
// nextPrior returns the next prior answer if available, or empty string.
|
||||
func (p *Prompter) nextPrior() string {
|
||||
if p.priorIdx < len(p.priorAnswers) {
|
||||
answer := p.priorAnswers[p.priorIdx]
|
||||
p.priorIdx++
|
||||
return answer
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// record saves an answer for later writing.
|
||||
func (p *Prompter) record(answer string) {
|
||||
p.answers = append(p.answers, answer)
|
||||
}
|
||||
|
||||
func (p *Prompter) Close() {
|
||||
if p.tty != nil {
|
||||
p.tty.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// ask presents a prompt with a default and valid options. Returns the chosen
|
||||
// option (lowercase). Options should be lowercase; the default is shown in
|
||||
// uppercase in the hint.
|
||||
func (p *Prompter) ask(prompt, defaultOpt string, options []string) string {
|
||||
// Override default with prior answer if available
|
||||
if prior := p.nextPrior(); prior != "" {
|
||||
for _, o := range options {
|
||||
if prior == o {
|
||||
defaultOpt = prior
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
hint := make([]string, len(options))
|
||||
for i, o := range options {
|
||||
if o == defaultOpt {
|
||||
hint[i] = strings.ToUpper(o)
|
||||
} else {
|
||||
hint[i] = o
|
||||
}
|
||||
}
|
||||
for {
|
||||
fmt.Fprintf(p.output, "%s [%s] ", prompt, strings.Join(hint, "/"))
|
||||
line, err := p.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
p.record(defaultOpt)
|
||||
return defaultOpt
|
||||
}
|
||||
line = strings.TrimSpace(strings.ToLower(line))
|
||||
if line == "" {
|
||||
p.record(defaultOpt)
|
||||
return defaultOpt
|
||||
}
|
||||
for _, o := range options {
|
||||
if line == o {
|
||||
p.record(o)
|
||||
return o
|
||||
}
|
||||
}
|
||||
fmt.Fprintf(p.output, " Please enter one of: %s\n", strings.Join(options, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
// askMapOrName presents a combined map/struct+name prompt. Shows [Default/m].
|
||||
// Accepts: 'm' or 'map' → returns "m", a name starting with an uppercase
|
||||
// letter → returns the name, empty → returns the default. Anything else
|
||||
// re-prompts.
|
||||
//
|
||||
// Prior answers are interpreted generously: "s" (old struct answer) is treated
|
||||
// as "accept the default struct name", "m" as map, and uppercase names as-is.
|
||||
func (p *Prompter) askMapOrName(prompt, defaultVal string) string {
|
||||
if prior := p.nextPrior(); prior != "" {
|
||||
if prior == "m" || prior == "map" {
|
||||
defaultVal = prior
|
||||
} else if len(prior) > 0 && prior[0] >= 'A' && prior[0] <= 'Z' {
|
||||
defaultVal = prior
|
||||
}
|
||||
// Old-format answers like "s" → keep the inferred default (treat as "accept")
|
||||
}
|
||||
|
||||
hint := defaultVal + "/m"
|
||||
if defaultVal == "m" {
|
||||
hint = "m"
|
||||
}
|
||||
|
||||
for {
|
||||
fmt.Fprintf(p.output, "%s [%s] ", prompt, hint)
|
||||
line, err := p.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
p.record(defaultVal)
|
||||
return defaultVal
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
p.record(defaultVal)
|
||||
return defaultVal
|
||||
}
|
||||
if line == "m" || line == "map" {
|
||||
p.record("m")
|
||||
return "m"
|
||||
}
|
||||
if len(line) > 0 && line[0] >= 'A' && line[0] <= 'Z' {
|
||||
p.record(line)
|
||||
return line
|
||||
}
|
||||
fmt.Fprintf(p.output, " Enter a TypeName (starting with uppercase), or 'm' for map\n")
|
||||
}
|
||||
}
|
||||
|
||||
// askTypeName presents a prompt for a type name with a suggested default.
|
||||
// Accepts names starting with an uppercase letter.
|
||||
//
|
||||
// Prior answers are interpreted generously: old-format answers that don't
|
||||
// start with uppercase are treated as "accept the default".
|
||||
func (p *Prompter) askTypeName(prompt, defaultVal string) string {
|
||||
if prior := p.nextPrior(); prior != "" {
|
||||
if len(prior) > 0 && prior[0] >= 'A' && prior[0] <= 'Z' {
|
||||
defaultVal = prior
|
||||
}
|
||||
// Old-format answers → keep the inferred default (treat as "accept")
|
||||
}
|
||||
|
||||
for {
|
||||
fmt.Fprintf(p.output, "%s [%s] ", prompt, defaultVal)
|
||||
line, err := p.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
p.record(defaultVal)
|
||||
return defaultVal
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
p.record(defaultVal)
|
||||
return defaultVal
|
||||
}
|
||||
if len(line) > 0 && line[0] >= 'A' && line[0] <= 'Z' {
|
||||
p.record(line)
|
||||
return line
|
||||
}
|
||||
fmt.Fprintf(p.output, " Enter a TypeName (starting with uppercase)\n")
|
||||
}
|
||||
}
|
||||
|
||||
// askFreeform presents a prompt with a suggested default. Returns user input
|
||||
// or the default if they just press enter.
|
||||
func (p *Prompter) askFreeform(prompt, defaultVal string) string {
|
||||
// Override default with prior answer if available
|
||||
if prior := p.nextPrior(); prior != "" {
|
||||
defaultVal = prior
|
||||
}
|
||||
|
||||
fmt.Fprintf(p.output, "%s [%s] ", prompt, defaultVal)
|
||||
line, err := p.reader.ReadString('\n')
|
||||
if err != nil {
|
||||
p.record(defaultVal)
|
||||
return defaultVal
|
||||
}
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" {
|
||||
p.record(defaultVal)
|
||||
return defaultVal
|
||||
}
|
||||
p.record(line)
|
||||
return line
|
||||
}
|
||||
80
tools/jsontypes/python.go
Normal file
80
tools/jsontypes/python.go
Normal file
@ -0,0 +1,80 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// generatePython converts formatted flat paths into Python TypedDict definitions.
|
||||
func GeneratePython(paths []string) string {
|
||||
types, _ := buildGoTypes(paths)
|
||||
if len(types) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
hasOptional := false
|
||||
for _, t := range types {
|
||||
for _, f := range t.fields {
|
||||
if f.optional {
|
||||
hasOptional = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if hasOptional {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
buf.WriteString("from __future__ import annotations\n\n")
|
||||
if hasOptional {
|
||||
buf.WriteString("from typing import NotRequired, TypedDict\n")
|
||||
} else {
|
||||
buf.WriteString("from typing import TypedDict\n")
|
||||
}
|
||||
|
||||
// Emit in reverse so referenced types come first.
|
||||
for i := len(types) - 1; i >= 0; i-- {
|
||||
t := types[i]
|
||||
buf.WriteString(fmt.Sprintf("\n\nclass %s(TypedDict):\n", t.name))
|
||||
if len(t.fields) == 0 {
|
||||
buf.WriteString(" pass\n")
|
||||
continue
|
||||
}
|
||||
for _, f := range t.fields {
|
||||
pyType := goTypeToPython(f.goType)
|
||||
if f.optional {
|
||||
buf.WriteString(fmt.Sprintf(" %s: NotRequired[%s | None]\n", f.jsonName, pyType))
|
||||
} else {
|
||||
buf.WriteString(fmt.Sprintf(" %s: %s\n", f.jsonName, pyType))
|
||||
}
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func goTypeToPython(goTyp string) string {
|
||||
goTyp = strings.TrimPrefix(goTyp, "*")
|
||||
|
||||
if strings.HasPrefix(goTyp, "[]") {
|
||||
return "list[" + goTypeToPython(goTyp[2:]) + "]"
|
||||
}
|
||||
if strings.HasPrefix(goTyp, "map[string]") {
|
||||
return "dict[str, " + goTypeToPython(goTyp[11:]) + "]"
|
||||
}
|
||||
|
||||
switch goTyp {
|
||||
case "string":
|
||||
return "str"
|
||||
case "int64":
|
||||
return "int"
|
||||
case "float64":
|
||||
return "float"
|
||||
case "bool":
|
||||
return "bool"
|
||||
case "any":
|
||||
return "object"
|
||||
default:
|
||||
return goTyp
|
||||
}
|
||||
}
|
||||
101
tools/jsontypes/python_test.go
Normal file
101
tools/jsontypes/python_test.go
Normal file
@ -0,0 +1,101 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGeneratePythonFlat(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".name{string}",
|
||||
".age{int}",
|
||||
".active{bool}",
|
||||
}
|
||||
out := GeneratePython(paths)
|
||||
assertContainsAll(t, out,
|
||||
"from typing import TypedDict",
|
||||
"class Root(TypedDict):",
|
||||
"name: str",
|
||||
"age: int",
|
||||
"active: bool",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGeneratePythonOptional(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".name{string}",
|
||||
".bio{string?}",
|
||||
}
|
||||
out := GeneratePython(paths)
|
||||
assertContainsAll(t, out,
|
||||
"from typing import NotRequired, TypedDict",
|
||||
"name: str",
|
||||
"bio: NotRequired[str | None]",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGeneratePythonNested(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".addr{Address}",
|
||||
".addr.city{string}",
|
||||
}
|
||||
out := GeneratePython(paths)
|
||||
assertContainsAll(t, out,
|
||||
"class Root(TypedDict):",
|
||||
"addr: Address",
|
||||
"class Address(TypedDict):",
|
||||
"city: str",
|
||||
)
|
||||
// Address should be defined before Root
|
||||
addrIdx := strings.Index(out, "class Address")
|
||||
rootIdx := strings.Index(out, "class Root")
|
||||
if addrIdx < 0 || rootIdx < 0 || addrIdx > rootIdx {
|
||||
t.Errorf("Address should be defined before Root\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePythonArray(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".items[]{Item}",
|
||||
".items[].id{string}",
|
||||
}
|
||||
out := GeneratePython(paths)
|
||||
assertContainsAll(t, out,
|
||||
"items: list[Item]",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGeneratePythonMap(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".scores[string]{Score}",
|
||||
".scores[string].value{int}",
|
||||
}
|
||||
out := GeneratePython(paths)
|
||||
assertContainsAll(t, out,
|
||||
"scores: dict[str, Score]",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGeneratePythonEmpty(t *testing.T) {
|
||||
out := GeneratePython(nil)
|
||||
if out != "" {
|
||||
t.Errorf("expected empty output, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratePythonEndToEnd(t *testing.T) {
|
||||
jsonStr := `{"name":"Alice","age":30,"tags":["a"],"meta":{"key":"val"}}`
|
||||
paths := analyzeAndFormat(t, jsonStr)
|
||||
out := GeneratePython(paths)
|
||||
assertContainsAll(t, out,
|
||||
"class",
|
||||
"TypedDict",
|
||||
"name: str",
|
||||
"age: int",
|
||||
)
|
||||
}
|
||||
154
tools/jsontypes/sql.go
Normal file
154
tools/jsontypes/sql.go
Normal file
@ -0,0 +1,154 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// generateSQL converts formatted flat paths into SQL CREATE TABLE statements.
|
||||
// Nested structs become separate tables with foreign key relationships.
|
||||
// Arrays of structs get a join table or FK pointing back to the parent.
|
||||
func GenerateSQL(paths []string) string {
|
||||
types, _ := buildGoTypes(paths)
|
||||
if len(types) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
typeMap := make(map[string]goType)
|
||||
for _, t := range types {
|
||||
typeMap[t.name] = t
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
|
||||
// Emit in reverse order so referenced tables are created first.
|
||||
for i := len(types) - 1; i >= 0; i-- {
|
||||
t := types[i]
|
||||
if i < len(types)-1 {
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
tableName := toSnakeCase(t.name) + "s"
|
||||
buf.WriteString(fmt.Sprintf("CREATE TABLE %s (\n", tableName))
|
||||
buf.WriteString(" id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY")
|
||||
|
||||
var fks []string
|
||||
for _, f := range t.fields {
|
||||
// Skip "id" — we generate a synthetic primary key
|
||||
if f.jsonName == "id" {
|
||||
continue
|
||||
}
|
||||
colType, fk := goTypeToSQL(f, tableName, typeMap)
|
||||
if colType == "" {
|
||||
continue // skip array-of-struct (handled via FK on child)
|
||||
}
|
||||
buf.WriteString(",\n")
|
||||
col := toSnakeCase(f.jsonName)
|
||||
if fk != "" {
|
||||
col += "_id"
|
||||
}
|
||||
if f.optional {
|
||||
buf.WriteString(fmt.Sprintf(" %s %s", col, colType))
|
||||
} else {
|
||||
buf.WriteString(fmt.Sprintf(" %s %s NOT NULL", col, colType))
|
||||
}
|
||||
if fk != "" {
|
||||
fks = append(fks, fk)
|
||||
}
|
||||
}
|
||||
|
||||
for _, fk := range fks {
|
||||
buf.WriteString(",\n")
|
||||
buf.WriteString(" " + fk)
|
||||
}
|
||||
|
||||
buf.WriteString("\n);\n")
|
||||
|
||||
// For array-of-struct fields, add a FK column on the child table
|
||||
// pointing back to this parent.
|
||||
for _, f := range t.fields {
|
||||
childType := arrayElementType(f.goType)
|
||||
if childType == "" {
|
||||
continue
|
||||
}
|
||||
if _, isStruct := typeMap[childType]; !isStruct {
|
||||
continue
|
||||
}
|
||||
childTable := toSnakeCase(childType) + "s"
|
||||
parentFK := toSnakeCase(t.name) + "_id"
|
||||
buf.WriteString(fmt.Sprintf(
|
||||
"\nALTER TABLE %s ADD COLUMN %s BIGINT REFERENCES %s(id);\n",
|
||||
childTable, parentFK, tableName))
|
||||
}
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
// goTypeToSQL returns (SQL column type, optional FK constraint string).
|
||||
// Returns ("", "") for array-of-struct fields (handled separately).
|
||||
func goTypeToSQL(f goField, parentTable string, typeMap map[string]goType) (string, string) {
|
||||
goTyp := strings.TrimPrefix(f.goType, "*")
|
||||
|
||||
// Array of primitives → use array type or JSON
|
||||
if strings.HasPrefix(goTyp, "[]") {
|
||||
elemType := goTyp[2:]
|
||||
if _, isStruct := typeMap[elemType]; isStruct {
|
||||
return "", "" // handled via FK on child table
|
||||
}
|
||||
return "JSONB", ""
|
||||
}
|
||||
|
||||
// Map → JSONB
|
||||
if strings.HasPrefix(goTyp, "map[") {
|
||||
return "JSONB", ""
|
||||
}
|
||||
|
||||
// Named struct → FK reference
|
||||
if _, isStruct := typeMap[goTyp]; isStruct {
|
||||
refTable := toSnakeCase(goTyp) + "s"
|
||||
col := toSnakeCase(f.jsonName) + "_id"
|
||||
fk := fmt.Sprintf("CONSTRAINT fk_%s FOREIGN KEY (%s) REFERENCES %s(id)",
|
||||
col, col, refTable)
|
||||
return "BIGINT", fk
|
||||
}
|
||||
|
||||
switch goTyp {
|
||||
case "string":
|
||||
return "TEXT", ""
|
||||
case "int64":
|
||||
return "BIGINT", ""
|
||||
case "float64":
|
||||
return "DOUBLE PRECISION", ""
|
||||
case "bool":
|
||||
return "BOOLEAN", ""
|
||||
case "any":
|
||||
return "JSONB", ""
|
||||
default:
|
||||
return "TEXT", ""
|
||||
}
|
||||
}
|
||||
|
||||
// arrayElementType returns the element type if goTyp is []SomeType, else "".
|
||||
func arrayElementType(goTyp string) string {
|
||||
goTyp = strings.TrimPrefix(goTyp, "*")
|
||||
if strings.HasPrefix(goTyp, "[]") {
|
||||
return goTyp[2:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// toSnakeCase converts PascalCase to snake_case.
|
||||
func toSnakeCase(s string) string {
|
||||
var buf strings.Builder
|
||||
for i, r := range s {
|
||||
if r >= 'A' && r <= 'Z' {
|
||||
if i > 0 {
|
||||
buf.WriteByte('_')
|
||||
}
|
||||
buf.WriteRune(r + ('a' - 'A'))
|
||||
} else {
|
||||
buf.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
161
tools/jsontypes/sql_test.go
Normal file
161
tools/jsontypes/sql_test.go
Normal file
@ -0,0 +1,161 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateSQLFlat(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".name{string}",
|
||||
".age{int}",
|
||||
".active{bool}",
|
||||
}
|
||||
out := GenerateSQL(paths)
|
||||
assertContainsAll(t, out,
|
||||
"CREATE TABLE roots (",
|
||||
"id BIGINT PRIMARY KEY GENERATED ALWAYS AS IDENTITY",
|
||||
"name TEXT NOT NULL",
|
||||
"age BIGINT NOT NULL",
|
||||
"active BOOLEAN NOT NULL",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerateSQLOptional(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".name{string}",
|
||||
".bio{string?}",
|
||||
}
|
||||
out := GenerateSQL(paths)
|
||||
// name should be NOT NULL, bio should not
|
||||
if !strings.Contains(out, "name TEXT NOT NULL") {
|
||||
t.Errorf("expected name NOT NULL\n%s", out)
|
||||
}
|
||||
// bio should NOT have NOT NULL
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
if strings.Contains(line, "bio") && strings.Contains(line, "NOT NULL") {
|
||||
t.Errorf("bio should be nullable\n%s", out)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSQLNested(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".addr{Address}",
|
||||
".addr.city{string}",
|
||||
}
|
||||
out := GenerateSQL(paths)
|
||||
assertContainsAll(t, out,
|
||||
"CREATE TABLE roots (",
|
||||
"CREATE TABLE addresss (",
|
||||
"addr_id BIGINT",
|
||||
"REFERENCES addresss(id)",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerateSQLArrayOfStructs(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".items[]{Item}",
|
||||
".items[].slug{string}",
|
||||
".items[].name{string}",
|
||||
}
|
||||
out := GenerateSQL(paths)
|
||||
assertContainsAll(t, out,
|
||||
"CREATE TABLE roots (",
|
||||
"CREATE TABLE items (",
|
||||
"ALTER TABLE items ADD COLUMN root_id BIGINT REFERENCES roots(id)",
|
||||
)
|
||||
// items should NOT appear as a column in roots
|
||||
for _, line := range strings.Split(out, "\n") {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if strings.HasPrefix(trimmed, "items ") && strings.Contains(out, "CREATE TABLE roots") {
|
||||
// This is fine if it's in the items table
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSQLArrayOfPrimitives(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".tags[]{string}",
|
||||
}
|
||||
out := GenerateSQL(paths)
|
||||
assertContainsAll(t, out,
|
||||
"tags JSONB NOT NULL",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerateSQLMap(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".metadata[string]{string}",
|
||||
}
|
||||
out := GenerateSQL(paths)
|
||||
assertContainsAll(t, out,
|
||||
"metadata JSONB NOT NULL",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerateSQLEmpty(t *testing.T) {
|
||||
out := GenerateSQL(nil)
|
||||
if out != "" {
|
||||
t.Errorf("expected empty output, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateSQLEndToEnd(t *testing.T) {
|
||||
jsonStr := `{"name":"Alice","age":30,"tags":["a"],"meta":{"key":"val"}}`
|
||||
paths := analyzeAndFormat(t, jsonStr)
|
||||
out := GenerateSQL(paths)
|
||||
assertContainsAll(t, out,
|
||||
"CREATE TABLE",
|
||||
"TEXT NOT NULL",
|
||||
"BIGINT",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerateSQLRelationships(t *testing.T) {
|
||||
paths := []string{
|
||||
"{User}",
|
||||
".name{string}",
|
||||
".profile{Profile}",
|
||||
".profile.bio{string}",
|
||||
".posts[]{Post}",
|
||||
".posts[].title{string}",
|
||||
".posts[].comments[]{Comment}",
|
||||
".posts[].comments[].body{string}",
|
||||
}
|
||||
out := GenerateSQL(paths)
|
||||
assertContainsAll(t, out,
|
||||
"CREATE TABLE users (",
|
||||
"CREATE TABLE profiles (",
|
||||
"CREATE TABLE posts (",
|
||||
"CREATE TABLE comments (",
|
||||
// User has FK to profile
|
||||
"profile_id BIGINT",
|
||||
"REFERENCES profiles(id)",
|
||||
// Posts have FK back to users
|
||||
"ALTER TABLE posts ADD COLUMN user_id BIGINT REFERENCES users(id)",
|
||||
// Comments have FK back to posts
|
||||
"ALTER TABLE comments ADD COLUMN post_id BIGINT REFERENCES posts(id)",
|
||||
)
|
||||
}
|
||||
|
||||
func TestToSnakeCase(t *testing.T) {
|
||||
tests := []struct{ in, want string }{
|
||||
{"Root", "root"},
|
||||
{"RootItem", "root_item"},
|
||||
{"HTTPServer", "h_t_t_p_server"},
|
||||
{"address", "address"},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
got := toSnakeCase(tc.in)
|
||||
if got != tc.want {
|
||||
t.Errorf("toSnakeCase(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
8
tools/jsontypes/testdata/sample.answers
vendored
Normal file
8
tools/jsontypes/testdata/sample.answers
vendored
Normal file
@ -0,0 +1,8 @@
|
||||
m
|
||||
n
|
||||
s
|
||||
Person
|
||||
Friend
|
||||
n
|
||||
s
|
||||
Identification
|
||||
44
tools/jsontypes/testdata/sample.json
vendored
Normal file
44
tools/jsontypes/testdata/sample.json
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
{
|
||||
"abc123": {
|
||||
"name": "Alice",
|
||||
"age": 30,
|
||||
"active": true,
|
||||
"friends": [
|
||||
{
|
||||
"name": "Bob",
|
||||
"identification": null
|
||||
},
|
||||
{
|
||||
"name": "Charlie",
|
||||
"identification": {
|
||||
"type": "StateID",
|
||||
"number": "12345",
|
||||
"name": "Charlie C"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"def456": {
|
||||
"name": "Dave",
|
||||
"age": 25,
|
||||
"active": false,
|
||||
"friends": []
|
||||
},
|
||||
"ghi789": {
|
||||
"name": "Eve",
|
||||
"age": 28,
|
||||
"active": true,
|
||||
"score": 95.5,
|
||||
"friends": [
|
||||
{
|
||||
"name": "Frank",
|
||||
"identification": {
|
||||
"type": "DriverLicense",
|
||||
"id": "DL-999",
|
||||
"name": "Frank F",
|
||||
"restrictions": ["corrective lenses"]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
14
tools/jsontypes/testdata/sample.paths
vendored
Normal file
14
tools/jsontypes/testdata/sample.paths
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
[string]{Person}
|
||||
[string].active{bool}
|
||||
[string].age{int}
|
||||
[string].friends[]{Friend}
|
||||
[string].friends[].identification{Identification?}
|
||||
[string].friends[].identification.id{string?}
|
||||
[string].friends[].identification.name{string}
|
||||
[string].friends[].identification.number{string?}
|
||||
[string].friends[].identification.restrictions{null}
|
||||
[string].friends[].identification.restrictions[]{string}
|
||||
[string].friends[].identification.type{string}
|
||||
[string].friends[].name{string}
|
||||
[string].name{string}
|
||||
[string].score{float?}
|
||||
116
tools/jsontypes/typedef.go
Normal file
116
tools/jsontypes/typedef.go
Normal file
@ -0,0 +1,116 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// generateTypedef converts formatted flat paths into a JSON Typedef (RFC 8927) document.
|
||||
func GenerateTypedef(paths []string) string {
|
||||
types, _ := buildGoTypes(paths)
|
||||
|
||||
typeMap := make(map[string]goType)
|
||||
for _, t := range types {
|
||||
typeMap[t.name] = t
|
||||
}
|
||||
|
||||
// The first type is the root
|
||||
if len(types) == 0 {
|
||||
return "{}\n"
|
||||
}
|
||||
|
||||
root := types[0]
|
||||
defs := make(map[string]any)
|
||||
result := structToJTD(root, typeMap, defs)
|
||||
|
||||
if len(defs) > 0 {
|
||||
result["definitions"] = defs
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(result, "", " ")
|
||||
return string(data) + "\n"
|
||||
}
|
||||
|
||||
// structToJTD converts a goType to a JTD schema object.
|
||||
func structToJTD(t goType, typeMap map[string]goType, defs map[string]any) map[string]any {
|
||||
props := make(map[string]any)
|
||||
optProps := make(map[string]any)
|
||||
|
||||
for _, f := range t.fields {
|
||||
schema := goTypeToJTD(f.goType, f.optional, typeMap, defs)
|
||||
if f.optional {
|
||||
optProps[f.jsonName] = schema
|
||||
} else {
|
||||
props[f.jsonName] = schema
|
||||
}
|
||||
}
|
||||
|
||||
result := make(map[string]any)
|
||||
if len(props) > 0 {
|
||||
result["properties"] = props
|
||||
} else if len(optProps) > 0 {
|
||||
// JTD requires "properties" if "optionalProperties" is present
|
||||
result["properties"] = map[string]any{}
|
||||
}
|
||||
if len(optProps) > 0 {
|
||||
result["optionalProperties"] = optProps
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// goTypeToJTD converts a Go type string to a JTD schema.
|
||||
func goTypeToJTD(goTyp string, nullable bool, typeMap map[string]goType, defs map[string]any) map[string]any {
|
||||
result := goTypeToJTDInner(goTyp, typeMap, defs)
|
||||
if nullable {
|
||||
result["nullable"] = true
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func goTypeToJTDInner(goTyp string, typeMap map[string]goType, defs map[string]any) map[string]any {
|
||||
// Strip pointer
|
||||
goTyp = strings.TrimPrefix(goTyp, "*")
|
||||
|
||||
// Slice
|
||||
if strings.HasPrefix(goTyp, "[]") {
|
||||
elemType := goTyp[2:]
|
||||
return map[string]any{
|
||||
"elements": goTypeToJTDInner(elemType, typeMap, defs),
|
||||
}
|
||||
}
|
||||
|
||||
// Map
|
||||
if strings.HasPrefix(goTyp, "map[string]") {
|
||||
valType := goTyp[11:]
|
||||
return map[string]any{
|
||||
"values": goTypeToJTDInner(valType, typeMap, defs),
|
||||
}
|
||||
}
|
||||
|
||||
// Primitives
|
||||
switch goTyp {
|
||||
case "string":
|
||||
return map[string]any{"type": "string"}
|
||||
case "int64":
|
||||
return map[string]any{"type": "int32"}
|
||||
case "float64":
|
||||
return map[string]any{"type": "float64"}
|
||||
case "bool":
|
||||
return map[string]any{"type": "boolean"}
|
||||
case "any":
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
// Named struct — emit as ref, add to definitions if not already there
|
||||
if t, ok := typeMap[goTyp]; ok {
|
||||
if _, exists := defs[goTyp]; !exists {
|
||||
// Add placeholder to prevent infinite recursion
|
||||
defs[goTyp] = nil
|
||||
defs[goTyp] = structToJTD(t, typeMap, defs)
|
||||
}
|
||||
return map[string]any{"ref": goTyp}
|
||||
}
|
||||
|
||||
// Unknown type
|
||||
return map[string]any{}
|
||||
}
|
||||
162
tools/jsontypes/typedef_test.go
Normal file
162
tools/jsontypes/typedef_test.go
Normal file
@ -0,0 +1,162 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateTypedefFlat(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".name{string}",
|
||||
".age{int}",
|
||||
".active{bool}",
|
||||
}
|
||||
out := GenerateTypedef(paths)
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &doc); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, out)
|
||||
}
|
||||
props, ok := doc["properties"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("expected properties, got %v", doc)
|
||||
}
|
||||
assertJTDType(t, props, "name", "string")
|
||||
assertJTDType(t, props, "age", "int32")
|
||||
assertJTDType(t, props, "active", "boolean")
|
||||
}
|
||||
|
||||
func TestGenerateTypedefNested(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".addr{Address}",
|
||||
".addr.city{string}",
|
||||
".addr.zip{string}",
|
||||
}
|
||||
out := GenerateTypedef(paths)
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &doc); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, out)
|
||||
}
|
||||
// addr should be a ref
|
||||
props := doc["properties"].(map[string]any)
|
||||
addr := props["addr"].(map[string]any)
|
||||
if addr["ref"] != "Address" {
|
||||
t.Errorf("expected ref=Address, got %v", addr)
|
||||
}
|
||||
// definitions should have Address
|
||||
defs := doc["definitions"].(map[string]any)
|
||||
addrDef := defs["Address"].(map[string]any)
|
||||
addrProps := addrDef["properties"].(map[string]any)
|
||||
assertJTDType(t, addrProps, "city", "string")
|
||||
assertJTDType(t, addrProps, "zip", "string")
|
||||
}
|
||||
|
||||
func TestGenerateTypedefArray(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".items[]{Item}",
|
||||
".items[].id{string}",
|
||||
}
|
||||
out := GenerateTypedef(paths)
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &doc); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, out)
|
||||
}
|
||||
props := doc["properties"].(map[string]any)
|
||||
items := props["items"].(map[string]any)
|
||||
elem := items["elements"].(map[string]any)
|
||||
if elem["ref"] != "Item" {
|
||||
t.Errorf("expected elements ref=Item, got %v", elem)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTypedefOptional(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".name{string}",
|
||||
".bio{string?}",
|
||||
}
|
||||
out := GenerateTypedef(paths)
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &doc); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, out)
|
||||
}
|
||||
optProps := doc["optionalProperties"].(map[string]any)
|
||||
bio := optProps["bio"].(map[string]any)
|
||||
if bio["nullable"] != true {
|
||||
t.Errorf("expected nullable=true for bio, got %v", bio)
|
||||
}
|
||||
if bio["type"] != "string" {
|
||||
t.Errorf("expected type=string for bio, got %v", bio["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTypedefMap(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".scores[string]{Score}",
|
||||
".scores[string].value{int}",
|
||||
}
|
||||
out := GenerateTypedef(paths)
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &doc); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, out)
|
||||
}
|
||||
props := doc["properties"].(map[string]any)
|
||||
scores := props["scores"].(map[string]any)
|
||||
vals := scores["values"].(map[string]any)
|
||||
if vals["ref"] != "Score" {
|
||||
t.Errorf("expected values ref=Score, got %v", vals)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTypedefEmpty(t *testing.T) {
|
||||
out := GenerateTypedef(nil)
|
||||
if out != "{}\n" {
|
||||
t.Errorf("expected empty schema, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTypedefEndToEnd(t *testing.T) {
|
||||
jsonStr := `{"name":"Alice","age":30,"tags":["a","b"],"meta":{"key":"val"}}`
|
||||
paths := analyzeAndFormat(t, jsonStr)
|
||||
out := GenerateTypedef(paths)
|
||||
var doc map[string]any
|
||||
if err := json.Unmarshal([]byte(out), &doc); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, out)
|
||||
}
|
||||
if _, ok := doc["properties"]; !ok {
|
||||
t.Errorf("expected properties in output: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func analyzeAndFormat(t *testing.T, jsonStr string) []string {
|
||||
t.Helper()
|
||||
var data any
|
||||
dec := json.NewDecoder(strings.NewReader(jsonStr))
|
||||
dec.UseNumber()
|
||||
if err := dec.Decode(&data); err != nil {
|
||||
t.Fatalf("invalid test JSON: %v", err)
|
||||
}
|
||||
a, err := NewAnalyzer(false, true, false)
|
||||
if err != nil {
|
||||
t.Fatalf("NewAnalyzer: %v", err)
|
||||
}
|
||||
defer a.Close()
|
||||
rawPaths := a.Analyze(".", data)
|
||||
return FormatPaths(rawPaths)
|
||||
}
|
||||
|
||||
func assertJTDType(t *testing.T, props map[string]any, field, expected string) {
|
||||
t.Helper()
|
||||
f, ok := props[field].(map[string]any)
|
||||
if !ok {
|
||||
t.Errorf("field %q not found in properties", field)
|
||||
return
|
||||
}
|
||||
if f["type"] != expected {
|
||||
t.Errorf("field %q: expected type=%q, got %v", field, expected, f["type"])
|
||||
}
|
||||
}
|
||||
56
tools/jsontypes/typescript.go
Normal file
56
tools/jsontypes/typescript.go
Normal file
@ -0,0 +1,56 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// generateTypeScript converts formatted flat paths into TypeScript interface definitions.
|
||||
func GenerateTypeScript(paths []string) string {
|
||||
types, _ := buildGoTypes(paths)
|
||||
if len(types) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var buf strings.Builder
|
||||
for i, t := range types {
|
||||
if i > 0 {
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("export interface %s {\n", t.name))
|
||||
for _, f := range t.fields {
|
||||
tsType := goTypeToTS(f.goType)
|
||||
if f.optional {
|
||||
buf.WriteString(fmt.Sprintf(" %s?: %s | null;\n", f.jsonName, tsType))
|
||||
} else {
|
||||
buf.WriteString(fmt.Sprintf(" %s: %s;\n", f.jsonName, tsType))
|
||||
}
|
||||
}
|
||||
buf.WriteString("}\n")
|
||||
}
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func goTypeToTS(goTyp string) string {
|
||||
goTyp = strings.TrimPrefix(goTyp, "*")
|
||||
|
||||
if strings.HasPrefix(goTyp, "[]") {
|
||||
return goTypeToTS(goTyp[2:]) + "[]"
|
||||
}
|
||||
if strings.HasPrefix(goTyp, "map[string]") {
|
||||
return "Record<string, " + goTypeToTS(goTyp[11:]) + ">"
|
||||
}
|
||||
|
||||
switch goTyp {
|
||||
case "string":
|
||||
return "string"
|
||||
case "int64", "float64":
|
||||
return "number"
|
||||
case "bool":
|
||||
return "boolean"
|
||||
case "any":
|
||||
return "unknown"
|
||||
default:
|
||||
return goTyp
|
||||
}
|
||||
}
|
||||
87
tools/jsontypes/typescript_test.go
Normal file
87
tools/jsontypes/typescript_test.go
Normal file
@ -0,0 +1,87 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateTypeScriptFlat(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".name{string}",
|
||||
".age{int}",
|
||||
".active{bool}",
|
||||
}
|
||||
out := GenerateTypeScript(paths)
|
||||
assertContains(t, out, "export interface Root {")
|
||||
assertContains(t, out, "name: string;")
|
||||
assertContains(t, out, "age: number;")
|
||||
assertContains(t, out, "active: boolean;")
|
||||
}
|
||||
|
||||
func TestGenerateTypeScriptOptional(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".name{string}",
|
||||
".bio{string?}",
|
||||
}
|
||||
out := GenerateTypeScript(paths)
|
||||
assertContains(t, out, "name: string;")
|
||||
assertContains(t, out, "bio?: string | null;")
|
||||
}
|
||||
|
||||
func TestGenerateTypeScriptNested(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".addr{Address}",
|
||||
".addr.city{string}",
|
||||
}
|
||||
out := GenerateTypeScript(paths)
|
||||
assertContains(t, out, "addr: Address;")
|
||||
assertContains(t, out, "export interface Address {")
|
||||
assertContains(t, out, "city: string;")
|
||||
}
|
||||
|
||||
func TestGenerateTypeScriptArray(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".items[]{Item}",
|
||||
".items[].id{string}",
|
||||
}
|
||||
out := GenerateTypeScript(paths)
|
||||
assertContains(t, out, "items: Item[];")
|
||||
}
|
||||
|
||||
func TestGenerateTypeScriptMap(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".scores[string]{Score}",
|
||||
".scores[string].value{int}",
|
||||
}
|
||||
out := GenerateTypeScript(paths)
|
||||
assertContains(t, out, "scores: Record<string, Score>;")
|
||||
}
|
||||
|
||||
func TestGenerateTypeScriptEmpty(t *testing.T) {
|
||||
out := GenerateTypeScript(nil)
|
||||
if out != "" {
|
||||
t.Errorf("expected empty output, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateTypeScriptEndToEnd(t *testing.T) {
|
||||
jsonStr := `{"name":"Alice","age":30,"tags":["a"],"meta":{"key":"val"}}`
|
||||
paths := analyzeAndFormat(t, jsonStr)
|
||||
out := GenerateTypeScript(paths)
|
||||
assertContains(t, out, "export interface")
|
||||
assertContains(t, out, "name: string;")
|
||||
assertContains(t, out, "age: number;")
|
||||
assertContains(t, out, "tags: string[];")
|
||||
}
|
||||
|
||||
func assertContains(t *testing.T, got, want string) {
|
||||
t.Helper()
|
||||
if !strings.Contains(got, want) {
|
||||
t.Errorf("output missing %q\ngot:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
67
tools/jsontypes/zod.go
Normal file
67
tools/jsontypes/zod.go
Normal file
@ -0,0 +1,67 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// generateZod converts formatted flat paths into Zod schema definitions.
|
||||
func GenerateZod(paths []string) string {
|
||||
types, _ := buildGoTypes(paths)
|
||||
if len(types) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Emit in reverse order so referenced schemas are defined first.
|
||||
var buf strings.Builder
|
||||
buf.WriteString("import { z } from \"zod\";\n\n")
|
||||
for i := len(types) - 1; i >= 0; i-- {
|
||||
t := types[i]
|
||||
if i < len(types)-1 {
|
||||
buf.WriteByte('\n')
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf("export const %sSchema = z.object({\n", t.name))
|
||||
for _, f := range t.fields {
|
||||
zodType := goTypeToZod(f.goType)
|
||||
if f.optional {
|
||||
zodType += ".nullable().optional()"
|
||||
}
|
||||
buf.WriteString(fmt.Sprintf(" %s: %s,\n", f.jsonName, zodType))
|
||||
}
|
||||
buf.WriteString("});\n")
|
||||
}
|
||||
|
||||
// Type aliases
|
||||
buf.WriteByte('\n')
|
||||
for _, t := range types {
|
||||
buf.WriteString(fmt.Sprintf("export type %s = z.infer<typeof %sSchema>;\n", t.name, t.name))
|
||||
}
|
||||
|
||||
return buf.String()
|
||||
}
|
||||
|
||||
func goTypeToZod(goTyp string) string {
|
||||
goTyp = strings.TrimPrefix(goTyp, "*")
|
||||
|
||||
if strings.HasPrefix(goTyp, "[]") {
|
||||
return "z.array(" + goTypeToZod(goTyp[2:]) + ")"
|
||||
}
|
||||
if strings.HasPrefix(goTyp, "map[string]") {
|
||||
return "z.record(z.string(), " + goTypeToZod(goTyp[11:]) + ")"
|
||||
}
|
||||
|
||||
switch goTyp {
|
||||
case "string":
|
||||
return "z.string()"
|
||||
case "int64":
|
||||
return "z.number().int()"
|
||||
case "float64":
|
||||
return "z.number()"
|
||||
case "bool":
|
||||
return "z.boolean()"
|
||||
case "any":
|
||||
return "z.unknown()"
|
||||
default:
|
||||
return goTyp + "Schema"
|
||||
}
|
||||
}
|
||||
98
tools/jsontypes/zod_test.go
Normal file
98
tools/jsontypes/zod_test.go
Normal file
@ -0,0 +1,98 @@
|
||||
package jsontypes
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestGenerateZodFlat(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".name{string}",
|
||||
".age{int}",
|
||||
".active{bool}",
|
||||
}
|
||||
out := GenerateZod(paths)
|
||||
assertContainsAll(t, out,
|
||||
`import { z } from "zod";`,
|
||||
"export const RootSchema = z.object({",
|
||||
"name: z.string(),",
|
||||
"age: z.number().int(),",
|
||||
"active: z.boolean(),",
|
||||
"export type Root = z.infer<typeof RootSchema>;",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerateZodOptional(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".name{string}",
|
||||
".bio{string?}",
|
||||
}
|
||||
out := GenerateZod(paths)
|
||||
assertContainsAll(t, out,
|
||||
"name: z.string(),",
|
||||
"bio: z.string().nullable().optional(),",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerateZodNested(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".addr{Address}",
|
||||
".addr.city{string}",
|
||||
}
|
||||
out := GenerateZod(paths)
|
||||
assertContainsAll(t, out,
|
||||
"export const AddressSchema = z.object({",
|
||||
"addr: AddressSchema,",
|
||||
)
|
||||
// AddressSchema should appear before RootSchema
|
||||
addrIdx := strings.Index(out, "AddressSchema = z.object")
|
||||
rootIdx := strings.Index(out, "RootSchema = z.object")
|
||||
if addrIdx < 0 || rootIdx < 0 || addrIdx > rootIdx {
|
||||
t.Errorf("AddressSchema should be defined before RootSchema\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateZodArray(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".items[]{Item}",
|
||||
".items[].id{string}",
|
||||
}
|
||||
out := GenerateZod(paths)
|
||||
assertContainsAll(t, out,
|
||||
"items: z.array(ItemSchema),",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerateZodMap(t *testing.T) {
|
||||
paths := []string{
|
||||
"{Root}",
|
||||
".scores[string]{Score}",
|
||||
".scores[string].value{int}",
|
||||
}
|
||||
out := GenerateZod(paths)
|
||||
assertContainsAll(t, out,
|
||||
"scores: z.record(z.string(), ScoreSchema),",
|
||||
)
|
||||
}
|
||||
|
||||
func TestGenerateZodEmpty(t *testing.T) {
|
||||
out := GenerateZod(nil)
|
||||
if out != "" {
|
||||
t.Errorf("expected empty output, got %q", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateZodEndToEnd(t *testing.T) {
|
||||
jsonStr := `{"name":"Alice","age":30,"tags":["a"],"meta":{"key":"val"}}`
|
||||
paths := analyzeAndFormat(t, jsonStr)
|
||||
out := GenerateZod(paths)
|
||||
assertContainsAll(t, out,
|
||||
"z.object({",
|
||||
"z.string()",
|
||||
"z.number()",
|
||||
)
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user