feat: swagger spec parser for type extraction
Co-Authored-By: Virgil <virgil@lethean.io> Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
286d91383f
commit
c2754cae4e
4 changed files with 352 additions and 0 deletions
7
cmd/forgegen/generator_stub.go
Normal file
7
cmd/forgegen/generator_stub.go
Normal file
|
|
@ -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
|
||||
}
|
||||
30
cmd/forgegen/main.go
Normal file
30
cmd/forgegen/main.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
212
cmd/forgegen/parser.go
Normal file
212
cmd/forgegen/parser.go
Normal file
|
|
@ -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, "")
|
||||
}
|
||||
103
cmd/forgegen/parser_test.go
Normal file
103
cmd/forgegen/parser_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue