diff --git a/cmd/forgegen/generator_stub.go b/cmd/forgegen/generator_stub.go new file mode 100644 index 0000000..771aa3b --- /dev/null +++ b/cmd/forgegen/generator_stub.go @@ -0,0 +1,7 @@ +package main + +// Generate writes Go source files for the extracted types and CRUD pairs. +// This is a stub that will be replaced in Task 8 with the full implementation. +func Generate(types map[string]*GoType, pairs []CRUDPair, outDir string) error { + return nil +} diff --git a/cmd/forgegen/main.go b/cmd/forgegen/main.go new file mode 100644 index 0000000..856331d --- /dev/null +++ b/cmd/forgegen/main.go @@ -0,0 +1,30 @@ +package main + +import ( + "flag" + "fmt" + "os" +) + +func main() { + specPath := flag.String("spec", "testdata/swagger.v1.json", "path to swagger.v1.json") + outDir := flag.String("out", "types", "output directory for generated types") + flag.Parse() + + spec, err := LoadSpec(*specPath) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } + + types := ExtractTypes(spec) + pairs := DetectCRUDPairs(spec) + + fmt.Printf("Loaded %d types, %d CRUD pairs\n", len(types), len(pairs)) + fmt.Printf("Output dir: %s\n", *outDir) + + if err := Generate(types, pairs, *outDir); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v\n", err) + os.Exit(1) + } +} diff --git a/cmd/forgegen/parser.go b/cmd/forgegen/parser.go new file mode 100644 index 0000000..3d4874a --- /dev/null +++ b/cmd/forgegen/parser.go @@ -0,0 +1,212 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "sort" + "strings" +) + +// Spec represents a Swagger 2.0 specification document. +type Spec struct { + Swagger string `json:"swagger"` + Info SpecInfo `json:"info"` + Definitions map[string]SchemaDefinition `json:"definitions"` + Paths map[string]map[string]any `json:"paths"` +} + +// SpecInfo holds metadata about the API specification. +type SpecInfo struct { + Title string `json:"title"` + Version string `json:"version"` +} + +// SchemaDefinition represents a single type definition in the swagger spec. +type SchemaDefinition struct { + Description string `json:"description"` + Type string `json:"type"` + Properties map[string]SchemaProperty `json:"properties"` + Required []string `json:"required"` + Enum []any `json:"enum"` + XGoName string `json:"x-go-name"` +} + +// SchemaProperty represents a single property within a schema definition. +type SchemaProperty struct { + Type string `json:"type"` + Format string `json:"format"` + Description string `json:"description"` + Ref string `json:"$ref"` + Items *SchemaProperty `json:"items"` + Enum []any `json:"enum"` + XGoName string `json:"x-go-name"` +} + +// GoType is the intermediate representation for a Go type to be generated. +type GoType struct { + Name string + Description string + Fields []GoField + IsEnum bool + EnumValues []string +} + +// GoField is the intermediate representation for a single struct field. +type GoField struct { + GoName string + GoType string + JSONName string + Comment string + Required bool +} + +// CRUDPair groups a base type with its corresponding Create and Edit option types. +type CRUDPair struct { + Base string + Create string + Edit string +} + +// LoadSpec reads and parses a Swagger 2.0 JSON file from the given path. +func LoadSpec(path string) (*Spec, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("read spec: %w", err) + } + var spec Spec + if err := json.Unmarshal(data, &spec); err != nil { + return nil, fmt.Errorf("parse spec: %w", err) + } + return &spec, nil +} + +// ExtractTypes converts all swagger definitions into Go type intermediate representations. +func ExtractTypes(spec *Spec) map[string]*GoType { + result := make(map[string]*GoType) + for name, def := range spec.Definitions { + gt := &GoType{Name: name, Description: def.Description} + if len(def.Enum) > 0 { + gt.IsEnum = true + for _, v := range def.Enum { + gt.EnumValues = append(gt.EnumValues, fmt.Sprintf("%v", v)) + } + sort.Strings(gt.EnumValues) + result[name] = gt + continue + } + required := make(map[string]bool) + for _, r := range def.Required { + required[r] = true + } + for fieldName, prop := range def.Properties { + goName := prop.XGoName + if goName == "" { + goName = pascalCase(fieldName) + } + gf := GoField{ + GoName: goName, + GoType: resolveGoType(prop), + JSONName: fieldName, + Comment: prop.Description, + Required: required[fieldName], + } + gt.Fields = append(gt.Fields, gf) + } + sort.Slice(gt.Fields, func(i, j int) bool { + return gt.Fields[i].GoName < gt.Fields[j].GoName + }) + result[name] = gt + } + return result +} + +// DetectCRUDPairs finds Create*Option / Edit*Option pairs in the swagger definitions +// and maps them back to the base type name. +func DetectCRUDPairs(spec *Spec) []CRUDPair { + var pairs []CRUDPair + for name := range spec.Definitions { + if !strings.HasPrefix(name, "Create") || !strings.HasSuffix(name, "Option") { + continue + } + inner := strings.TrimPrefix(name, "Create") + inner = strings.TrimSuffix(inner, "Option") + editName := "Edit" + inner + "Option" + pair := CRUDPair{Base: inner, Create: name} + if _, ok := spec.Definitions[editName]; ok { + pair.Edit = editName + } + pairs = append(pairs, pair) + } + sort.Slice(pairs, func(i, j int) bool { + return pairs[i].Base < pairs[j].Base + }) + return pairs +} + +// resolveGoType maps a swagger schema property to a Go type string. +func resolveGoType(prop SchemaProperty) string { + if prop.Ref != "" { + parts := strings.Split(prop.Ref, "/") + return "*" + parts[len(parts)-1] + } + switch prop.Type { + case "string": + switch prop.Format { + case "date-time": + return "time.Time" + case "binary": + return "[]byte" + default: + return "string" + } + case "integer": + switch prop.Format { + case "int64": + return "int64" + case "int32": + return "int32" + default: + return "int" + } + case "number": + switch prop.Format { + case "float": + return "float32" + default: + return "float64" + } + case "boolean": + return "bool" + case "array": + if prop.Items != nil { + return "[]" + resolveGoType(*prop.Items) + } + return "[]any" + case "object": + return "map[string]any" + default: + return "any" + } +} + +// pascalCase converts a snake_case or kebab-case string to PascalCase, +// with common acronyms kept uppercase. +func pascalCase(s string) string { + parts := strings.FieldsFunc(s, func(r rune) bool { + return r == '_' || r == '-' + }) + for i, p := range parts { + if len(p) == 0 { + continue + } + upper := strings.ToUpper(p) + switch upper { + case "ID", "URL", "HTML", "SSH", "HTTP", "HTTPS", "API", "URI", "GPG", "IP", "CSS", "JS": + parts[i] = upper + default: + parts[i] = strings.ToUpper(p[:1]) + p[1:] + } + } + return strings.Join(parts, "") +} diff --git a/cmd/forgegen/parser_test.go b/cmd/forgegen/parser_test.go new file mode 100644 index 0000000..2607268 --- /dev/null +++ b/cmd/forgegen/parser_test.go @@ -0,0 +1,103 @@ +package main + +import ( + "testing" +) + +func TestParser_Good_LoadSpec(t *testing.T) { + spec, err := LoadSpec("../../testdata/swagger.v1.json") + if err != nil { + t.Fatal(err) + } + if spec.Swagger != "2.0" { + t.Errorf("got swagger=%q", spec.Swagger) + } + if len(spec.Definitions) < 200 { + t.Errorf("got %d definitions, expected 200+", len(spec.Definitions)) + } +} + +func TestParser_Good_ExtractTypes(t *testing.T) { + spec, err := LoadSpec("../../testdata/swagger.v1.json") + if err != nil { + t.Fatal(err) + } + + types := ExtractTypes(spec) + if len(types) < 200 { + t.Errorf("got %d types", len(types)) + } + + // Check a known type + repo, ok := types["Repository"] + if !ok { + t.Fatal("Repository type not found") + } + if len(repo.Fields) < 50 { + t.Errorf("Repository has %d fields, expected 50+", len(repo.Fields)) + } +} + +func TestParser_Good_FieldTypes(t *testing.T) { + spec, err := LoadSpec("../../testdata/swagger.v1.json") + if err != nil { + t.Fatal(err) + } + + types := ExtractTypes(spec) + repo := types["Repository"] + + // Check specific field mappings + for _, f := range repo.Fields { + switch f.JSONName { + case "id": + if f.GoType != "int64" { + t.Errorf("id: got %q, want int64", f.GoType) + } + case "name": + if f.GoType != "string" { + t.Errorf("name: got %q, want string", f.GoType) + } + case "private": + if f.GoType != "bool" { + t.Errorf("private: got %q, want bool", f.GoType) + } + case "created_at": + if f.GoType != "time.Time" { + t.Errorf("created_at: got %q, want time.Time", f.GoType) + } + case "owner": + if f.GoType != "*User" { + t.Errorf("owner: got %q, want *User", f.GoType) + } + } + } +} + +func TestParser_Good_DetectCreateEditPairs(t *testing.T) { + spec, err := LoadSpec("../../testdata/swagger.v1.json") + if err != nil { + t.Fatal(err) + } + + pairs := DetectCRUDPairs(spec) + if len(pairs) < 10 { + t.Errorf("got %d pairs, expected 10+", len(pairs)) + } + + found := false + for _, p := range pairs { + if p.Base == "Repo" { + found = true + if p.Create != "CreateRepoOption" { + t.Errorf("repo create=%q", p.Create) + } + if p.Edit != "EditRepoOption" { + t.Errorf("repo edit=%q", p.Edit) + } + } + } + if !found { + t.Fatal("Repo pair not found") + } +}