diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3ac094f..7d6ce92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,11 +34,11 @@ jobs: run: git diff --exit-code -- go.mod go.sum - name: Setup golangci-lint - uses: golangci/golangci-lint-action@v7 + uses: golangci/golangci-lint-action@v9 with: - version: v2.10 - args: --help - verify: false + version: latest + verify: true + install-only: true - name: Format check run: make fmt diff --git a/.gitignore b/.gitignore index d506a08..b67e46b 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,5 @@ output/ integration-tests/gozero-demo/openapi.yaml integration-tests/gozero-demo/openapi.json +integration-tests/gin-demo/openapi.yaml +/openapi/openapi.yaml diff --git a/.golangci.yml b/.golangci.yml index acd544e..fc38e95 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -131,8 +131,6 @@ linters: msg: use log/slog for logging - pattern: fmt\.Fprint(os\.(Stdout|Stderr),.*)$ msg: use log/slog for logging - # Exclude test files from this check - exclude_godoc_examples: true exclusions: generated: lax @@ -179,7 +177,8 @@ formatters: extra-rules: true goimports: - local-prefixes: github.com/spencercjh/spec-forge + local-prefixes: + - github.com/spencercjh/spec-forge exclusions: generated: lax diff --git a/CLAUDE.md b/CLAUDE.md index 7305064..c976694 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -50,22 +50,28 @@ internal/ ├── executor/ # Shell command execution with timeout ├── extractor/ # OpenAPI spec extraction │ ├── types.go # GenerateOptions, GenerateResult, etc. -│ ├── builtin/ # Built-in extractor registry -│ ├── spring/ # Spring Boot implementation +│ ├── spring/ # Spring Boot specific implementation │ │ ├── detector.go # Project type detection (Maven/Gradle) │ │ ├── patcher.go # springdoc dependency injection │ │ ├── generator.go # Maven/Gradle command execution │ │ ├── maven.go # POM parsing, spring-boot plugin config │ │ └── gradle.go # build.gradle parsing -│ ├── gozero/ # go-zero implementation +│ ├── gozero/ # go-zero framework support │ │ ├── detector.go # go.mod parsing, dependency detection │ │ ├── patcher.go # go-swagger installation check │ │ └── generator.go # goctl command execution -│ └── grpcprotoc/ # gRPC-protoc implementation -│ ├── detector.go # .proto file detection, buf.yaml rejection -│ ├── patcher.go # protoc tools check -│ ├── generator.go # protoc command execution -│ └── grpcprotoc.go # Info struct with ProtoFiles, ServiceProtoFiles +│ ├── grpcprotoc/ # gRPC-protoc implementation +│ │ ├── detector.go # .proto file detection, buf.yaml rejection +│ │ ├── patcher.go # protoc tools check +│ │ ├── generator.go # protoc command execution +│ │ └── grpcprotoc.go # Info struct with ProtoFiles, ServiceProtoFiles +│ └── gin/ # Gin framework support (AST-based) +│ ├── detector.go # go.mod parsing for gin dependency +│ ├── patcher.go # No-op (no patching needed) +│ ├── generator.go # AST-based OpenAPI generation +│ ├── ast_parser.go # Go AST parsing for routes +│ ├── handler_analyzer.go # Handler function analysis +│ └── schema_extractor.go # Go struct to OpenAPI schema ├── validator/ # kin-openapi validation ├── enricher/ # LLM-based description enrichment │ ├── enricher.go # Main enricher interface @@ -84,6 +90,7 @@ internal/ ``` Spring Project → springdoc plugin → openapi.json → Enricher (LLM) → openapi.yaml +Gin Project → AST Parser → OpenAPI Generator → Enricher (LLM) → openapi.yaml ``` ## Critical Constraints @@ -168,7 +175,7 @@ API keys should be provided via environment variables: ## Functional Testing with Example Projects -The `integration-tests/` directory contains example Spring Boot projects for testing: +The `integration-tests/` directory contains example projects for testing: ``` integration-tests/ @@ -177,7 +184,41 @@ integration-tests/ ├── maven-springboot-openapi-demo/ # Maven-based Spring Boot project ├── gradle-springboot-openapi-demo/ # Gradle-based Spring Boot project ├── maven-multi-module-demo/ # Multi-module Maven project -└── gradle-multi-module-demo/ # Multi-module Gradle project +├── gradle-multi-module-demo/ # Multi-module Gradle project +└── gin-demo/ # Gin framework project +``` + +### Gin Framework Development + +The Gin extractor is located in `internal/extractor/gin/`. + +**Architecture:** +- Uses Go AST (go/ast, go/parser) for static analysis +- No runtime execution required (unlike Spring Boot) +- Patcher is a no-op (no dependencies to install) + +**Key Components:** +- `ASTParser` - Parses Go files and extracts routes +- `HandlerAnalyzer` - Analyzes handler functions for params/responses +- `SchemaExtractor` - Converts Go structs to OpenAPI schemas + +**Testing:** +```bash +# Run Gin-specific tests +go test -v ./internal/extractor/gin/... + +# Run Gin e2e test (requires go.mod with gin dependency) +go test -v -tags=e2e ./integration-tests/... -run TestGinDemo +``` + +**Example Usage:** +```bash +# Generate OpenAPI spec from a Gin project +spec-forge generate ./integration-tests/gin-demo + +# Generate with AI enrichment +LLM_API_KEY="your-key" spec-forge generate ./integration-tests/gin-demo \ + --enrich --language zh ``` ### Running E2E Tests diff --git a/README.md b/README.md index 0fc61d1..afa83ec 100644 --- a/README.md +++ b/README.md @@ -93,11 +93,41 @@ LLM_API_KEY="sk-xxx" spec-forge enrich ./openapi.json \ --model deepseek-chat ``` +### Framework-Specific Usage + +#### Gin Framework + +For Gin projects, spec-forge uses static AST analysis (no runtime required): + +```bash +# Basic generation from a Gin project +cd my-gin-project +spec-forge generate . -o ./openapi + +# Generate with AI enrichment +LLM_API_KEY="sk-xxx" spec-forge generate . \ + --enrich \ + --provider custom \ + --model deepseek-chat \ + --language zh + +# Verbose mode to see extraction details +spec-forge generate . -v +``` + +Supported Gin patterns: +- Direct route registration: `r.GET("/users", handler)` +- Route groups: `api := r.Group("/api")` +- Middleware chains: `r.Use(auth).GET("/protected", handler)` +- Parameter binding: `c.Param()`, `c.Query()`, `c.ShouldBindJSON()` +- Response types: extracted from `c.JSON()` calls with type inference + ## Supported Frameworks | Framework | Language | Status | |----------------------------------------------------------------------------------------------------------------------------------------------|----------------|----------------| | [Spring Boot](https://springdoc.org/#plugins) | Java | ✅ Supported | +| [Gin](https://gin-gonic.com/) | Go | ✅ Supported | | [go-zero](https://go-zero.dev/reference/cli-guide/swagger/) | Go | ✅ Supported | | [gRPC (protoc)](https://github.com/sudorandom/protoc-gen-connect-openapi) | Multi-language | ✅ Supported | | [Hertz](https://github.com/hertz-contrib/swagger-generate/tree/main/protoc-gen-http-swagger) | Go | 🚧 Coming soon | diff --git a/cmd/generate.go b/cmd/generate.go index 32bfc07..2c1ba40 100644 --- a/cmd/generate.go +++ b/cmd/generate.go @@ -230,12 +230,17 @@ func runGenerate(cmd *cobra.Command, args []string) error { //nolint:gocyclo // slog.InfoContext(ctx, "Publisher output", "message", pubResult.Message) } } else if outputDir != "" { - // Skip publish: just copy to output directory - if err := copySpecToOutput(genResult.SpecFilePath, outputDir); err != nil { - return errWrap("failed to copy spec to output directory", err) + // Skip publish: just copy to output directory if needed + genDir := filepath.Dir(genResult.SpecFilePath) + if genDir != outputDir { + if err := copySpecToOutput(genResult.SpecFilePath, outputDir); err != nil { + return errWrap("failed to copy spec to output directory", err) + } + finalSpecPath := filepath.Join(outputDir, filepath.Base(genResult.SpecFilePath)) + slog.InfoContext(ctx, "Spec copied to output directory (publish skipped)", "path", finalSpecPath) + } else { + slog.InfoContext(ctx, "Spec already in output directory", "path", genResult.SpecFilePath) } - finalSpecPath := filepath.Join(outputDir, filepath.Base(genResult.SpecFilePath)) - slog.InfoContext(ctx, "Spec copied to output directory (publish skipped)", "path", finalSpecPath) } // Step 8: Output final result diff --git a/docs/plans/2026-03-07-spec-forge-gin-design.md b/docs/plans/2026-03-07-spec-forge-gin-design.md new file mode 100644 index 0000000..f25cf98 --- /dev/null +++ b/docs/plans/2026-03-07-spec-forge-gin-design.md @@ -0,0 +1,343 @@ +# Gin Framework Support Design + +## Overview + +This document describes the design for adding Gin framework support to spec-forge. Gin is a popular Go HTTP web framework. Unlike go-zero which uses `.api` files, Gin routes are defined in Go code. This design uses AST parsing to extract route information and generate OpenAPI specifications. + +## Goals + +- Support static analysis of Gin projects without starting the application +- Extract routes from 4 patterns: direct registration, route groups, middleware chains, and handler references +- Generate accurate OpenAPI 3.0 schemas from Go struct definitions +- Integrate with existing enricher for LLM-enhanced descriptions + +## Non-Goals + +- Multi-module/complex project structures (out of scope for initial implementation) +- Runtime reflection-based extraction +- Dynamic route patterns (e.g., paths constructed from variables) + +## Architecture + +### Package Structure + +``` +internal/extractor/gin/ +├── gin.go # Extractor entry point, implements extractor.Extractor interface +├── info.go # Gin project info structures +├── detector.go # Detect Gin projects (go.mod dependency check) +├── patcher.go # Patch (Gin doesn't modify files, returns empty result) +├── generator.go # Main OpenAPI generation entry point +├── ast_parser.go # AST parser - route extraction +├── handler_analyzer.go # Handler function body analysis (param binding, responses) +├── schema_extractor.go # Generate OpenAPI Schema from Go structs +└── *_test.go # Test files +``` + +### Data Structures + +```go +// info.go +package gin + +type Info struct { + GoVersion string // Go version from go.mod + ModuleName string // Module path + GinVersion string // gin dependency version + HasGin bool // Has gin dependency + MainFiles []string // main.go or files with route registration + HandlerFiles []string // Handler file list + RouterGroups []RouterGroup // Detected router groups +} + +type RouterGroup struct { + BasePath string + Routes []Route +} + +type Route struct { + Method string // GET, POST, PUT, DELETE, PATCH + Path string // /users/:id + FullPath string // /api/v1/users/:id (including group prefix) + HandlerName string // Function name + HandlerFile string // Definition file + Middlewares []string // Middleware names +} + +// handler_analyzer.go +type HandlerInfo struct { + PathParams []ParamInfo // c.Param("id") + QueryParams []ParamInfo // c.Query("page") + HeaderParams []ParamInfo // c.GetHeader("Authorization") + BodyType string // c.ShouldBindJSON type + Responses []ResponseInfo // c.JSON(200, resp) +} + +type ParamInfo struct { + Name string + GoType string + Required bool +} + +type ResponseInfo struct { + StatusCode int + GoType string +} +``` + +## AST Parsing Flow + +``` +go.mod Detection + │ + ▼ +Scan *.go Files + │ + ▼ +┌─────────────────────────────────────┐ +│ Phase 1: Build Type Map │ +│ - Collect all struct definitions │ +│ - Collect all function definitions │ +│ - Build import alias map │ +└─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Phase 2: Route Registration Detect │ +│ - r.GET("/path", handler) │ +│ - r.Group("/api") + sub-routes │ +│ - r.Use(middleware) │ +│ - Track middleware chains │ +└─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Phase 3: Handler Analysis │ +│ - Locate handler function definition│ +│ - Analyze Gin Context calls in body │ +│ - Extract parameter bindings │ +│ - Extract response calls │ +└─────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────┐ +│ Phase 4: Schema Generation │ +│ - Generate OpenAPI Schema from defs │ +│ - Parse json/binding/validate tags │ +│ - Handle nested structs │ +└─────────────────────────────────────┘ + │ + ▼ +Generate OpenAPI 3.0 Document +``` + +## AST Pattern Matching + +### Direct Route Registration + +```go +// Code: r.GET("/users", handler) +// AST Pattern: +ExprStmt{ + CallExpr{ + SelectorExpr{X: Ident("r"), Sel: Ident("GET|POST|PUT|DELETE|PATCH")}, + Args: [BasicLit("/users"), Ident("handler") or FuncLit] + } +} +``` + +### Route Groups + +```go +// Code: +// api := r.Group("/api") +// api.GET("/users", handler) +// AST Pattern: +AssignStmt{ + Lhs: [Ident("api")], + Rhs: [CallExpr{SelectorExpr{X: "r", Sel: "Group"}, Args: ["/api"]}] +} +// Track subsequent calls on the "api" variable +``` + +### Middleware Chains + +```go +// Code: r.Use(authMiddleware).GET("/protected", handler) +// AST Pattern: +CallExpr{ + SelectorExpr{ + X: CallExpr{SelectorExpr{X: "r", Sel: "Use"}, Args: [...]}, + Sel: "GET" + }, + Args: [...] +} +``` + +### Handler Parameter Binding + +```go +// c.ShouldBindJSON(&req) - Request body +// c.Param("id") - Path parameter +// c.Query("page") - Query parameter +// c.GetHeader("Authorization") - Header parameter +// AST Pattern: +CallExpr{ + SelectorExpr{X: Ident("c"), Sel: Ident("ShouldBindJSON|Param|Query|GetHeader")}, + Args: [...] +} +``` + +### Handler Response + +```go +// c.JSON(200, resp) +// c.JSON(http.StatusOK, response) +// AST Pattern: +CallExpr{ + SelectorExpr{X: Ident("c"), Sel: Ident("JSON")}, + Args: [BasicLit(200) or Ident("http.StatusOK"), ...] +} +``` + +## Schema Generation + +### Type Mapping + +| Go Type | OpenAPI Type | OpenAPI Format | +|---------|--------------|----------------| +| string | string | - | +| int, int32 | integer | int32 | +| int64 | integer | int64 | +| uint, uint32 | integer | - | +| float32 | number | float | +| float64 | number | double | +| bool | boolean | - | +| []T | array | items: T | +| map[string]T | object | additionalProperties: T | +| time.Time | string | date-time | +| Named struct | $ref | - | + +### Tag Processing + +| Tag | Processing | +|-----|------------| +| `json:"name"` | Property name | +| `json:"name,omitempty"` | Property name (not in required) | +| `binding:"required"` | Add to required list | +| `validate:"required"` | Add to required list | +| `validate:"min=X"` | Set minimum | +| `validate:"max=X"` | Set maximum | +| `validate:"minLength=X"` | Set minLength | +| `validate:"maxLength=X"` | Set maxLength | +| `validate:"email"` | Set format: email | +| `validate:"url"` | Set format: uri | +| `validate:"uuid"` | Set format: uuid | + +### Example + +```go +// Go struct +type CreateUserRequest struct { + Name string `json:"name" binding:"required" validate:"min=1,max=100"` + Email string `json:"email" binding:"required" validate:"email"` + Age int `json:"age,omitempty" validate:"min=0,max=150"` + Role string `json:"role" validate:"oneof=admin user guest"` +} + +// Generated OpenAPI Schema +{ + "type": "object", + "required": ["name", "email"], + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 100 + }, + "email": { + "type": "string", + "format": "email" + }, + "age": { + "type": "integer", + "minimum": 0, + "maximum": 150 + }, + "role": { + "type": "string", + "enum": ["admin", "user", "guest"] + } + } +} +``` + +## Responsibility Split: Generator vs Enricher + +| Feature | Generator (Static) | Enricher (LLM) | +|---------|-------------------|----------------| +| Path, Method, OperationID | ✅ | - | +| Parameter types and locations | ✅ | - | +| Schema fields, types, required | ✅ | - | +| Operation summary | Function name as placeholder | ✅ Enhanced to natural language | +| Operation description | - | ✅ | +| Parameter description | - | ✅ | +| Schema field description | - | ✅ | + +## Edge Cases + +| Case | Handling | +|------|----------| +| Dynamic paths (variable concatenation) | Skip, log warning | +| Handler is a method (struct method) | Support, record receiver type | +| Anonymous function handler | Support, OperationID = "anonymous-N" | +| Cross-file type references | Use go/packages for full type loading | +| Unused type definitions | Still generate Schema (may be referenced) | +| c.JSON in conditional branches | Record all possible status codes | +| Third-party middleware | Record middleware name, no deep analysis | +| gin.Context wrapped in struct | Track context field access | +| Multiple router instances | Track each router variable separately | +| Route registration in init() | Support, scan all functions | + +## Dependencies + +- `go/ast` - AST parsing +- `go/parser` - Parse Go source files +- `go/token` - Token positions +- `golang.org/x/mod/modfile` - Parse go.mod +- `golang.org/x/tools/go/packages` - Load full type information (for schema extraction) +- `github.com/getkin/kin-openapi/openapi3` - OpenAPI document building + +## Testing Strategy + +1. **Unit Tests**: Each component (detector, parser, analyzer, schema extractor) has isolated tests +2. **Integration Tests**: Use example Gin projects in `integration-tests/gin-springboot-demo/` +3. **Golden Files**: Compare generated OpenAPI specs against expected outputs + +## Implementation Status + +✅ **Completed**: All phases implemented and merged to main + +| Phase | Status | Description | +|-------|--------|-------------| +| Phase 1 | ✅ | Basic detector and extractor skeleton | +| Phase 2 | ✅ | AST parser for route extraction (4 patterns) | +| Phase 3 | ✅ | Handler analyzer (param binding, responses) | +| Phase 4 | ✅ | Schema extractor from Go structs | +| Phase 5 | ✅ | Integration with enricher and CLI | +| Phase 6 | ✅ | Testing and documentation | + +### Implementation Phases (Original) + +1. **Phase 1**: Basic detector and extractor skeleton +2. **Phase 2**: AST parser for route extraction (4 patterns) +3. **Phase 3**: Handler analyzer (param binding, responses) +4. **Phase 4**: Schema extractor from Go structs +5. **Phase 5**: Integration with enricher and CLI +6. **Phase 6**: Testing and documentation + +## References + +- Gin documentation: https://gin-gonic.com/docs/ +- Go AST package: https://pkg.go.dev/go/ast +- OpenAPI 3.0 Specification: https://spec.openapis.org/oas/v3.0.0 diff --git a/docs/plans/2026-03-08-spec-forge-gin-implementation.md b/docs/plans/2026-03-08-spec-forge-gin-implementation.md new file mode 100644 index 0000000..1bb10fd --- /dev/null +++ b/docs/plans/2026-03-08-spec-forge-gin-implementation.md @@ -0,0 +1,2952 @@ +# Gin Framework Support Implementation Plan + +> **Status:** ✅ **COMPLETED** - All tasks implemented, tested, and merged to main + +**Goal:** Implement Gin framework support with Detector, Patcher, and Generator components using AST parsing. + +**Architecture:** Follow Spring Boot pattern with three components: Detector (go.mod + Gin dependency check), Patcher (Gin needs no patching - no-op), Generator (AST parsing to extract routes, handlers, and schemas). + +**Tech Stack:** Go, go/ast, go/parser, go/token, golang.org/x/mod/modfile, kin-openapi/openapi3, log/slog + +**Implementation Date:** March 2026 +**Status:** Production-ready with comprehensive logging and lint-clean code + +--- + +## Task 1: Create gin package structure + +**Files:** +- Create: `internal/extractor/gin/gin.go` (extractor interface implementation) +- Create: `internal/extractor/gin/detector.go` (stub) +- Create: `internal/extractor/gin/detector_test.go` (stub) +- Create: `internal/extractor/gin/patcher.go` (stub) +- Create: `internal/extractor/gin/patcher_test.go` (stub) +- Create: `internal/extractor/gin/generator.go` (stub) +- Create: `internal/extractor/gin/generator_test.go` (stub) +- Create: `internal/extractor/gin/ast_parser.go` (stub) +- Create: `internal/extractor/gin/ast_parser_test.go` (stub) +- Create: `internal/extractor/gin/handler_analyzer.go` (stub) +- Create: `internal/extractor/gin/handler_analyzer_test.go` (stub) +- Create: `internal/extractor/gin/schema_extractor.go` (stub) +- Create: `internal/extractor/gin/schema_extractor_test.go` (stub) + +**Step 1: Create package directory and stub files** + +```bash +mkdir -p internal/extractor/gin +touch internal/extractor/gin/{gin,detector,patcher,generator,ast_parser,handler_analyzer,schema_extractor}_test.go +``` + +**Step 2: Create gin.go - extractor entry point** + +```go +// Package gin provides Gin framework specific extraction functionality. +package gin + +import ( + "context" + "fmt" + + "github.com/spencercjh/spec-forge/internal/extractor" +) + +const FrameworkName = "gin" + +// GinExtractor implements extractor.Extractor for Gin projects. +type GinExtractor struct { + detector *Detector + patcher *Patcher + generator *Generator +} + +// NewGinExtractor creates a new GinExtractor instance. +func NewGinExtractor() *GinExtractor { + return &GinExtractor{ + detector: NewDetector(), + patcher: NewPatcher(), + generator: NewGenerator(), + } +} + +// Detect implements extractor.Extractor.Detect. +func (e *GinExtractor) Detect(projectPath string) (*extractor.ProjectInfo, error) { + return e.detector.Detect(projectPath) +} + +// Patch implements extractor.Extractor.Patch. +func (e *GinExtractor) Patch(ctx context.Context, projectPath string, info *extractor.ProjectInfo, opts *extractor.PatchOptions) (*extractor.PatchResult, error) { + return e.patcher.Patch(ctx, projectPath, info, opts) +} + +// Generate implements extractor.Extractor.Generate. +func (e *GinExtractor) Generate(ctx context.Context, projectPath string, info *extractor.ProjectInfo, opts *extractor.GenerateOptions) (*extractor.GenerateResult, error) { + return e.generator.Generate(ctx, projectPath, info, opts) +} +``` + +**Step 3: Create detector.go stub** + +```go +package gin + +import "github.com/spencercjh/spec-forge/internal/extractor" + +// Detector detects Gin projects. +type Detector struct{} + +// NewDetector creates a new Detector instance. +func NewDetector() *Detector { + return &Detector{} +} + +// Detect analyzes a project and returns info if it's a Gin project. +func (d *Detector) Detect(projectPath string) (*extractor.ProjectInfo, error) { + return nil, nil +} +``` + +**Step 4: Create patcher.go stub** + +```go +package gin + +import ( + "context" + + "github.com/spencercjh/spec-forge/internal/extractor" +) + +// Patcher is a no-op for Gin projects (no patching needed). +type Patcher struct{} + +// NewPatcher creates a new Patcher instance. +func NewPatcher() *Patcher { + return &Patcher{} +} + +// Patch performs no-op patching for Gin projects. +func (p *Patcher) Patch(ctx context.Context, projectPath string, info *extractor.ProjectInfo, opts *extractor.PatchOptions) (*extractor.PatchResult, error) { + return &extractor.PatchResult{}, nil +} +``` + +**Step 5: Create generator.go stub** + +```go +package gin + +import ( + "context" + + "github.com/spencercjh/spec-forge/internal/extractor" +) + +// Generator generates OpenAPI specs from Gin projects using AST parsing. +type Generator struct{} + +// NewGenerator creates a new Generator instance. +func NewGenerator() *Generator { + return &Generator{} +} + +// Generate generates OpenAPI spec from Gin project. +func (g *Generator) Generate(ctx context.Context, projectPath string, info *extractor.ProjectInfo, opts *extractor.GenerateOptions) (*extractor.GenerateResult, error) { + return nil, nil +} +``` + +**Step 6: Create ast_parser.go stub** + +```go +package gin + +// ASTParser parses Go AST to extract Gin routes. +type ASTParser struct { + projectPath string +} + +// NewASTParser creates a new ASTParser instance. +func NewASTParser(projectPath string) *ASTParser { + return &ASTParser{projectPath: projectPath} +} +``` + +**Step 7: Create handler_analyzer.go stub** + +```go +package gin + +// HandlerAnalyzer analyzes Gin handler functions. +type HandlerAnalyzer struct{} + +// NewHandlerAnalyzer creates a new HandlerAnalyzer instance. +func NewHandlerAnalyzer() *HandlerAnalyzer { + return &HandlerAnalyzer{} +} +``` + +**Step 8: Create schema_extractor.go stub** + +```go +package gin + +// SchemaExtractor extracts OpenAPI schemas from Go structs. +type SchemaExtractor struct{} + +// NewSchemaExtractor creates a new SchemaExtractor instance. +func NewSchemaExtractor() *SchemaExtractor { + return &SchemaExtractor{} +} +``` + +**Step 9: Create test stubs** + +```go +// gin_test.go +package gin + +import "testing" + +func TestNewGinExtractor(t *testing.T) { + e := NewGinExtractor() + if e == nil { + t.Error("expected non-nil extractor") + } +} +``` + +```go +// detector_test.go +package gin + +import "testing" + +func TestNewDetector(t *testing.T) { + d := NewDetector() + if d == nil { + t.Error("expected non-nil detector") + } +} +``` + +```go +// patcher_test.go +package gin + +import "testing" + +func TestNewPatcher(t *testing.T) { + p := NewPatcher() + if p == nil { + t.Error("expected non-nil patcher") + } +} +``` + +```go +// generator_test.go +package gin + +import "testing" + +func TestNewGenerator(t *testing.T) { + g := NewGenerator() + if g == nil { + t.Error("expected non-nil generator") + } +} +``` + +```go +// ast_parser_test.go +package gin + +import "testing" + +func TestNewASTParser(t *testing.T) { + p := NewASTParser("/tmp") + if p == nil { + t.Error("expected non-nil parser") + } +} +``` + +```go +// handler_analyzer_test.go +package gin + +import "testing" + +func TestNewHandlerAnalyzer(t *testing.T) { + a := NewHandlerAnalyzer() + if a == nil { + t.Error("expected non-nil analyzer") + } +} +``` + +```go +// schema_extractor_test.go +package gin + +import "testing" + +func TestNewSchemaExtractor(t *testing.T) { + e := NewSchemaExtractor() + if e == nil { + t.Error("expected non-nil extractor") + } +} +``` + +**Step 10: Verify stubs compile** + +Run: +```bash +go build ./internal/extractor/gin/... +``` + +Expected: No errors + +**Step 11: Run stub tests** + +Run: +```bash +go test ./internal/extractor/gin/... -v +``` + +Expected: 7 tests pass + +**Step 12: Commit** + +```bash +git add internal/extractor/gin/ +git commit -s -m "chore(gin): create package structure with stubs" +``` + +--- + +## Task 2: Define Gin types and data structures + +**Files:** +- Create: `internal/extractor/gin/types.go` +- Create: `internal/extractor/gin/types_test.go` + +**Step 1: Write the failing test** + +```go +// internal/extractor/gin/types_test.go +package gin + +import "testing" + +func TestInfo(t *testing.T) { + info := Info{ + GoVersion: "1.21", + ModuleName: "github.com/example/app", + GinVersion: "v1.9.1", + HasGin: true, + } + + if info.GoVersion != "1.21" { + t.Errorf("expected GoVersion '1.21', got %s", info.GoVersion) + } + if info.ModuleName != "github.com/example/app" { + t.Errorf("expected ModuleName 'github.com/example/app', got %s", info.ModuleName) + } +} + +func TestRouterGroup(t *testing.T) { + rg := RouterGroup{ + BasePath: "/api/v1", + Routes: []Route{ + {Method: "GET", Path: "/users"}, + {Method: "POST", Path: "/users"}, + }, + } + + if rg.BasePath != "/api/v1" { + t.Errorf("expected BasePath '/api/v1', got %s", rg.BasePath) + } + if len(rg.Routes) != 2 { + t.Errorf("expected 2 routes, got %d", len(rg.Routes)) + } +} + +func TestRoute(t *testing.T) { + route := Route{ + Method: "GET", + Path: "/users/:id", + FullPath: "/api/v1/users/:id", + HandlerName: "GetUser", + HandlerFile: "user_handler.go", + Middlewares: []string{"Auth"}, + } + + if route.Method != "GET" { + t.Errorf("expected Method 'GET', got %s", route.Method) + } + if route.FullPath != "/api/v1/users/:id" { + t.Errorf("expected FullPath '/api/v1/users/:id', got %s", route.FullPath) + } +} + +func TestHandlerInfo(t *testing.T) { + hi := HandlerInfo{ + PathParams: []ParamInfo{ + {Name: "id", GoType: "string", Required: true}, + }, + QueryParams: []ParamInfo{ + {Name: "page", GoType: "string", Required: false}, + }, + BodyType: "CreateUserRequest", + Responses: []ResponseInfo{ + {StatusCode: 200, GoType: "User"}, + {StatusCode: 404, GoType: "ErrorResponse"}, + }, + } + + if len(hi.PathParams) != 1 { + t.Errorf("expected 1 path param, got %d", len(hi.PathParams)) + } + if len(hi.Responses) != 2 { + t.Errorf("expected 2 responses, got %d", len(hi.Responses)) + } +} + +func TestParamInfo(t *testing.T) { + param := ParamInfo{ + Name: "id", + GoType: "string", + Required: true, + } + + if param.Name != "id" { + t.Errorf("expected Name 'id', got %s", param.Name) + } + if !param.Required { + t.Error("expected Required to be true") + } +} + +func TestResponseInfo(t *testing.T) { + resp := ResponseInfo{ + StatusCode: 200, + GoType: "User", + } + + if resp.StatusCode != 200 { + t.Errorf("expected StatusCode 200, got %d", resp.StatusCode) + } + if resp.GoType != "User" { + t.Errorf("expected GoType 'User', got %s", resp.GoType) + } +} +``` + +Run: +```bash +go test ./internal/extractor/gin/... -run TestInfo -v +``` + +Expected: FAIL - types not defined + +**Step 2: Create types.go** + +```go +// internal/extractor/gin/types.go +package gin + +// Info contains information about a Gin project. +type Info struct { + GoVersion string // Go version from go.mod + ModuleName string // Module path + GinVersion string // gin dependency version + HasGin bool // Has gin dependency + MainFiles []string // main.go or files with route registration + HandlerFiles []string // Handler file list + RouterGroups []RouterGroup // Detected router groups +} + +// RouterGroup represents a Gin router group. +type RouterGroup struct { + BasePath string // Group base path (e.g., "/api/v1") + Routes []Route // Routes in this group +} + +// Route represents a single Gin route. +type Route struct { + Method string // HTTP method: GET, POST, PUT, DELETE, PATCH + Path string // Route path (e.g., "/users/:id") + FullPath string // Full path including group prefix + HandlerName string // Handler function name + HandlerFile string // File containing handler definition + Middlewares []string // Middleware names +} + +// HandlerInfo contains information extracted from a handler function. +type HandlerInfo struct { + PathParams []ParamInfo // Path parameters (c.Param) + QueryParams []ParamInfo // Query parameters (c.Query) + HeaderParams []ParamInfo // Header parameters (c.GetHeader) + BodyType string // Request body type (from ShouldBindJSON) + Responses []ResponseInfo // Response info (from c.JSON calls) +} + +// ParamInfo represents a parameter extracted from handler. +type ParamInfo struct { + Name string // Parameter name + GoType string // Go type name + Required bool // Whether parameter is required +} + +// ResponseInfo represents a response from handler analysis. +type ResponseInfo struct { + StatusCode int // HTTP status code + GoType string // Response Go type +} + +// HandlerRef represents a reference to a handler function. +type HandlerRef struct { + Name string // Function name (empty for anonymous functions) + File string // File path + IsAnonymous bool // Whether it's an anonymous function + ReceiverType string // Receiver type for methods (empty for functions) +} +``` + +**Step 3: Run test to verify it passes** + +Run: +```bash +go test ./internal/extractor/gin/... -run "TestInfo|TestRouter|TestRoute|TestHandler|TestParam|TestResponse" -v +``` + +Expected: PASS + +**Step 4: Commit** + +```bash +git add internal/extractor/gin/types.go internal/extractor/gin/types_test.go +git commit -s -m "feat(gin): add Gin project types and data structures" +``` + +--- + +## Task 3: Implement Detector with go.mod parsing + +**Files:** +- Modify: `internal/extractor/gin/detector.go` +- Modify: `internal/extractor/gin/detector_test.go` + +**Step 1: Add import and extend Detector** + +```go +package gin + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "golang.org/x/mod/modfile" + "github.com/spencercjh/spec-forge/internal/extractor" +) + +const GinModule = "github.com/gin-gonic/gin" +const GoModFile = "go.mod" + +// Detector detects Gin projects. +type Detector struct{} + +// NewDetector creates a new Detector instance. +func NewDetector() *Detector { + return &Detector{} +} +``` + +**Step 2: Write failing test for parseGinVersion** + +```go +// Add to detector_test.go + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetector_parseGinVersion(t *testing.T) { + tests := []struct { + name string + goMod string + expected string + }{ + { + name: "has gin dependency", + goMod: `module test + +go 1.21 + +require github.com/gin-gonic/gin v1.9.1 +`, + expected: "v1.9.1", + }, + { + name: "no gin dependency", + goMod: `module test + +go 1.21 + +require github.com/gin-gonic/gin v1.9.0 +`, + expected: "v1.9.0", + }, + { + name: "no gin - other framework", + goMod: `module test + +go 1.21 + +require github.com/zeromicro/go-zero v1.6.0 +`, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + goModPath := filepath.Join(dir, "go.mod") + os.WriteFile(goModPath, []byte(tt.goMod), 0644) + + d := NewDetector() + version, err := d.parseGinVersion(goModPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if version != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, version) + } + }) + } +} +``` + +Run: +```bash +go test ./internal/extractor/gin/... -run TestDetector_parseGinVersion -v +``` + +Expected: FAIL - method not implemented + +**Step 3: Implement parseGinVersion method** + +```go +// Add to detector.go + +func (d *Detector) parseGinVersion(goModPath string) (string, error) { + content, err := os.ReadFile(goModPath) + if err != nil { + return "", err + } + + modFile, err := modfile.Parse("go.mod", content, nil) + if err != nil { + return "", err + } + + for _, req := range modFile.Require { + if req.Mod.Path == GinModule { + return req.Mod.Version, nil + } + } + + return "", nil +} +``` + +Run: +```bash +go test ./internal/extractor/gin/... -run TestDetector_parseGinVersion -v +``` + +Expected: PASS + +**Step 4: Write failing test for findMainFiles** + +```go +// Add to detector_test.go + +func TestDetector_findMainFiles(t *testing.T) { + dir := t.TempDir() + + // Create main.go + os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n\nfunc main() {}"), 0644) + + // Create router.go with route registration + os.WriteFile(filepath.Join(dir, "router.go"), []byte("package main\n\nfunc setupRouter() {}"), 0644) + + // Create non-main file + os.WriteFile(filepath.Join(dir, "utils.go"), []byte("package main\n\nfunc helper() {}"), 0644) + + // Create vendor directory (should be excluded) + os.MkdirAll(filepath.Join(dir, "vendor", "test"), 0755) + os.WriteFile(filepath.Join(dir, "vendor", "test", "main.go"), []byte("package main"), 0644) + + d := NewDetector() + files, err := d.findMainFiles(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should find main.go and router.go + if len(files) < 1 { + t.Errorf("expected at least 1 main file, got %d", len(files)) + } + + // Check main.go is found + foundMain := false + for _, f := range files { + if filepath.Base(f) == "main.go" { + foundMain = true + break + } + } + if !foundMain { + t.Error("expected main.go to be found") + } +} +``` + +Run: +```bash +go test ./internal/extractor/gin/... -run TestDetector_findMainFiles -v +``` + +Expected: FAIL - method not implemented + +**Step 5: Implement findMainFiles method** + +```go +// Add to detector.go + +func (d *Detector) findMainFiles(projectPath string) ([]string, error) { + var mainFiles []string + + err := filepath.Walk(projectPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip vendor and hidden directories + if info.IsDir() { + if info.Name() == "vendor" || strings.HasPrefix(info.Name(), ".") { + return filepath.SkipDir + } + return nil + } + + // Only process .go files + if !strings.HasSuffix(path, ".go") { + return nil + } + + // Skip vendor + if strings.Contains(path, "/vendor/") { + return nil + } + + // For now, collect all .go files - AST parser will identify route registration files + mainFiles = append(mainFiles, path) + + return nil + }) + + return mainFiles, err +} +``` + +Run: +```bash +go test ./internal/extractor/gin/... -run TestDetector_findMainFiles -v +``` + +Expected: PASS + +**Step 6: Write failing test for full Detect method** + +```go +// Add to detector_test.go + +func TestDetector_Detect(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string + wantErr bool + wantVersion string + wantHasGin bool + }{ + { + name: "valid gin project", + setup: func(t *testing.T) string { + dir := t.TempDir() + goMod := `module test + +go 1.21 + +require github.com/gin-gonic/gin v1.9.1 +` + os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0644) + os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n\nfunc main() {}"), 0644) + return dir + }, + wantErr: false, + wantVersion: "v1.9.1", + wantHasGin: true, + }, + { + name: "missing go.mod", + setup: func(t *testing.T) string { + return t.TempDir() + }, + wantErr: true, + }, + { + name: "no gin dependency", + setup: func(t *testing.T) string { + dir := t.TempDir() + goMod := `module test + +go 1.21 + +require github.com/zeromicro/go-zero v1.6.0 +` + os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0644) + return dir + }, + wantErr: true, + }, + { + name: "no go files", + setup: func(t *testing.T) string { + dir := t.TempDir() + goMod := `module test + +go 1.21 + +require github.com/gin-gonic/gin v1.9.1 +` + os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0644) + return dir + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setup(t) + d := NewDetector() + info, err := d.Detect(dir) + + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if info.Framework != FrameworkName { + t.Errorf("expected framework %q, got %q", FrameworkName, info.Framework) + } + if info.GinVersion != tt.wantVersion { + t.Errorf("expected version %q, got %q", tt.wantVersion, info.GinVersion) + } + }) + } +} +``` + +Run: +```bash +go test ./internal/extractor/gin/... -run TestDetector_Detect -v +``` + +Expected: FAIL - Detect method not fully implemented + +**Step 7: Implement full Detect method** + +```go +// Replace Detect method in detector.go + +// Detect analyzes a project and returns info if it's a Gin project. +func (d *Detector) Detect(projectPath string) (*extractor.ProjectInfo, error) { + absPath, err := filepath.Abs(projectPath) + if err != nil { + return nil, fmt.Errorf("failed to resolve path: %w", err) + } + + // Check for go.mod + goModPath := filepath.Join(absPath, GoModFile) + if _, err := os.Stat(goModPath); err != nil { + return nil, fmt.Errorf("no go.mod found in %s", absPath) + } + + // Parse go.mod for Gin dependency + ginVersion, err := d.parseGinVersion(goModPath) + if err != nil { + return nil, fmt.Errorf("failed to parse go.mod: %w", err) + } + if ginVersion == "" { + return nil, fmt.Errorf("no gin dependency found in go.mod") + } + + // Extract module name + content, _ := os.ReadFile(goModPath) + modFile, _ := modfile.Parse("go.mod", content, nil) + moduleName := "" + if modFile != nil { + moduleName = modFile.Module.Mod.Path + } + + // Find Go files + mainFiles, err := d.findMainFiles(absPath) + if err != nil { + return nil, fmt.Errorf("failed to find main files: %w", err) + } + if len(mainFiles) == 0 { + return nil, fmt.Errorf("no .go files found in %s", absPath) + } + + return &extractor.ProjectInfo{ + Framework: FrameworkName, + BuildTool: "go", + BuildFilePath: goModPath, + GinVersion: ginVersion, + ModuleName: moduleName, + MainFiles: mainFiles, + HasGin: true, + }, nil +} +``` + +**Step 8: Update ProjectInfo in types.go to add Gin fields** + +```go +// Add to internal/extractor/types.go in ProjectInfo struct + +// Gin specific fields (for Framework == "gin") +GinVersion string // Gin framework version +ModuleName string // Go module name +MainFiles []string // Main Go files +HasGin bool // Whether Gin is detected +``` + +**Step 9: Run all Detector tests** + +Run: +```bash +go test ./internal/extractor/gin/... -run TestDetector -v +``` + +Expected: All Detector tests PASS + +**Step 10: Commit** + +```bash +git add internal/extractor/gin/detector.go internal/extractor/gin/detector_test.go internal/extractor/types.go +git commit -s -m "feat(gin): implement Detector with go.mod parsing" +``` + +--- + +## Task 4: Implement Patcher (no-op for Gin) + +**Files:** +- Modify: `internal/extractor/gin/patcher.go` +- Modify: `internal/extractor/gin/patcher_test.go` + +**Step 1: Write failing test** + +```go +// Add to patcher_test.go + +import ( + "context" + "testing" + + "github.com/spencercjh/spec-forge/internal/extractor" +) + +func TestPatcher_Patch(t *testing.T) { + p := NewPatcher() + ctx := context.Background() + info := &extractor.ProjectInfo{} + opts := &extractor.PatchOptions{} + + result, err := p.Patch(ctx, "/tmp", info, opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Patcher should return empty result for Gin + if result == nil { + t.Error("expected non-nil result") + } +} +``` + +Run: +```bash +go test ./internal/extractor/gin/... -run TestPatcher_Patch -v +``` + +Expected: PASS (already implemented as stub) + +**Step 2: Update ProjectInfo to track Patcher result** + +```go +// Update patcher.go to set HasGin flag + +func (p *Patcher) Patch(ctx context.Context, projectPath string, info *extractor.ProjectInfo, opts *extractor.PatchOptions) (*extractor.PatchResult, error) { + // Gin projects don't need patching, just mark as ready + info.HasGin = true + return &extractor.PatchResult{}, nil +} +``` + +**Step 3: Commit** + +```bash +git add internal/extractor/gin/patcher.go internal/extractor/gin/patcher_test.go +git commit -s -m "feat(gin): implement no-op Patcher for Gin projects" +``` + +--- + +## Task 5: Implement AST Parser for route extraction + +**Files:** +- Modify: `internal/extractor/gin/ast_parser.go` +- Modify: `internal/extractor/gin/ast_parser_test.go` + +**Step 1: Add imports and structure** + +```go +package gin + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" +) + +// ASTParser parses Go AST to extract Gin routes. +type ASTParser struct { + fset *token.FileSet + projectPath string + files map[string]*ast.File + routerVars map[string]bool // Track router variable names +} + +// NewASTParser creates a new ASTParser instance. +func NewASTParser(projectPath string) *ASTParser { + return &ASTParser{ + projectPath: projectPath, + fset: token.NewFileSet(), + files: make(map[string]*ast.File), + routerVars: make(map[string]bool), + } +} +``` + +**Step 2: Write failing test for ParseFiles** + +```go +// Add to ast_parser_test.go + +import ( + "os" + "path/filepath" + "testing" +) + +func TestASTParser_ParseFiles(t *testing.T) { + dir := t.TempDir() + + // Create a simple Go file + code := `package main + +import "github.com/gin-gonic/gin" + +func main() { + r := gin.Default() + r.GET("/ping", func(c *gin.Context) { + c.JSON(200, gin.H{"message": "pong"}) + }) +} +` + os.WriteFile(filepath.Join(dir, "main.go"), []byte(code), 0644) + + parser := NewASTParser(dir) + err := parser.ParseFiles() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(parser.files) != 1 { + t.Errorf("expected 1 file, got %d", len(parser.files)) + } +} +``` + +Run: +```bash +go test ./internal/extractor/gin/... -run TestASTParser_ParseFiles -v +``` + +Expected: FAIL - method not implemented + +**Step 3: Implement ParseFiles method** + +```go +// Add to ast_parser.go + +// ParseFiles parses all Go files in the project. +func (p *ASTParser) ParseFiles() error { + return filepath.Walk(p.projectPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip vendor and hidden directories + if info.IsDir() { + if info.Name() == "vendor" || strings.HasPrefix(info.Name(), ".") { + return filepath.SkipDir + } + return nil + } + + // Only process .go files + if !strings.HasSuffix(path, ".go") { + return nil + } + + // Skip test files + if strings.HasSuffix(path, "_test.go") { + return nil + } + + // Parse the file + content, err := os.ReadFile(path) + if err != nil { + return err + } + + file, err := parser.ParseFile(p.fset, path, content, parser.ParseComments) + if err != nil { + // Log but continue with other files + return nil + } + + p.files[path] = file + return nil + }) +} +``` + +Run: +```bash +go test ./internal/extractor/gin/... -run TestASTParser_ParseFiles -v +``` + +Expected: PASS + +**Step 4: Write failing test for ExtractRoutes** + +```go +// Add to ast_parser_test.go + +func TestASTParser_ExtractRoutes(t *testing.T) { + dir := t.TempDir() + + // Create a file with direct route registration + code := `package main + +import "github.com/gin-gonic/gin" + +func main() { + r := gin.Default() + r.GET("/users", getUsers) + r.POST("/users", createUser) +} + +func getUsers(c *gin.Context) {} +func createUser(c *gin.Context) {} +` + os.WriteFile(filepath.Join(dir, "main.go"), []byte(code), 0644) + + parser := NewASTParser(dir) + parser.ParseFiles() + + routes, err := parser.ExtractRoutes() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(routes) != 2 { + t.Errorf("expected 2 routes, got %d", len(routes)) + } + + // Check first route + foundGet := false + foundPost := false + for _, r := range routes { + if r.Method == "GET" && r.Path == "/users" { + foundGet = true + } + if r.Method == "POST" && r.Path == "/users" { + foundPost = true + } + } + if !foundGet { + t.Error("expected GET /users route") + } + if !foundPost { + t.Error("expected POST /users route") + } +} +``` + +Run: +```bash +go test ./internal/extractor/gin/... -run TestASTParser_ExtractRoutes -v +``` + +Expected: FAIL - method not implemented + +**Step 5: Implement ExtractRoutes and helper methods** + +```go +// Add to ast_parser.go + +// ExtractRoutes extracts all Gin routes from parsed files. +func (p *ASTParser) ExtractRoutes() ([]Route, error) { + var routes []Route + + for path, file := range p.files { + fileRoutes := p.extractRoutesFromFile(path, file) + routes = append(routes, fileRoutes...) + } + + return routes, nil +} + +// extractRoutesFromFile extracts routes from a single file. +func (p *ASTParser) extractRoutesFromFile(path string, file *ast.File) []Route { + var routes []Route + + // Inspect the AST + ast.Inspect(file, func(n ast.Node) bool { + switch node := n.(type) { + case *ast.CallExpr: + if route := p.parseRouteCall(path, node); route != nil { + routes = append(routes, *route) + } + } + return true + }) + + return routes +} + +// parseRouteCall parses a route registration call. +func (p *ASTParser) parseRouteCall(file string, call *ast.CallExpr) *Route { + // Pattern: r.GET("/path", handler) + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return nil + } + + method := sel.Sel.Name + if !isHTTPMethod(method) { + return nil + } + + // Get the router variable + _, ok = sel.X.(*ast.Ident) + if !ok { + return nil + } + + // Need at least 2 args: path and handler + if len(call.Args) < 2 { + return nil + } + + // Extract path + path := extractStringLiteral(call.Args[0]) + if path == "" { + return nil + } + + // Extract handler name + handlerName := extractHandlerName(call.Args[1]) + + return &Route{ + Method: method, + Path: path, + FullPath: path, + HandlerName: handlerName, + HandlerFile: file, + } +} + +// isHTTPMethod checks if a string is an HTTP method. +func isHTTPMethod(s string) bool { + switch s { + case "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS": + return true + } + return false +} + +// extractStringLiteral extracts a string from an AST expression. +func extractStringLiteral(expr ast.Expr) string { + if lit, ok := expr.(*ast.BasicLit); ok && lit.Kind == token.STRING { + // Remove quotes + return strings.Trim(lit.Value, `"`) + } + return "" +} + +// extractHandlerName extracts handler name from an expression. +func extractHandlerName(expr ast.Expr) string { + switch e := expr.(type) { + case *ast.Ident: + return e.Name + case *ast.FuncLit: + return "" // Anonymous function + case *ast.SelectorExpr: + if x, ok := e.X.(*ast.Ident); ok { + return x.Name + "." + e.Sel.Name + } + } + return "" +} +``` + +Run: +```bash +go test ./internal/extractor/gin/... -run TestASTParser_ExtractRoutes -v +``` + +Expected: PASS + +**Step 6: Write failing test for Group extraction** + +```go +// Add to ast_parser_test.go + +func TestASTParser_ExtractGroupRoutes(t *testing.T) { + dir := t.TempDir() + + // Create a file with route groups + code := `package main + +import "github.com/gin-gonic/gin" + +func main() { + r := gin.Default() + api := r.Group("/api/v1") + api.GET("/users", getUsers) + api.POST("/users", createUser) +} + +func getUsers(c *gin.Context) {} +func createUser(c *gin.Context) {} +` + os.WriteFile(filepath.Join(dir, "main.go"), []byte(code), 0644) + + parser := NewASTParser(dir) + parser.ParseFiles() + + routes, err := parser.ExtractRoutes() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Check full paths include group prefix + found := false + for _, r := range routes { + if r.Method == "GET" && r.FullPath == "/api/v1/users" { + found = true + break + } + } + if !found { + t.Error("expected GET /api/v1/users route") + } +} +``` + +Run: +```bash +go test ./internal/extractor/gin/... -run TestASTParser_ExtractGroupRoutes -v +``` + +Expected: FAIL - group extraction not implemented + +**Step 7: Implement Group route extraction** + +This requires tracking group variables and their base paths. Add to ast_parser.go: + +```go +// GroupInfo stores information about a router group. +type GroupInfo struct { + BasePath string + VarName string +} + +// extractGroups extracts router group definitions. +func (p *ASTParser) extractGroups(file *ast.File) map[string]*GroupInfo { + groups := make(map[string]*GroupInfo) + + ast.Inspect(file, func(n ast.Node) bool { + switch node := n.(type) { + case *ast.AssignStmt: + // Pattern: api := r.Group("/api") + if len(node.Lhs) == 1 && len(node.Rhs) == 1 { + if group := p.parseGroupAssignment(node.Rhs[0]); group != nil { + if ident, ok := node.Lhs[0].(*ast.Ident); ok { + group.VarName = ident.Name + groups[ident.Name] = group + } + } + } + } + return true + }) + + return groups +} + +// parseGroupAssignment parses a Group assignment. +func (p *ASTParser) parseGroupAssignment(expr ast.Expr) *GroupInfo { + call, ok := expr.(*ast.CallExpr) + if !ok { + return nil + } + + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Group" { + return nil + } + + if len(call.Args) < 1 { + return nil + } + + basePath := extractStringLiteral(call.Args[0]) + if basePath == "" { + return nil + } + + return &GroupInfo{BasePath: basePath} +} +``` + +Then update `extractRoutesFromFile` to use groups. + +**Step 8: Commit** + +```bash +git add internal/extractor/gin/ast_parser.go internal/extractor/gin/ast_parser_test.go +git commit -s -m "feat(gin): implement AST parser for route extraction" +``` + +--- + +## Task 6: Implement Handler Analyzer + +**Files:** +- Modify: `internal/extractor/gin/handler_analyzer.go` +- Modify: `internal/extractor/gin/handler_analyzer_test.go` + +**Step 1: Add imports and structure** + +```go +package gin + +import ( + "go/ast" + "go/token" +) + +// HandlerAnalyzer analyzes Gin handler functions. +type HandlerAnalyzer struct { + fset *token.FileSet + files map[string]*ast.File + typeCache map[string]*ast.TypeSpec +} + +// NewHandlerAnalyzer creates a new HandlerAnalyzer instance. +func NewHandlerAnalyzer(fset *token.FileSet, files map[string]*ast.File) *HandlerAnalyzer { + return &HandlerAnalyzer{ + fset: fset, + files: files, + typeCache: make(map[string]*ast.TypeSpec), + } +} +``` + +**Step 2: Write failing test for AnalyzeHandler** + +```go +// Add to handler_analyzer_test.go + +import ( + "go/parser" + "go/token" + "testing" +) + +func TestHandlerAnalyzer_AnalyzeHandler(t *testing.T) { + // Create a simple handler function + src := `package main + +import "github.com/gin-gonic/gin" + +type User struct { + ID int + Name string +} + +func getUser(c *gin.Context) { + id := c.Param("id") + c.JSON(200, User{ID: 1, Name: "test"}) +} +` + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "test.go", src, 0) + if err != nil { + t.Fatalf("failed to parse: %v", err) + } + + files := map[string]*ast.File{"test.go": file} + analyzer := NewHandlerAnalyzer(fset, files) + + // Find the getUser function + var handlerDecl *ast.FuncDecl + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == "getUser" { + handlerDecl = fn + break + } + } + + if handlerDecl == nil { + t.Fatal("getUser function not found") + } + + info, err := analyzer.AnalyzeHandler(handlerDecl) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Check path params + if len(info.PathParams) != 1 { + t.Errorf("expected 1 path param, got %d", len(info.PathParams)) + } + if len(info.PathParams) > 0 && info.PathParams[0].Name != "id" { + t.Errorf("expected param 'id', got %s", info.PathParams[0].Name) + } + + // Check responses + if len(info.Responses) != 1 { + t.Errorf("expected 1 response, got %d", len(info.Responses)) + } +} +``` + +Run: +```bash +go test ./internal/extractor/gin/... -run TestHandlerAnalyzer_AnalyzeHandler -v +``` + +Expected: FAIL - method not implemented + +**Step 3: Implement AnalyzeHandler method** + +```go +// Add to handler_analyzer.go + +// AnalyzeHandler analyzes a handler function and extracts information. +func (a *HandlerAnalyzer) AnalyzeHandler(fn *ast.FuncDecl) (*HandlerInfo, error) { + info := &HandlerInfo{ + PathParams: []ParamInfo{}, + QueryParams: []ParamInfo{}, + HeaderParams: []ParamInfo{}, + Responses: []ResponseInfo{}, + } + + // Inspect the function body + if fn.Body == nil { + return info, nil + } + + ast.Inspect(fn.Body, func(n ast.Node) bool { + switch node := n.(type) { + case *ast.CallExpr: + a.parseHandlerCall(node, info) + } + return true + }) + + return info, nil +} + +// parseHandlerCall parses a call expression in a handler. +func (a *HandlerAnalyzer) parseHandlerCall(call *ast.CallExpr, info *HandlerInfo) { + // Check for c.Param, c.Query, c.GetHeader, c.ShouldBindJSON, c.JSON + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return + } + + // Check if receiver is a context variable + _, ok = sel.X.(*ast.Ident) + if !ok { + return + } + + method := sel.Sel.Name + + switch method { + case "Param": + if len(call.Args) >= 1 { + if name := extractStringLiteral(call.Args[0]); name != "" { + info.PathParams = append(info.PathParams, ParamInfo{ + Name: name, + GoType: "string", + Required: true, + }) + } + } + case "Query": + if len(call.Args) >= 1 { + if name := extractStringLiteral(call.Args[0]); name != "" { + info.QueryParams = append(info.QueryParams, ParamInfo{ + Name: name, + GoType: "string", + Required: false, + }) + } + } + case "GetHeader": + if len(call.Args) >= 1 { + if name := extractStringLiteral(call.Args[0]); name != "" { + info.HeaderParams = append(info.HeaderParams, ParamInfo{ + Name: name, + GoType: "string", + Required: false, + }) + } + } + case "ShouldBindJSON", "BindJSON", "ShouldBind": + if len(call.Args) >= 1 { + if typeName := extractTypeFromArg(call.Args[0]); typeName != "" { + info.BodyType = typeName + } + } + case "JSON": + if len(call.Args) >= 2 { + statusCode := extractStatusCode(call.Args[0]) + goType := extractTypeFromResponse(call.Args[1]) + info.Responses = append(info.Responses, ResponseInfo{ + StatusCode: statusCode, + GoType: goType, + }) + } + } +} +``` + +**Step 4: Add helper functions** + +```go +// extractTypeFromArg extracts type name from a binding argument. +func extractTypeFromArg(expr ast.Expr) string { + // Pattern: &variable or &Struct{} + unary, ok := expr.(*ast.UnaryExpr) + if !ok || unary.Op != token.AND { + return "" + } + + // Check for composite literal: &Type{} + if comp, ok := unary.X.(*ast.CompositeLit); ok { + if sel, ok := comp.Type.(*ast.Ident); ok { + return sel.Name + } + if sel, ok := comp.Type.(*ast.SelectorExpr); ok { + if x, ok := sel.X.(*ast.Ident); ok { + return x.Name + "." + sel.Sel.Name + } + } + } + + // Check for variable: &variable + if ident, ok := unary.X.(*ast.Ident); ok { + return ident.Name // Return variable name, type would need type checking + } + + return "" +} + +// extractStatusCode extracts HTTP status code from expression. +func extractStatusCode(expr ast.Expr) int { + // Integer literal + if lit, ok := expr.(*ast.BasicLit); ok { + if lit.Kind == token.INT { + var code int + // Try to parse + if _, err := fmt.Sscanf(lit.Value, "%d", &code); err == nil { + return code + } + } + } + + // http.StatusOK reference + if sel, ok := expr.(*ast.SelectorExpr); ok { + if x, ok := sel.X.(*ast.Ident); ok && x.Name == "http" { + return statusCodeFromName(sel.Sel.Name) + } + } + + return 200 // Default +} + +// statusCodeFromName converts http.StatusXxx to code. +func statusCodeFromName(name string) int { + switch name { + case "StatusOK": + return 200 + case "StatusCreated": + return 201 + case "StatusBadRequest": + return 400 + case "StatusUnauthorized": + return 401 + case "StatusForbidden": + return 403 + case "StatusNotFound": + return 404 + case "StatusInternalServerError": + return 500 + } + return 200 +} + +// extractTypeFromResponse extracts type from response argument. +func extractTypeFromResponse(expr ast.Expr) string { + switch e := expr.(type) { + case *ast.Ident: + return e.Name + case *ast.CompositeLit: + if ident, ok := e.Type.(*ast.Ident); ok { + return ident.Name + } + if sel, ok := e.Type.(*ast.SelectorExpr); ok { + if x, ok := sel.X.(*ast.Ident); ok { + return x.Name + "." + sel.Sel.Name + } + } + case *ast.CallExpr: + // gin.H or similar + if sel, ok := e.Fun.(*ast.SelectorExpr); ok { + if sel.Sel.Name == "H" { + return "map[string]any" + } + } + } + return "" +} +``` + +Add import for "fmt". + +Run: +```bash +go test ./internal/extractor/gin/... -run TestHandlerAnalyzer_AnalyzeHandler -v +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/extractor/gin/handler_analyzer.go internal/extractor/gin/handler_analyzer_test.go +git commit -s -m "feat(gin): implement Handler analyzer for param and response extraction" +``` + +--- + +## Task 7: Implement Schema Extractor + +**Files:** +- Modify: `internal/extractor/gin/schema_extractor.go` +- Modify: `internal/extractor/gin/schema_extractor_test.go` + +**Step 1: Add imports and structure** + +```go +package gin + +import ( + "fmt" + "go/ast" + "strings" + + "github.com/getkin/kin-openapi/openapi3" +) + +// SchemaExtractor extracts OpenAPI schemas from Go structs. +type SchemaExtractor struct { + files map[string]*ast.File + typeCache map[string]*openapi3.SchemaRef +} + +// NewSchemaExtractor creates a new SchemaExtractor instance. +func NewSchemaExtractor(files map[string]*ast.File) *SchemaExtractor { + return &SchemaExtractor{ + files: files, + typeCache: make(map[string]*openapi3.SchemaRef), + } +} +``` + +**Step 2: Write failing test for ExtractSchema** + +```go +// Add to schema_extractor_test.go + +import ( + "go/parser" + "go/token" + "testing" + + "github.com/getkin/kin-openapi/openapi3" +) + +func TestSchemaExtractor_ExtractSchema(t *testing.T) { + src := `package main + +type User struct { + ID int ` + "`" + `json:"id" binding:"required"` + "`" + ` + Name string ` + "`" + `json:"name"` + "`" + ` + Email string ` + "`" + `json:"email" validate:"email"` + "`" + ` + Age int ` + "`" + `json:"age,omitempty" validate:"min=0,max=150"` + "`" + ` +} +` + fset := token.NewFileSet() + file, _ := parser.ParseFile(fset, "test.go", src, 0) + files := map[string]*ast.File{"test.go": file} + + extractor := NewSchemaExtractor(files) + schema, err := extractor.ExtractSchema("User") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if schema == nil { + t.Fatal("expected non-nil schema") + } + + // Check required fields + required := schema.Value.Required + if len(required) != 1 || required[0] != "id" { + t.Errorf("expected required=['id'], got %v", required) + } + + // Check properties + props := schema.Value.Properties + if len(props) != 4 { + t.Errorf("expected 4 properties, got %d", len(props)) + } + + // Check email format + if emailProp := props["email"]; emailProp != nil { + if emailProp.Value.Format != "email" { + t.Errorf("expected email format, got %s", emailProp.Value.Format) + } + } +} +``` + +Run: +```bash +go test ./internal/extractor/gin/... -run TestSchemaExtractor_ExtractSchema -v +``` + +Expected: FAIL - method not implemented + +**Step 3: Implement ExtractSchema method** + +```go +// Add to schema_extractor.go + +// ExtractSchema extracts an OpenAPI schema from a Go type. +func (e *SchemaExtractor) ExtractSchema(typeName string) (*openapi3.SchemaRef, error) { + // Check cache + if cached, ok := e.typeCache[typeName]; ok { + return cached, nil + } + + // Find the type definition + typeSpec := e.findTypeSpec(typeName) + if typeSpec == nil { + return nil, fmt.Errorf("type %s not found", typeName) + } + + // Extract schema from struct + schema, err := e.extractStructSchema(typeSpec) + if err != nil { + return nil, err + } + + ref := &openapi3.SchemaRef{Value: schema} + e.typeCache[typeName] = ref + return ref, nil +} + +// findTypeSpec finds a type definition by name. +func (e *SchemaExtractor) findTypeSpec(name string) *ast.TypeSpec { + for _, file := range e.files { + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.TYPE { + continue + } + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if ok && typeSpec.Name.Name == name { + return typeSpec + } + } + } + } + return nil +} + +// extractStructSchema extracts schema from a struct type. +func (e *SchemaExtractor) extractStructSchema(typeSpec *ast.TypeSpec) (*openapi3.Schema, error) { + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + return nil, fmt.Errorf("type %s is not a struct", typeSpec.Name.Name) + } + + schema := &openapi3.Schema{ + Type: "object", + Properties: make(openapi3.Schemas), + } + + for _, field := range structType.Fields.List { + if len(field.Names) == 0 { + continue // Embedded field - skip for now + } + + fieldName := field.Names[0].Name + fieldSchema := e.fieldToSchema(field.Type) + + // Parse struct tags + if field.Tag != nil { + tag := strings.Trim(field.Tag.Value, "`") + e.applyTags(fieldSchema, tag, fieldName, schema) + } + + schema.Properties[fieldName] = &openapi3.SchemaRef{Value: fieldSchema} + } + + return schema, nil +} +``` + +**Step 4: Implement type mapping and tag processing** + +```go +// fieldToSchema converts a Go type to OpenAPI schema. +func (e *SchemaExtractor) fieldToSchema(expr ast.Expr) *openapi3.Schema { + switch t := expr.(type) { + case *ast.Ident: + return goTypeToSchema(t.Name) + case *ast.StarExpr: + // Pointer - unwrap and process underlying type + return e.fieldToSchema(t.X) + case *ast.ArrayType: + itemSchema := e.fieldToSchema(t.Elt) + return &openapi3.Schema{ + Type: "array", + Items: &openapi3.SchemaRef{Value: itemSchema}, + } + case *ast.MapType: + valueSchema := e.fieldToSchema(t.Value) + return &openapi3.Schema{ + Type: "object", + AdditionalProperties: openapi3.AdditionalProperties{Schema: &openapi3.SchemaRef{Value: valueSchema}}, + } + case *ast.SelectorExpr: + // Package qualified type (e.g., time.Time) + if x, ok := t.X.(*ast.Ident); ok { + fullName := x.Name + "." + t.Sel.Name + return goTypeToSchema(fullName) + } + } + + return &openapi3.Schema{Type: "object"} +} + +// goTypeToSchema converts a Go type name to OpenAPI schema. +func goTypeToSchema(goType string) *openapi3.Schema { + switch goType { + case "string": + return &openapi3.Schema{Type: "string"} + case "int", "int32": + return &openapi3.Schema{Type: "integer", Format: "int32"} + case "int64": + return &openapi3.Schema{Type: "integer", Format: "int64"} + case "uint", "uint32": + return &openapi3.Schema{Type: "integer"} + case "float32": + return &openapi3.Schema{Type: "number", Format: "float"} + case "float64": + return &openapi3.Schema{Type: "number", Format: "double"} + case "bool": + return &openapi3.Schema{Type: "boolean"} + case "time.Time": + return &openapi3.Schema{Type: "string", Format: "date-time"} + default: + return &openapi3.Schema{Type: "object"} + } +} + +// applyTags processes struct tags and updates schema. +func (e *SchemaExtractor) applyTags(schema *openapi3.Schema, tag, fieldName string, parentSchema *openapi3.Schema) { + // Parse json tag + if jsonTag := extractTagValue(tag, "json"); jsonTag != "" { + parts := strings.Split(jsonTag, ",") + if parts[0] != "" { + // Rename property + oldKey := "" + for k := range parentSchema.Properties { + if _, ok := parentSchema.Properties[k]; ok && k == fieldName { + oldKey = k + break + } + } + if oldKey != "" && parts[0] != fieldName { + parentSchema.Properties[parts[0]] = parentSchema.Properties[oldKey] + delete(parentSchema.Properties, oldKey) + } + } + // Check for omitempty + for _, part := range parts[1:] { + if part == "omitempty" { + // Not required + return + } + } + } + + // Parse binding tag + if bindingTag := extractTagValue(tag, "binding"); bindingTag != "" { + if bindingTag == "required" { + parentSchema.Required = append(parentSchema.Required, fieldName) + } + } + + // Parse validate tag + if validateTag := extractTagValue(tag, "validate"); validateTag != "" { + e.applyValidation(schema, validateTag, fieldName, parentSchema) + } +} + +// extractTagValue extracts a specific tag value. +func extractTagValue(tag, key string) string { + prefix := key + ":\"" + start := strings.Index(tag, prefix) + if start == -1 { + return "" + } + start += len(prefix) + end := strings.Index(tag[start:], "\"") + if end == -1 { + return "" + } + return tag[start : start+end] +} + +// applyValidation applies validation rules to schema. +func (e *SchemaExtractor) applyValidation(schema *openapi3.Schema, validateTag, fieldName string, parentSchema *openapi3.Schema) { + rules := strings.Split(validateTag, ",") + for _, rule := range rules { + if rule == "required" { + parentSchema.Required = append(parentSchema.Required, fieldName) + continue + } + + // Parse min/max + if strings.HasPrefix(rule, "min=") { + var val float64 + fmt.Sscanf(rule, "min=%f", &val) + schema.Min = &val + } + if strings.HasPrefix(rule, "max=") { + var val float64 + fmt.Sscanf(rule, "max=%f", &val) + schema.Max = &val + } + + // Parse minLength/maxLength + if strings.HasPrefix(rule, "minLength=") { + var val uint64 + fmt.Sscanf(rule, "minLength=%d", &val) + schema.MinLength = val + } + if strings.HasPrefix(rule, "maxLength=") { + var val uint64 + fmt.Sscanf(rule, "maxLength=%d", &val) + schema.MaxLength = &val + } + + // Parse format validators + switch rule { + case "email": + schema.Format = "email" + case "url": + schema.Format = "uri" + case "uuid": + schema.Format = "uuid" + } + + // Parse oneof enum + if strings.HasPrefix(rule, "oneof=") { + values := strings.Split(rule[6:], " ") + for _, v := range values { + schema.Enum = append(schema.Enum, v) + } + } + } +} +``` + +Add import for "fmt" and "go/token". + +Run: +```bash +go test ./internal/extractor/gin/... -run TestSchemaExtractor_ExtractSchema -v +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/extractor/gin/schema_extractor.go internal/extractor/gin/schema_extractor_test.go +git commit -s -m "feat(gin): implement Schema extractor for Go struct to OpenAPI conversion" +``` + +--- + +## Task 8: Implement Generator + +**Files:** +- Modify: `internal/extractor/gin/generator.go` +- Modify: `internal/extractor/gin/generator_test.go` + +**Step 1: Add imports and structure** + +```go +package gin + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/spencercjh/spec-forge/internal/extractor" + "gopkg.in/yaml.v3" +) + +// Generator generates OpenAPI specs from Gin projects using AST parsing. +type Generator struct{} + +// NewGenerator creates a new Generator instance. +func NewGenerator() *Generator { + return &Generator{} +} +``` + +**Step 2: Write failing test for Generate** + +```go +// Add to generator_test.go + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/spencercjh/spec-forge/internal/extractor" +) + +func TestGenerator_Generate(t *testing.T) { + // Create a simple Gin project + dir := t.TempDir() + + goMod := `module test + +go 1.21 + +require github.com/gin-gonic/gin v1.9.1 +` + os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0644) + + mainGo := `package main + +import "github.com/gin-gonic/gin" + +type User struct { + ID int ` + "`" + `json:"id"` + "`" + ` + Name string ` + "`" + `json:"name"` + "`" + ` +} + +func main() { + r := gin.Default() + r.GET("/users/:id", getUser) +} + +func getUser(c *gin.Context) { + id := c.Param("id") + c.JSON(200, User{ID: 1, Name: "test"}) +} +` + os.WriteFile(filepath.Join(dir, "main.go"), []byte(mainGo), 0644) + + g := NewGenerator() + ctx := context.Background() + info := &extractor.ProjectInfo{ + Framework: "gin", + GinVersion: "v1.9.1", + MainFiles: []string{filepath.Join(dir, "main.go")}, + } + opts := &extractor.GenerateOptions{ + OutputDir: dir, + OutputFile: "openapi", + Format: "yaml", + } + + result, err := g.Generate(ctx, dir, info, opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result == nil { + t.Fatal("expected non-nil result") + } + + // Check output file exists + if _, err := os.Stat(result.OutputPath); err != nil { + t.Errorf("output file not created: %s", result.OutputPath) + } +} +``` + +Run: +```bash +go test ./internal/extractor/gin/... -run TestGenerator_Generate -v +``` + +Expected: FAIL - method not implemented + +**Step 3: Implement Generate method** + +```go +// Generate generates OpenAPI spec from Gin project. +func (g *Generator) Generate(ctx context.Context, projectPath string, info *extractor.ProjectInfo, opts *extractor.GenerateOptions) (*extractor.GenerateResult, error) { + // Step 1: Parse AST files + parser := NewASTParser(projectPath) + if err := parser.ParseFiles(); err != nil { + return nil, fmt.Errorf("failed to parse files: %w", err) + } + + // Step 2: Extract routes + routes, err := parser.ExtractRoutes() + if err != nil { + return nil, fmt.Errorf("failed to extract routes: %w", err) + } + + // Step 3: Analyze handlers + analyzer := NewHandlerAnalyzer(parser.fset, parser.files) + handlerInfos := make(map[string]*HandlerInfo) + for _, route := range routes { + // Find handler function + handlerDecl := g.findHandlerDecl(route.HandlerName, parser.files) + if handlerDecl != nil { + handlerInfo, err := analyzer.AnalyzeHandler(handlerDecl) + if err != nil { + continue // Log but continue + } + handlerInfos[route.HandlerName] = handlerInfo + } + } + + // Step 4: Extract schemas + extractor := NewSchemaExtractor(parser.files) + schemas := make(openapi3.Schemas) + for _, handlerInfo := range handlerInfos { + if handlerInfo.BodyType != "" { + if schema, err := extractor.ExtractSchema(handlerInfo.BodyType); err == nil { + schemas[handlerInfo.BodyType] = schema + } + } + for _, resp := range handlerInfo.Responses { + if resp.GoType != "" && resp.GoType != "map[string]any" { + if schema, err := extractor.ExtractSchema(resp.GoType); err == nil { + schemas[resp.GoType] = schema + } + } + } + } + + // Step 5: Build OpenAPI document + doc := g.buildOpenAPIDoc(info, routes, handlerInfos, schemas) + + // Step 6: Write output + outputPath, err := g.writeOutput(doc, opts) + if err != nil { + return nil, fmt.Errorf("failed to write output: %w", err) + } + + return &extractor.GenerateResult{ + OutputPath: outputPath, + }, nil +} + +// findHandlerDecl finds a handler function declaration by name. +func (g *Generator) findHandlerDecl(name string, files map[string]*ast.File) *ast.FuncDecl { + for _, file := range files { + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == name { + return fn + } + } + } + return nil +} +``` + +**Step 4: Implement buildOpenAPIDoc and writeOutput** + +```go +// buildOpenAPIDoc builds the OpenAPI document. +func (g *Generator) buildOpenAPIDoc(info *extractor.ProjectInfo, routes []Route, handlerInfos map[string]*HandlerInfo, schemas openapi3.Schemas) *openapi3.T { + doc := &openapi3.T{ + OpenAPI: "3.0.0", + Info: &openapi3.Info{ + Title: "Gin API", + Version: "1.0.0", + }, + Paths: make(openapi3.Paths), + Components: &openapi3.Components{Schemas: schemas}, + } + + if info.ModuleName != "" { + doc.Info.Title = info.ModuleName + } + + // Build paths + for _, route := range routes { + pathItem := doc.Paths[route.FullPath] + if pathItem == nil { + pathItem = &openapi3.PathItem{} + doc.Paths[route.FullPath] = pathItem + } + + operation := g.buildOperation(route, handlerInfos[route.HandlerName]) + setOperationForMethod(pathItem, route.Method, operation) + } + + return doc +} + +// buildOperation builds an OpenAPI operation from a route. +func (g *Generator) buildOperation(route Route, handlerInfo *HandlerInfo) *openapi3.Operation { + operation := &openapi3.Operation{ + OperationID: route.HandlerName, + Summary: route.HandlerName, + } + + if handlerInfo == nil { + return operation + } + + // Add parameters + operation.Parameters = make(openapi3.Parameters, 0) + + // Path parameters + for _, param := range handlerInfo.PathParams { + operation.Parameters = append(operation.Parameters, &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: param.Name, + In: "path", + Required: param.Required, + Schema: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: "string"}}, + }, + }) + } + + // Query parameters + for _, param := range handlerInfo.QueryParams { + operation.Parameters = append(operation.Parameters, &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: param.Name, + In: "query", + Required: param.Required, + Schema: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: "string"}}, + }, + }) + } + + // Request body + if handlerInfo.BodyType != "" { + operation.RequestBody = &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Content: openapi3.Content{ + "application/json": { + Schema: &openapi3.SchemaRef{ + Ref: "#/components/schemas/" + handlerInfo.BodyType, + }, + }, + }, + }, + } + } + + // Responses + operation.Responses = make(openapi3.Responses) + for _, resp := range handlerInfo.Responses { + response := &openapi3.Response{ + Description: &[]string{"Response"}[0], + Content: openapi3.Content{}, + } + + if resp.GoType != "" { + if resp.GoType == "map[string]any" || resp.GoType == "" { + response.Content["application/json"] = &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: "object"}}, + } + } else { + response.Content["application/json"] = &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: "#/components/schemas/" + resp.GoType, + }, + } + } + } + + statusCode := fmt.Sprintf("%d", resp.StatusCode) + operation.Responses[statusCode] = &openapi3.ResponseRef{Value: response} + } + + // Default response if none specified + if len(handlerInfo.Responses) == 0 { + desc := "Success" + operation.Responses["200"] = &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: &desc, + }, + } + } + + return operation +} + +// setOperationForMethod sets the operation for the given HTTP method. +func setOperationForMethod(pathItem *openapi3.PathItem, method string, operation *openapi3.Operation) { + switch method { + case "GET": + pathItem.Get = operation + case "POST": + pathItem.Post = operation + case "PUT": + pathItem.Put = operation + case "DELETE": + pathItem.Delete = operation + case "PATCH": + pathItem.Patch = operation + case "HEAD": + pathItem.Head = operation + case "OPTIONS": + pathItem.Options = operation + } +} + +// writeOutput writes the OpenAPI document to file. +func (g *Generator) writeOutput(doc *openapi3.T, opts *extractor.GenerateOptions) (string, error) { + outputDir := opts.OutputDir + if outputDir == "" { + outputDir = "." + } + + outputFile := opts.OutputFile + if outputFile == "" { + outputFile = "openapi" + } + + format := opts.Format + if format == "" { + format = "yaml" + } + + var data []byte + var err error + var ext string + + switch format { + case "json": + data, err = doc.MarshalJSON() + ext = ".json" + default: // yaml + data, err = yaml.Marshal(doc) + ext = ".yaml" + } + + if err != nil { + return "", err + } + + outputPath := filepath.Join(outputDir, outputFile+ext) + if err := os.WriteFile(outputPath, data, 0644); err != nil { + return "", err + } + + return outputPath, nil +} +``` + +Add import for "go/ast". + +Run: +```bash +go test ./internal/extractor/gin/... -run TestGenerator_Generate -v +``` + +Expected: PASS + +**Step 5: Commit** + +```bash +git add internal/extractor/gin/generator.go internal/extractor/gin/generator_test.go +git commit -s -m "feat(gin): implement Generator with OpenAPI document building" +``` + +--- + +## Task 9: Register Gin Extractor in factory + +**Files:** +- Modify: `internal/extractor/factory.go` +- Modify: `internal/extractor/factory_test.go` + +**Step 1: Update factory to include Gin** + +```go +// Add to internal/extractor/factory.go + +import "github.com/spencercjh/spec-forge/internal/extractor/gin" + +// In CreateExtractor function, add: +func CreateExtractor(framework string) (Extractor, error) { + switch framework { + case "spring": + return spring.NewSpringExtractor(), nil + case "gozero": + return gozero.NewGoZeroExtractor(), nil + case "gin": + return gin.NewGinExtractor(), nil + default: + return nil, fmt.Errorf("unsupported framework: %s", framework) + } +} +``` + +**Step 2: Add test for Gin extractor creation** + +```go +// Add to factory_test.go + +func TestCreateExtractor_Gin(t *testing.T) { + extractor, err := CreateExtractor("gin") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if extractor == nil { + t.Error("expected non-nil extractor") + } +} +``` + +Run: +```bash +go test ./internal/extractor/... -run TestCreateExtractor_Gin -v +``` + +Expected: PASS + +**Step 3: Commit** + +```bash +git add internal/extractor/factory.go internal/extractor/factory_test.go +git commit -s -m "feat(extractor): register Gin extractor in factory" +``` + +--- + +## Task 10: Update CLI for automatic Gin detection + +**Files:** +- Modify: `cmd/generate.go` + +**Step 1: Update detectFramework function** + +```go +// Add to cmd/generate.go + +func detectFramework(projectPath string) (string, error) { + // Try Gin first + ginDetector := gin.NewDetector() + if _, err := ginDetector.Detect(projectPath); err == nil { + return "gin", nil + } + + // Then go-zero + goZeroDetector := gozero.NewDetector() + if _, err := goZeroDetector.Detect(projectPath); err == nil { + return "gozero", nil + } + + // Then Spring Boot + springDetector := spring.NewDetector() + if _, err := springDetector.Detect(projectPath); err == nil { + return "spring", nil + } + + return "", fmt.Errorf("could not detect framework for project: %s", projectPath) +} +``` + +Add import for gin detector. + +**Step 2: Run tests** + +```bash +go build ./cmd/... +go test ./cmd/... -v +``` + +Expected: Tests pass + +**Step 3: Commit** + +```bash +git add cmd/generate.go +git commit -s -m "feat(cmd): add automatic Gin framework detection" +``` + +--- + +## Task 11: Create integration test example + +**Files:** +- Create: `integration-tests/gin-demo/` directory with sample project +- Create: `integration-tests/gin_demo_test.go` + +**Step 1: Create example Gin project** + +```bash +mkdir -p integration-tests/gin-demo +``` + +**Step 2: Create go.mod** + +```go +// integration-tests/gin-demo/go.mod +module gin-demo + +go 1.21 + +require github.com/gin-gonic/gin v1.9.1 +``` + +**Step 3: Create main.go** + +```go +// integration-tests/gin-demo/main.go +package main + +import ( + "net/http" + + "github.com/gin-gonic/gin" +) + +type User struct { + ID int `json:"id" binding:"required"` + Name string `json:"name" binding:"required"` + Email string `json:"email" binding:"required,email"` + Age int `json:"age,omitempty"` +} + +type CreateUserRequest struct { + Name string `json:"name" binding:"required"` + Email string `json:"email" binding:"required,email"` + Age int `json:"age,omitempty"` +} + +func main() { + r := gin.Default() + + // Direct routes + r.GET("/users", listUsers) + r.POST("/users", createUser) + + // Route group + api := r.Group("/api/v1") + api.GET("/users/:id", getUser) + api.PUT("/users/:id", updateUser) + api.DELETE("/users/:id", deleteUser) + + r.Run() +} + +func listUsers(c *gin.Context) { + page := c.Query("page") + size := c.DefaultQuery("size", "10") + _ = page + _ = size + c.JSON(http.StatusOK, []User{}) +} + +func createUser(c *gin.Context) { + var req CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusCreated, User{ID: 1, Name: req.Name}) +} + +func getUser(c *gin.Context) { + id := c.Param("id") + _ = id + c.JSON(http.StatusOK, User{ID: 1, Name: "Test"}) +} + +func updateUser(c *gin.Context) { + id := c.Param("id") + _ = id + var req CreateUserRequest + c.ShouldBindJSON(&req) + c.JSON(http.StatusOK, User{ID: 1}) +} + +func deleteUser(c *gin.Context) { + id := c.Param("id") + _ = id + c.Status(http.StatusNoContent) +} +``` + +**Step 4: Create integration test** + +```go +// integration-tests/gin_demo_test.go +//go:build e2e + +package integration + +import ( + "os" + "path/filepath" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + "github.com/spencercjh/spec-forge/internal/extractor" + "github.com/spencercjh/spec-forge/internal/extractor/gin" +) + +func TestGinDemo(t *testing.T) { + projectPath := "./gin-demo" + + // Detect project + detector := gin.NewDetector() + info, err := detector.Detect(projectPath) + if err != nil { + t.Fatalf("failed to detect project: %v", err) + } + + if info.Framework != "gin" { + t.Errorf("expected framework 'gin', got %s", info.Framework) + } + + // Generate OpenAPI spec + generator := gin.NewGenerator() + ctx := t.Context() + opts := &extractor.GenerateOptions{ + OutputDir: t.TempDir(), + OutputFile: "openapi", + Format: "yaml", + } + + result, err := generator.Generate(ctx, projectPath, info, opts) + if err != nil { + t.Fatalf("failed to generate spec: %v", err) + } + + // Verify output file exists + if _, err := os.Stat(result.OutputPath); err != nil { + t.Fatalf("output file not created: %s", result.OutputPath) + } + + // Load and validate the spec + loader := openapi3.NewLoader() + spec, err := loader.LoadFromFile(result.OutputPath) + if err != nil { + t.Fatalf("failed to load spec: %v", err) + } + + // Validate spec + if err := spec.Validate(loader.Context); err != nil { + t.Errorf("spec validation failed: %v", err) + } + + // Check paths + if len(spec.Paths) == 0 { + t.Error("expected at least one path") + } + + // Check schemas + if spec.Components == nil || len(spec.Components.Schemas) == 0 { + t.Error("expected at least one schema") + } +} +``` + +**Step 5: Run integration test** + +```bash +go test -v -tags=e2e ./integration-tests/... -run TestGinDemo +``` + +Expected: PASS (if dependencies are available) + +**Step 6: Commit** + +```bash +git add integration-tests/gin-demo/ integration-tests/gin_demo_test.go +git commit -s -m "test(integration): add Gin demo project and e2e test" +``` + +--- + +## Task 12: Update documentation + +**Files:** +- Modify: `README.md` +- Modify: `AGENTS.md` + +**Step 1: Update README.md with Gin support** + +Add to README.md in the Framework Support section: + +```markdown +### Gin Framework + +For Gin projects, spec-forge uses AST parsing to extract routes from Go source code: + +```bash +spec-forge generate ./my-gin-project +``` + +Features: +- Automatic detection of Gin projects (via go.mod) +- Route extraction from Go source files +- Support for route groups +- Parameter extraction (path, query, header, body) +- Schema generation from Go structs +- Support for json/binding/validate tags +``` + +**Step 2: Update AGENTS.md with Gin development notes** + +Add to AGENTS.md: + +```markdown +### Gin Framework Development + +The Gin extractor is located in `internal/extractor/gin/`. + +**Architecture:** +- Uses Go AST (go/ast, go/parser) for static analysis +- No runtime execution required (unlike Spring Boot) +- Patcher is a no-op (no dependencies to install) + +**Key Components:** +- `ASTParser` - Parses Go files and extracts routes +- `HandlerAnalyzer` - Analyzes handler functions for params/responses +- `SchemaExtractor` - Converts Go structs to OpenAPI schemas + +**Testing:** +- Example project: `integration-tests/gin-demo/` +- Run e2e test: `go test -v -tags=e2e ./integration-tests/... -run TestGinDemo` +``` + +**Step 3: Commit** + +```bash +git add README.md AGENTS.md +git commit -s -m "docs: update README and AGENTS with Gin support documentation" +``` + +--- + +## Summary + +This implementation plan adds complete Gin framework support to spec-forge: + +1. **Package Structure** - Created all necessary files with stubs +2. **Types** - Defined data structures for Gin projects +3. **Detector** - Detects Gin projects via go.mod parsing +4. **Patcher** - No-op implementation (Gin doesn't need patching) +5. **AST Parser** - Extracts routes from Go source code +6. **Handler Analyzer** - Analyzes handler functions +7. **Schema Extractor** - Converts Go structs to OpenAPI schemas +8. **Generator** - Assembles complete OpenAPI documents +9. **Factory Integration** - Registered Gin extractor +10. **CLI Integration** - Added automatic detection +11. **Integration Tests** - Created demo project and e2e test +12. **Documentation** - Updated README and AGENTS + +The implementation follows the same patterns as existing Spring Boot and go-zero extractors for consistency. diff --git a/integration-tests/gin-demo/go.mod b/integration-tests/gin-demo/go.mod new file mode 100644 index 0000000..4d9821a --- /dev/null +++ b/integration-tests/gin-demo/go.mod @@ -0,0 +1,5 @@ +module gin-demo + +go 1.21 + +require github.com/gin-gonic/gin v1.9.1 diff --git a/integration-tests/gin-demo/go.sum b/integration-tests/gin-demo/go.sum new file mode 100644 index 0000000..1e2f9a7 --- /dev/null +++ b/integration-tests/gin-demo/go.sum @@ -0,0 +1 @@ +github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU= diff --git a/integration-tests/gin-demo/main.go b/integration-tests/gin-demo/main.go new file mode 100644 index 0000000..220844c --- /dev/null +++ b/integration-tests/gin-demo/main.go @@ -0,0 +1,286 @@ +package main + +import ( + "net/http" + "strconv" + + "github.com/gin-gonic/gin" +) + +// User represents a user in the system +type User struct { + ID int64 `json:"id" binding:"required"` + Username string `json:"username" binding:"required"` + Email string `json:"email" binding:"required,email"` + FullName string `json:"fullName" binding:"required"` + Age int `json:"age"` +} + +// ApiResponse is a generic API response wrapper (using interface{} for compatibility) +type ApiResponse struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data"` +} + +// PageResult is a pagination result wrapper +type PageResult struct { + Content []User `json:"content"` + PageNumber int `json:"pageNumber"` + PageSize int `json:"pageSize"` + Total int64 `json:"total"` + TotalPages int `json:"totalPages"` +} + +// FileUploadResult represents file upload result +type FileUploadResult struct { + Filename string `json:"filename"` + Size int64 `json:"size"` + ContentType string `json:"contentType"` + Message string `json:"message"` +} + +// CreateUserRequest represents a request to create a user +type CreateUserRequest struct { + Username string `json:"username" binding:"required"` + Email string `json:"email" binding:"required,email"` + FullName string `json:"fullName" binding:"required"` + Age int `json:"age"` +} + +// ListUsersRequest represents query params for listing users +type ListUsersRequest struct { + Page int `form:"page,default=0"` + Size int `form:"size,default=10"` + Username string `form:"username"` +} + +// UpdateProfileRequest represents form data for updating profile +type UpdateProfileRequest struct { + FullName string `form:"fullName"` + Email string `form:"email"` + Age int `form:"age"` +} + +// In-memory user store +var userStore = map[int64]User{ + 1: {ID: 1, Username: "john_doe", Email: "john@example.com", FullName: "John Doe", Age: 30}, + 2: {ID: 2, Username: "jane_smith", Email: "jane@example.com", FullName: "Jane Smith", Age: 25}, + 3: {ID: 3, Username: "bob_wilson", Email: "bob@example.com", FullName: "Bob Wilson", Age: 35}, +} + +func main() { + r := gin.Default() + + // API v1 routes + api := r.Group("/api/v1") + { + // User routes + api.GET("/users/:id", getUserByID) + api.GET("/users", listUsers) + api.POST("/users", createUser) + api.POST("/users/upload", uploadFile) + api.POST("/users/:id/profile", updateProfile) + } + + r.Run() +} + +// getUserByID gets a user by ID +func getUserByID(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, ApiResponse{ + Code: 400, + Message: "Invalid user ID", + }) + return + } + + user, exists := userStore[id] + if !exists { + c.JSON(http.StatusNotFound, ApiResponse{ + Code: 404, + Message: "User not found", + }) + return + } + + c.JSON(http.StatusOK, ApiResponse{ + Code: 200, + Message: "Success", + Data: user, + }) +} + +// listUsers lists users with pagination +func listUsers(c *gin.Context) { + var req ListUsersRequest + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(http.StatusBadRequest, ApiResponse{ + Code: 400, + Message: "Invalid query parameters", + }) + return + } + + // Get all users + allUsers := make([]User, 0, len(userStore)) + for _, user := range userStore { + // Filter by username if provided + if req.Username != "" && user.Username != req.Username { + continue + } + allUsers = append(allUsers, user) + } + + // Pagination + total := int64(len(allUsers)) + start := req.Page * req.Size + end := start + req.Size + if start > len(allUsers) { + start = len(allUsers) + } + if end > len(allUsers) { + end = len(allUsers) + } + + pageContent := allUsers[start:end] + totalPages := 0 + if req.Size > 0 { + totalPages = (len(allUsers) + req.Size - 1) / req.Size + } + + result := PageResult{ + Content: pageContent, + PageNumber: req.Page, + PageSize: req.Size, + Total: total, + TotalPages: totalPages, + } + + c.JSON(http.StatusOK, ApiResponse{ + Code: 200, + Message: "Success", + Data: result, + }) +} + +// createUser creates a new user +func createUser(c *gin.Context) { + var req CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, ApiResponse{ + Code: 400, + Message: "Invalid request body: " + err.Error(), + }) + return + } + + // Generate new ID + var newID int64 = 1 + for id := range userStore { + if id >= newID { + newID = id + 1 + } + } + + user := User{ + ID: newID, + Username: req.Username, + Email: req.Email, + FullName: req.FullName, + Age: req.Age, + } + userStore[newID] = user + + c.JSON(http.StatusCreated, ApiResponse{ + Code: 201, + Message: "User created successfully", + Data: user, + }) +} + +// uploadFile handles file upload +func uploadFile(c *gin.Context) { + // Get the file + file, err := c.FormFile("file") + if err != nil { + c.JSON(http.StatusBadRequest, ApiResponse{ + Code: 400, + Message: "No file uploaded: " + err.Error(), + }) + return + } + + // Get optional userId + userID := c.PostForm("userId") + message := "File uploaded successfully" + if userID != "" { + message = "File uploaded successfully for user: " + userID + } + + result := FileUploadResult{ + Filename: file.Filename, + Size: file.Size, + ContentType: file.Header.Get("Content-Type"), + Message: message, + } + + c.JSON(http.StatusOK, ApiResponse{ + Code: 200, + Message: "Success", + Data: result, + }) +} + +// updateProfile updates user profile using form data +func updateProfile(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, ApiResponse{ + Code: 400, + Message: "Invalid user ID", + }) + return + } + + user, exists := userStore[id] + if !exists { + c.JSON(http.StatusNotFound, ApiResponse{ + Code: 404, + Message: "User not found", + }) + return + } + + var req UpdateProfileRequest + if err := c.ShouldBind(&req); err != nil { + c.JSON(http.StatusBadRequest, ApiResponse{ + Code: 400, + Message: "Invalid form data: " + err.Error(), + }) + return + } + + // Update fields if provided + if req.FullName != "" { + user.FullName = req.FullName + } + if req.Email != "" { + user.Email = req.Email + } + if req.Age > 0 { + user.Age = req.Age + } + + userStore[id] = user + + c.JSON(http.StatusOK, ApiResponse{ + Code: 200, + Message: "Profile updated successfully", + Data: user, + }) +} diff --git a/integration-tests/gin_demo_test.go b/integration-tests/gin_demo_test.go new file mode 100644 index 0000000..a9d8d65 --- /dev/null +++ b/integration-tests/gin_demo_test.go @@ -0,0 +1,394 @@ +//go:build e2e + +package e2e_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + + "github.com/spencercjh/spec-forge/internal/extractor" + "github.com/spencercjh/spec-forge/internal/extractor/gin" + "github.com/spencercjh/spec-forge/internal/validator" +) + +func TestGinDemo(t *testing.T) { + projectPath := "./gin-demo" + + // Check if project exists + if _, err := os.Stat(projectPath); os.IsNotExist(err) { + t.Skip("Gin demo project not found") + } + + // Check if go.mod exists + goModPath := filepath.Join(projectPath, "go.mod") + if _, err := os.Stat(goModPath); os.IsNotExist(err) { + t.Skip("go.mod not found, skipping test") + } + + ctx := context.Background() + + // Step 1: Detect project + detector := gin.NewDetector() + info, err := detector.Detect(projectPath) + if err != nil { + t.Fatalf("failed to detect project: %v", err) + } + + if info.Framework != "gin" { + t.Errorf("expected framework 'gin', got %s", info.Framework) + } + + if info.BuildTool != "gomodules" { + t.Errorf("expected build tool 'gomodules', got %s", info.BuildTool) + } + + // Check FrameworkData + ginInfo, ok := info.FrameworkData.(*gin.Info) + if !ok { + t.Fatal("expected FrameworkData to be *gin.Info") + } + + if !ginInfo.HasGin { + t.Error("expected HasGin to be true") + } + + if ginInfo.GinVersion == "" { + t.Error("expected GinVersion to be set") + } + + t.Logf("Detected Gin project: module=%s, version=%s", ginInfo.ModuleName, ginInfo.GinVersion) + + // Step 2: Generate OpenAPI spec (YAML) + gen := gin.NewGenerator() + result, err := gen.Generate(ctx, projectPath, info, &extractor.GenerateOptions{ + OutputDir: t.TempDir(), + OutputFile: "openapi", + Format: "yaml", + }) + if err != nil { + t.Fatalf("failed to generate spec: %v", err) + } + + if result.SpecFilePath == "" { + t.Fatal("expected spec file path to be set") + } + + // Verify output file exists + if _, err := os.Stat(result.SpecFilePath); err != nil { + t.Fatalf("output file not created: %s", result.SpecFilePath) + } + + t.Logf("Generated spec: %s", result.SpecFilePath) + + // Step 3: Validate generated spec + v := validator.NewValidator() + validateResult, err := v.Validate(ctx, result.SpecFilePath) + if err != nil { + t.Fatalf("failed to validate spec: %v", err) + } + + if !validateResult.Valid { + t.Errorf("generated spec is invalid: %v", validateResult.Errors) + } + + // Step 4: Load and verify spec content + loader := openapi3.NewLoader() + spec, err := loader.LoadFromFile(result.SpecFilePath) + if err != nil { + t.Fatalf("failed to load spec: %v", err) + } + + // Step 5: Verify paths + if spec.Paths.Len() == 0 { + t.Fatal("expected at least one path") + } + + expectedPaths := map[string][]string{ + "/api/v1/users": {"GET", "POST"}, + "/api/v1/users/{id}": {"GET"}, + "/api/v1/users/{id}/profile": {"POST"}, + "/api/v1/users/upload": {"POST"}, + } + + for expectedPath, methods := range expectedPaths { + pathItem := spec.Paths.Find(expectedPath) + if pathItem == nil { + t.Errorf("expected path %s not found", expectedPath) + continue + } + + for _, method := range methods { + var operation *openapi3.Operation + switch method { + case "GET": + operation = pathItem.Get + case "POST": + operation = pathItem.Post + case "PUT": + operation = pathItem.Put + case "DELETE": + operation = pathItem.Delete + case "PATCH": + operation = pathItem.Patch + } + + if operation == nil { + t.Errorf("expected %s operation for path %s", method, expectedPath) + } + } + } + + // Step 6: Verify schemas + if spec.Components == nil || spec.Components.Schemas == nil { + t.Fatal("expected Components.Schemas to be defined") + } + + // The extractor now supports extracting types wrapped in ApiResponse.Data + // by tracking variable assignments and resolving composite literal fields + // e.g., c.JSON(200, ApiResponse{Data: user}) extracts User as the response type + expectedSchemas := []string{ + "CreateUserRequest", + "ListUsersRequest", + "UpdateProfileRequest", + "ApiResponse", + "User", + "PageResult", + "FileUploadResult", + } + + for _, schemaName := range expectedSchemas { + if spec.Components.Schemas[schemaName] == nil { + t.Errorf("expected schema %s to be defined", schemaName) + } + } + + // Log available schemas for debugging + t.Logf("Available schemas: %v", getSchemaNames(spec.Components.Schemas)) + + // Step 7: Verify specific schema properties + apiResponseSchema := spec.Components.Schemas["ApiResponse"] + if apiResponseSchema != nil { + apiProps := apiResponseSchema.Value.Properties + expectedProps := []string{"code", "message", "data"} + for _, prop := range expectedProps { + if _, exists := apiProps[prop]; !exists { + t.Errorf("expected ApiResponse schema to have property %s", prop) + } + } + } + + createUserSchema := spec.Components.Schemas["CreateUserRequest"] + if createUserSchema != nil { + props := createUserSchema.Value.Properties + expectedProps := []string{"username", "email", "fullName", "age"} + for _, prop := range expectedProps { + if _, exists := props[prop]; !exists { + t.Errorf("expected CreateUserRequest schema to have property %s", prop) + } + } + } + + // Step 8: Verify request body types + createUserPath := spec.Paths.Find("/api/v1/users") + if createUserPath != nil && createUserPath.Post != nil { + if createUserPath.Post.RequestBody != nil && + createUserPath.Post.RequestBody.Value != nil { + content := createUserPath.Post.RequestBody.Value.Content + if jsonContent, exists := content["application/json"]; exists { + if jsonContent.Schema != nil && + jsonContent.Schema.Ref != "#/components/schemas/CreateUserRequest" { + t.Errorf("expected CreateUser request body to reference CreateUserRequest, got %s", + jsonContent.Schema.Ref) + } + } + } + } + + // Step 9: Verify path parameters + getUserPath := spec.Paths.Find("/api/v1/users/{id}") + if getUserPath != nil && getUserPath.Get != nil { + params := getUserPath.Get.Parameters + foundPathParam := false + for _, param := range params { + if param.Value.In == "path" && param.Value.Name == "id" { + foundPathParam = true + if !param.Value.Required { + t.Error("expected path parameter 'id' to be required") + } + } + } + if !foundPathParam { + t.Error("expected path parameter 'id' for GET /api/v1/users/{id}") + } + } + + t.Log("All validations passed!") +} + +// getSchemaNames returns a slice of schema names for debugging +func getSchemaNames(schemas openapi3.Schemas) []string { + names := make([]string, 0, len(schemas)) + for name := range schemas { + names = append(names, name) + } + return names +} + +func TestGinDemo_JSONFormat(t *testing.T) { + projectPath := "./gin-demo" + + // Check if project exists + if _, err := os.Stat(projectPath); os.IsNotExist(err) { + t.Skip("Gin demo project not found") + } + + ctx := context.Background() + + // Detect project + detector := gin.NewDetector() + info, err := detector.Detect(projectPath) + if err != nil { + t.Fatalf("failed to detect project: %v", err) + } + + // Generate OpenAPI spec (JSON) + gen := gin.NewGenerator() + result, err := gen.Generate(ctx, projectPath, info, &extractor.GenerateOptions{ + OutputDir: t.TempDir(), + OutputFile: "openapi", + Format: "json", + }) + if err != nil { + t.Fatalf("failed to generate spec: %v", err) + } + + // Verify JSON content + specData, err := os.ReadFile(result.SpecFilePath) + if err != nil { + t.Fatalf("failed to read spec file: %v", err) + } + + var spec map[string]any + if err := json.Unmarshal(specData, &spec); err != nil { + t.Fatalf("failed to parse spec JSON: %v", err) + } + + // Verify basic structure + if spec["openapi"] == nil { + t.Error("expected openapi field in spec") + } + + if spec["paths"] == nil { + t.Error("expected paths field in spec") + } + + paths, ok := spec["paths"].(map[string]any) + if !ok { + t.Fatal("expected paths to be an object") + } + + // Verify expected paths exist + expectedPaths := []string{ + "/api/v1/users", + "/api/v1/users/{id}", + "/api/v1/users/{id}/profile", + "/api/v1/users/upload", + } + + for _, path := range expectedPaths { + if _, exists := paths[path]; !exists { + t.Errorf("expected path %s not found in JSON spec", path) + } + } + + // Verify components/schemas exist + components, ok := spec["components"].(map[string]any) + if !ok { + t.Fatal("expected components field in spec") + } + + schemas, ok := components["schemas"].(map[string]any) + if !ok { + t.Fatal("expected components.schemas to be an object") + } + + expectedSchemas := []string{ + "CreateUserRequest", + "ListUsersRequest", + "UpdateProfileRequest", + "ApiResponse", + } + + for _, schema := range expectedSchemas { + if _, exists := schemas[schema]; !exists { + t.Errorf("expected schema %s not found in JSON spec", schema) + } + } + + t.Log("JSON format test passed!") +} + +func TestGinDemo_AutoDetection(t *testing.T) { + projectPath := "./gin-demo" + + // Check if project exists + if _, err := os.Stat(projectPath); os.IsNotExist(err) { + t.Skip("Gin demo project not found") + } + + // Test using Extractor interface (like CLI does) + ext := gin.NewExtractor() + + if ext.Name() != "gin" { + t.Errorf("expected extractor name 'gin', got %s", ext.Name()) + } + + // Detect + info, err := ext.Detect(projectPath) + if err != nil { + t.Fatalf("failed to detect: %v", err) + } + + if info.Framework != "gin" { + t.Errorf("expected framework 'gin', got %s", info.Framework) + } + + // Patch (no-op for Gin) + patchOpts := &extractor.PatchOptions{} + patchResult, err := ext.Patch(projectPath, patchOpts) + if err != nil { + t.Fatalf("failed to patch: %v", err) + } + + if patchResult == nil { + t.Error("expected patch result") + } + + // Generate + ctx := context.Background() + genOpts := &extractor.GenerateOptions{ + OutputDir: t.TempDir(), + OutputFile: "openapi", + Format: "yaml", + } + result, err := ext.Generate(ctx, projectPath, info, genOpts) + if err != nil { + t.Fatalf("failed to generate: %v", err) + } + + if result.SpecFilePath == "" { + t.Error("expected spec file path") + } + + // Verify file exists + if _, err := os.Stat(result.SpecFilePath); err != nil { + t.Errorf("spec file not found: %s", result.SpecFilePath) + } + + t.Log("Auto-detection test passed!") +} diff --git a/internal/extractor/builtin/register.go b/internal/extractor/builtin/register.go index 2a73b5c..a12d6c4 100644 --- a/internal/extractor/builtin/register.go +++ b/internal/extractor/builtin/register.go @@ -1,6 +1,7 @@ package builtin import ( + "github.com/spencercjh/spec-forge/internal/extractor/gin" "github.com/spencercjh/spec-forge/internal/extractor/gozero" "github.com/spencercjh/spec-forge/internal/extractor/grpcprotoc" "github.com/spencercjh/spec-forge/internal/extractor/spring" @@ -11,4 +12,5 @@ func init() { Register(spring.ExtractorName, &spring.Extractor{}) Register(gozero.ExtractorName, &gozero.Extractor{}) Register(grpcprotoc.ExtractorName, &grpcprotoc.Extractor{}) + Register(gin.ExtractorName, gin.NewExtractor()) } diff --git a/internal/extractor/gin/ast_parser.go b/internal/extractor/gin/ast_parser.go new file mode 100644 index 0000000..8f39c14 --- /dev/null +++ b/internal/extractor/gin/ast_parser.go @@ -0,0 +1,291 @@ +package gin + +import ( + "go/ast" + "go/parser" + "go/token" + "log/slog" + "maps" + "net/http" + "os" + "path/filepath" + "strings" +) + +// ASTParser parses Go AST to extract Gin routes. +type ASTParser struct { + fset *token.FileSet + projectPath string + files map[string]*ast.File + routerVars map[string]bool // Track router variable names + groups map[string]*GroupInfo +} + +// GroupInfo stores information about a router group. +type GroupInfo struct { + BasePath string + VarName string +} + +// NewASTParser creates a new ASTParser instance. +func NewASTParser(projectPath string) *ASTParser { + return &ASTParser{ + projectPath: projectPath, + fset: token.NewFileSet(), + files: make(map[string]*ast.File), + routerVars: make(map[string]bool), + groups: make(map[string]*GroupInfo), + } +} + +// ParseFiles parses all Go files in the project. +func (p *ASTParser) ParseFiles() error { + slog.Debug("Parsing Go files", "path", p.projectPath) + return filepath.Walk(p.projectPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip vendor and hidden directories + if info.IsDir() { + if info.Name() == "vendor" || strings.HasPrefix(info.Name(), ".") { + return filepath.SkipDir + } + return nil + } + + // Only process .go files + if !strings.HasSuffix(path, ".go") { + return nil + } + + // Skip test files + if strings.HasSuffix(path, "_test.go") { + return nil + } + + // Parse the file + // #nosec G122 - path is validated through filepath.Walk which handles symlink traversal + content, err := os.ReadFile(path) + if err != nil { + return err + } + + file, parseErr := parser.ParseFile(p.fset, path, content, parser.ParseComments) + _ = parseErr // Intentionally ignore parse errors to skip malformed files + if file == nil { + // Skip files that failed to parse + return nil + } + + p.files[path] = file + return nil + }) +} + +// ExtractRoutes extracts all Gin routes from parsed files. +func (p *ASTParser) ExtractRoutes() ([]Route, error) { + slog.Debug("Extracting routes", "files", len(p.files)) + + // Routes count is unpredictable as it depends on AST analysis + // #nosec prealloc - cannot preallocate without knowing route count upfront + var routes []Route + + // First pass: extract group definitions + for _, file := range p.files { + groups := p.extractGroups(file) + maps.Copy(p.groups, groups) + } + if len(p.groups) > 0 { + slog.Debug("Extracted router groups", "count", len(p.groups)) + } + + // Second pass: extract routes + for path, file := range p.files { + fileRoutes := p.extractRoutesFromFile(path, file) + routes = append(routes, fileRoutes...) + } + + slog.Info("Extracted routes", "count", len(routes)) + return routes, nil +} + +// extractGroups extracts router group definitions from a file. +func (p *ASTParser) extractGroups(file *ast.File) map[string]*GroupInfo { + groups := make(map[string]*GroupInfo) + + ast.Inspect(file, func(n ast.Node) bool { + if node, ok := n.(*ast.AssignStmt); ok { + if len(node.Lhs) == 1 && len(node.Rhs) == 1 { + if group := p.parseGroupAssignment(node.Rhs[0]); group != nil { + if ident, ok := node.Lhs[0].(*ast.Ident); ok { + group.VarName = ident.Name + groups[ident.Name] = group + } + } + } + } + return true + }) + + return groups +} + +// parseGroupAssignment parses a Group assignment. +func (p *ASTParser) parseGroupAssignment(expr ast.Expr) *GroupInfo { + call, ok := expr.(*ast.CallExpr) + if !ok { + return nil + } + + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok || sel.Sel.Name != "Group" { + return nil + } + + if len(call.Args) < 1 { + return nil + } + + basePath := extractStringLiteral(call.Args[0]) + if basePath == "" { + return nil + } + + return &GroupInfo{BasePath: basePath} +} + +// extractRoutesFromFile extracts routes from a single file. +func (p *ASTParser) extractRoutesFromFile(path string, file *ast.File) []Route { + var routes []Route + + // Inspect the AST + ast.Inspect(file, func(n ast.Node) bool { + if node, ok := n.(*ast.CallExpr); ok { + if route := p.parseRouteCall(path, node); route != nil { + routes = append(routes, *route) + } + } + return true + }) + + return routes +} + +// parseRouteCall parses a route registration call. +func (p *ASTParser) parseRouteCall(file string, call *ast.CallExpr) *Route { + // Pattern: r.GET("/path", handler) or api.GET("/path", handler) + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return nil + } + + method := sel.Sel.Name + if !isHTTPMethod(method) { + return nil + } + + // Get the router variable + routerVar, ok := sel.X.(*ast.Ident) + if !ok { + return nil + } + + // Need at least 2 args: path and handler + if len(call.Args) < 2 { + return nil + } + + // Extract path + path := extractStringLiteral(call.Args[0]) + if path == "" { + return nil + } + + // Check if this is a group route + fullPath := path + if group, ok := p.groups[routerVar.Name]; ok { + fullPath = group.BasePath + path + } + + // Convert Gin path format (:id) to OpenAPI path format ({id}) + fullPath = convertPathFormat(fullPath) + + // Extract handler name + handlerName := extractHandlerName(call.Args[1]) + + return &Route{ + Method: method, + Path: path, + FullPath: fullPath, + HandlerName: handlerName, + HandlerFile: file, + } +} + +// isHTTPMethod checks if a string is an HTTP method. +func isHTTPMethod(s string) bool { + switch s { + case http.MethodGet, http.MethodPost, http.MethodPut, http.MethodDelete, + http.MethodPatch, http.MethodHead, http.MethodOptions: + return true + } + return false +} + +// convertPathFormat converts Gin path format (:param) to OpenAPI path format ({param}). +func convertPathFormat(path string) string { + // Replace :param with {param} + // Handle patterns like :id, :userId, etc. + var result strings.Builder + for i := 0; i < len(path); i++ { + if path[i] == ':' { + // Find the end of the parameter name + j := i + 1 + for j < len(path) && isValidParamChar(path[j]) { + j++ + } + if j > i+1 { + // Convert :param to {param} + result.WriteByte('{') + result.WriteString(path[i+1 : j]) + result.WriteByte('}') + i = j - 1 + } else { + result.WriteByte(path[i]) + } + } else { + result.WriteByte(path[i]) + } + } + return result.String() +} + +// isValidParamChar checks if a character is valid for a parameter name. +func isValidParamChar(c byte) bool { + return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' +} + +// extractStringLiteral extracts a string from an AST expression. +func extractStringLiteral(expr ast.Expr) string { + if lit, ok := expr.(*ast.BasicLit); ok && lit.Kind == token.STRING { + // Remove quotes + return strings.Trim(lit.Value, `"`) + } + return "" +} + +// extractHandlerName extracts handler name from an expression. +func extractHandlerName(expr ast.Expr) string { + switch e := expr.(type) { + case *ast.Ident: + return e.Name + case *ast.FuncLit: + return "" // Anonymous function + case *ast.SelectorExpr: + if x, ok := e.X.(*ast.Ident); ok { + return x.Name + "." + e.Sel.Name + } + } + return "" +} diff --git a/internal/extractor/gin/ast_parser_test.go b/internal/extractor/gin/ast_parser_test.go new file mode 100644 index 0000000..572222c --- /dev/null +++ b/internal/extractor/gin/ast_parser_test.go @@ -0,0 +1,169 @@ +package gin + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNewASTParser(t *testing.T) { + p := NewASTParser("/tmp") + if p == nil { + t.Error("expected non-nil parser") + } +} + +func TestASTParser_ParseFiles(t *testing.T) { + dir := t.TempDir() + + // Create a simple Go file + code := `package main + +import "github.com/gin-gonic/gin" + +func main() { + r := gin.Default() + r.GET("/ping", func(c *gin.Context) { + c.JSON(200, gin.H{"message": "pong"}) + }) +} +` + os.WriteFile(filepath.Join(dir, "main.go"), []byte(code), 0o644) + + parser := NewASTParser(dir) + err := parser.ParseFiles() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(parser.files) != 1 { + t.Errorf("expected 1 file, got %d", len(parser.files)) + } +} + +func TestASTParser_ExtractRoutes(t *testing.T) { + dir := t.TempDir() + + // Create a file with direct route registration + code := `package main + +import "github.com/gin-gonic/gin" + +func main() { + r := gin.Default() + r.GET("/users", getUsers) + r.POST("/users", createUser) +} + +func getUsers(c *gin.Context) {} +func createUser(c *gin.Context) {} +` + os.WriteFile(filepath.Join(dir, "main.go"), []byte(code), 0o644) + + parser := NewASTParser(dir) + parser.ParseFiles() + + routes, err := parser.ExtractRoutes() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(routes) != 2 { + t.Errorf("expected 2 routes, got %d", len(routes)) + } + + // Check first route + foundGet := false + foundPost := false + for _, r := range routes { + if r.Method == "GET" && r.Path == "/users" { + foundGet = true + } + if r.Method == "POST" && r.Path == "/users" { + foundPost = true + } + } + if !foundGet { + t.Error("expected GET /users route") + } + if !foundPost { + t.Error("expected POST /users route") + } +} + +func TestASTParser_ExtractGroupRoutes(t *testing.T) { + dir := t.TempDir() + + // Create a file with route groups + code := `package main + +import "github.com/gin-gonic/gin" + +func main() { + r := gin.Default() + api := r.Group("/api/v1") + api.GET("/users", getUsers) + api.POST("/users", createUser) +} + +func getUsers(c *gin.Context) {} +func createUser(c *gin.Context) {} +` + os.WriteFile(filepath.Join(dir, "main.go"), []byte(code), 0o644) + + parser := NewASTParser(dir) + parser.ParseFiles() + + routes, err := parser.ExtractRoutes() + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Check full paths include group prefix + found := false + for _, r := range routes { + if r.Method == "GET" && r.FullPath == "/api/v1/users" { + found = true + break + } + } + if !found { + t.Error("expected GET /api/v1/users route") + } +} + +func TestIsHTTPMethod(t *testing.T) { + tests := []struct { + name string + method string + expected bool + }{ + {"GET", "GET", true}, + {"POST", "POST", true}, + {"PUT", "PUT", true}, + {"DELETE", "DELETE", true}, + {"PATCH", "PATCH", true}, + {"HEAD", "HEAD", true}, + {"OPTIONS", "OPTIONS", true}, + {"INVALID", "INVALID", false}, + {"", "", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isHTTPMethod(tt.method) + if result != tt.expected { + t.Errorf("isHTTPMethod(%q) = %v, expected %v", tt.method, result, tt.expected) + } + }) + } +} + +func TestExtractStringLiteral(t *testing.T) { + // This function is tested indirectly through ExtractRoutes tests + // as it requires AST nodes which are complex to create manually +} + +func TestExtractHandlerName(t *testing.T) { + // This function is tested indirectly through ExtractRoutes tests +} diff --git a/internal/extractor/gin/detector.go b/internal/extractor/gin/detector.go new file mode 100644 index 0000000..f968b59 --- /dev/null +++ b/internal/extractor/gin/detector.go @@ -0,0 +1,149 @@ +package gin + +import ( + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "strings" + + "golang.org/x/mod/modfile" + + "github.com/spencercjh/spec-forge/internal/extractor" +) + +const ( + GinModule = "github.com/gin-gonic/gin" + GoModFile = "go.mod" +) + +// Detector detects Gin projects. +type Detector struct{} + +// NewDetector creates a new Detector instance. +func NewDetector() *Detector { + return &Detector{} +} + +// Detect analyzes a project and returns info if it's a Gin project. +func (d *Detector) Detect(projectPath string) (*extractor.ProjectInfo, error) { + slog.Debug("Detecting Gin project", "path", projectPath) + + absPath, err := filepath.Abs(projectPath) + if err != nil { + slog.Error("Failed to resolve path", "path", projectPath, "error", err) + return nil, fmt.Errorf("failed to resolve path: %w", err) + } + + // Check for go.mod + goModPath := filepath.Join(absPath, GoModFile) + if _, statErr := os.Stat(goModPath); statErr != nil { + slog.Warn("No go.mod found", "path", absPath) + return nil, fmt.Errorf("no go.mod found in %s", absPath) + } + + // Parse go.mod for Gin dependency + ginVersion, err := d.parseGinVersion(goModPath) + if err != nil { + slog.Error("Failed to parse go.mod", "path", goModPath, "error", err) + return nil, fmt.Errorf("failed to parse go.mod: %w", err) + } + if ginVersion == "" { + slog.Warn("No gin dependency found in go.mod", "path", goModPath) + return nil, errors.New("no gin dependency found in go.mod") + } + + // Extract module name + content, err := os.ReadFile(goModPath) + if err != nil { + return nil, fmt.Errorf("failed to read go.mod: %w", err) + } + modFile, err := modfile.Parse("go.mod", content, nil) + if err != nil { + return nil, fmt.Errorf("failed to parse go.mod: %w", err) + } + moduleName := modFile.Module.Mod.Path + + // Find Go files + mainFiles, err := d.findMainFiles(absPath) + if err != nil { + return nil, fmt.Errorf("failed to find main files: %w", err) + } + if len(mainFiles) == 0 { + return nil, fmt.Errorf("no .go files found in %s", absPath) + } + + slog.Info("Detected Gin project", "module", moduleName, "version", ginVersion, "files", len(mainFiles)) + + // Create Gin-specific info + ginInfo := &Info{ + GoVersion: "", // Will be filled if needed + ModuleName: moduleName, + GinVersion: ginVersion, + HasGin: true, + MainFiles: mainFiles, + } + + return &extractor.ProjectInfo{ + Framework: ExtractorName, + BuildTool: "gomodules", + BuildFilePath: goModPath, + FrameworkData: ginInfo, + }, nil +} + +// parseGinVersion parses go.mod and returns the gin version if present. +func (d *Detector) parseGinVersion(goModPath string) (string, error) { + content, err := os.ReadFile(goModPath) + if err != nil { + return "", err + } + + modFile, err := modfile.Parse("go.mod", content, nil) + if err != nil { + return "", err + } + + for _, req := range modFile.Require { + if req.Mod.Path == GinModule { + return req.Mod.Version, nil + } + } + + return "", nil +} + +// findMainFiles finds all Go files in the project (excluding vendor and tests). +func (d *Detector) findMainFiles(projectPath string) ([]string, error) { + var mainFiles []string + + err := filepath.Walk(projectPath, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + + // Skip vendor and hidden directories + if info.IsDir() { + if info.Name() == "vendor" || strings.HasPrefix(info.Name(), ".") { + return filepath.SkipDir + } + return nil + } + + // Only process .go files + if !strings.HasSuffix(path, ".go") { + return nil + } + + // Skip test files + if strings.HasSuffix(path, "_test.go") { + return nil + } + + mainFiles = append(mainFiles, path) + return nil + }) + + return mainFiles, err +} diff --git a/internal/extractor/gin/detector_test.go b/internal/extractor/gin/detector_test.go new file mode 100644 index 0000000..024167c --- /dev/null +++ b/internal/extractor/gin/detector_test.go @@ -0,0 +1,210 @@ +package gin + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNewDetector(t *testing.T) { + d := NewDetector() + if d == nil { + t.Error("expected non-nil detector") + } +} + +func TestDetector_parseGinVersion(t *testing.T) { + tests := []struct { + name string + goMod string + expected string + }{ + { + name: "has gin dependency", + goMod: `module test + +go 1.21 + +require github.com/gin-gonic/gin v1.9.1 +`, + expected: "v1.9.1", + }, + { + name: "has gin - different version", + goMod: `module test + +go 1.21 + +require github.com/gin-gonic/gin v1.9.0 +`, + expected: "v1.9.0", + }, + { + name: "no gin - other framework", + goMod: `module test + +go 1.21 + +require github.com/zeromicro/go-zero v1.6.0 +`, + expected: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + goModPath := filepath.Join(dir, "go.mod") + os.WriteFile(goModPath, []byte(tt.goMod), 0o644) + + d := NewDetector() + version, err := d.parseGinVersion(goModPath) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if version != tt.expected { + t.Errorf("expected %q, got %q", tt.expected, version) + } + }) + } +} + +func TestDetector_findMainFiles(t *testing.T) { + dir := t.TempDir() + + // Create main.go + os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n\nfunc main() {}"), 0o644) + + // Create router.go with route registration + os.WriteFile(filepath.Join(dir, "router.go"), []byte("package main\n\nfunc setupRouter() {}"), 0o644) + + // Create non-main file + os.WriteFile(filepath.Join(dir, "utils.go"), []byte("package main\n\nfunc helper() {}"), 0o644) + + // Create vendor directory (should be excluded) + os.MkdirAll(filepath.Join(dir, "vendor", "test"), 0o755) + os.WriteFile(filepath.Join(dir, "vendor", "test", "main.go"), []byte("package main"), 0o644) + + d := NewDetector() + files, err := d.findMainFiles(dir) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Should find main.go and router.go + if len(files) < 1 { + t.Errorf("expected at least 1 main file, got %d", len(files)) + } + + // Check main.go is found + foundMain := false + for _, f := range files { + if filepath.Base(f) == "main.go" { + foundMain = true + break + } + } + if !foundMain { + t.Error("expected main.go to be found") + } +} + +func TestDetector_Detect(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T) string + wantErr bool + wantVersion string + wantHasGin bool + }{ + { + name: "valid gin project", + setup: func(t *testing.T) string { + t.Helper() + dir := t.TempDir() + goMod := `module test + +go 1.21 + +require github.com/gin-gonic/gin v1.9.1 +` + os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0o644) + os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n\nfunc main() {}"), 0o644) + return dir + }, + wantErr: false, + wantVersion: "v1.9.1", + wantHasGin: true, + }, + { + name: "missing go.mod", + setup: func(t *testing.T) string { + t.Helper() + return t.TempDir() + }, + wantErr: true, + }, + { + name: "no gin dependency", + setup: func(t *testing.T) string { + t.Helper() + dir := t.TempDir() + goMod := `module test + +go 1.21 + +require github.com/zeromicro/go-zero v1.6.0 +` + os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0o644) + return dir + }, + wantErr: true, + }, + { + name: "no go files", + setup: func(t *testing.T) string { + t.Helper() + dir := t.TempDir() + goMod := `module test + +go 1.21 + +require github.com/gin-gonic/gin v1.9.1 +` + os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0o644) + return dir + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := tt.setup(t) + d := NewDetector() + info, err := d.Detect(dir) + + if tt.wantErr { + if err == nil { + t.Error("expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if info.Framework != ExtractorName { + t.Errorf("expected framework %q, got %q", ExtractorName, info.Framework) + } + ginInfo, ok := info.FrameworkData.(*Info) + if !ok { + t.Fatal("expected FrameworkData to be *gin.Info") + } + if ginInfo.GinVersion != tt.wantVersion { + t.Errorf("expected version %q, got %q", tt.wantVersion, ginInfo.GinVersion) + } + }) + } +} diff --git a/internal/extractor/gin/generator.go b/internal/extractor/gin/generator.go new file mode 100644 index 0000000..fb5dbf5 --- /dev/null +++ b/internal/extractor/gin/generator.go @@ -0,0 +1,433 @@ +package gin + +import ( + "context" + "errors" + "fmt" + "go/ast" + "log/slog" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/getkin/kin-openapi/openapi3" + "gopkg.in/yaml.v3" + + "github.com/spencercjh/spec-forge/internal/extractor" +) + +// Generator generates OpenAPI specs from Gin projects using AST parsing. +type Generator struct{} + +// ginHType represents the gin.H map type for OpenAPI spec generation. +const ginHType = "map[string]any" + +// NewGenerator creates a new Generator instance. +func NewGenerator() *Generator { + return &Generator{} +} + +// Generate generates OpenAPI spec from Gin project. +func (g *Generator) Generate(ctx context.Context, projectPath string, info *extractor.ProjectInfo, opts *extractor.GenerateOptions) (*extractor.GenerateResult, error) { + slog.InfoContext(ctx, "Generating OpenAPI spec from Gin project", "path", projectPath) + + ginInfo, ok := info.FrameworkData.(*Info) + if !ok { + slog.ErrorContext(ctx, "Invalid framework data type") + return nil, errors.New("invalid framework data type") + } + + // Step 1: Parse AST files + slog.DebugContext(ctx, "Parsing AST files", "path", projectPath) + parser := NewASTParser(projectPath) + if err := parser.ParseFiles(); err != nil { + slog.ErrorContext(ctx, "Failed to parse files", "error", err) + return nil, fmt.Errorf("failed to parse files: %w", err) + } + slog.DebugContext(ctx, "Parsed AST files", "count", len(parser.files)) + + // Step 2: Extract routes + routes, err := parser.ExtractRoutes() + if err != nil { + slog.ErrorContext(ctx, "Failed to extract routes", "error", err) + return nil, fmt.Errorf("failed to extract routes: %w", err) + } + slog.InfoContext(ctx, "Extracted routes", "count", len(routes)) + + // Step 3: Analyze handlers + slog.DebugContext(ctx, "Analyzing handlers") + analyzer := NewHandlerAnalyzer(parser.fset, parser.files) + handlerInfos := make(map[string]*HandlerInfo) + for _, route := range routes { + // Find handler function + handlerDecl := g.findHandlerDecl(route.HandlerName, parser.files) + if handlerDecl != nil { + handlerInfo, analyzeErr := analyzer.AnalyzeHandler(handlerDecl) + if analyzeErr != nil { + slog.WarnContext(ctx, "Failed to analyze handler", "handler", route.HandlerName, "error", analyzeErr) + continue + } + handlerInfos[route.HandlerName] = handlerInfo + slog.DebugContext(ctx, "Analyzed handler", "handler", route.HandlerName, "bodyType", handlerInfo.BodyType, "responses", len(handlerInfo.Responses)) + } else { + slog.WarnContext(ctx, "Handler function not found", "handler", route.HandlerName) + } + } + + // Step 4: Extract schemas + slog.DebugContext(ctx, "Extracting schemas") + schemaExtractor := NewSchemaExtractor(parser.files) + schemas := make(openapi3.Schemas) + for _, handlerInfo := range handlerInfos { + if handlerInfo.BodyType != "" && handlerInfo.BodyType != ginHType { + if schema, extractErr := schemaExtractor.ExtractSchema(handlerInfo.BodyType); extractErr == nil { + schemas[handlerInfo.BodyType] = schema + slog.DebugContext(ctx, "Extracted request body schema", "type", handlerInfo.BodyType) + } else { + slog.WarnContext(ctx, "Failed to extract schema", "type", handlerInfo.BodyType, "error", extractErr) + } + } + for _, resp := range handlerInfo.Responses { + if resp.GoType != "" && resp.GoType != ginHType { + if schema, extractErr := schemaExtractor.ExtractSchema(resp.GoType); extractErr == nil { + schemas[resp.GoType] = schema + slog.DebugContext(ctx, "Extracted response schema", "type", resp.GoType) + } else { + slog.WarnContext(ctx, "Failed to extract schema", "type", resp.GoType, "error", extractErr) + } + } + } + } + slog.InfoContext(ctx, "Extracted schemas", "count", len(schemas)) + + // Step 5: Build OpenAPI document + slog.DebugContext(ctx, "Building OpenAPI document") + doc := g.buildOpenAPIDoc(ginInfo, routes, handlerInfos, schemas) + + // Step 6: Write output + outputPath, err := g.writeOutput(doc, opts) + if err != nil { + slog.ErrorContext(ctx, "Failed to write output", "error", err) + return nil, fmt.Errorf("failed to write output: %w", err) + } + + slog.InfoContext(ctx, "Generated OpenAPI spec", "path", outputPath, "format", opts.Format) + + return &extractor.GenerateResult{ + SpecFilePath: outputPath, + Format: opts.Format, + }, nil +} + +// findHandlerDecl finds a handler function declaration by name. +func (g *Generator) findHandlerDecl(name string, files map[string]*ast.File) *ast.FuncDecl { + if name == "" { + return nil + } + for _, file := range files { + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == name { + return fn + } + } + } + return nil +} + +// buildOpenAPIDoc builds the OpenAPI document. +func (g *Generator) buildOpenAPIDoc(info *Info, routes []Route, handlerInfos map[string]*HandlerInfo, schemas openapi3.Schemas) *openapi3.T { + title := "Gin API" + if info.ModuleName != "" { + title = info.ModuleName + } + + doc := &openapi3.T{ + OpenAPI: "3.0.3", + Info: &openapi3.Info{ + Title: title, + Version: "1.0.0", + }, + Paths: openapi3.NewPaths(), + } + + if len(schemas) > 0 { + doc.Components = &openapi3.Components{ + Schemas: schemas, + } + } + + // Build paths + for _, route := range routes { + pathItem := doc.Paths.Find(route.FullPath) + if pathItem == nil { + pathItem = &openapi3.PathItem{} + doc.Paths.Set(route.FullPath, pathItem) + } + + operation := g.buildOperation(&route, handlerInfos[route.HandlerName], schemas) + setOperationForMethod(pathItem, route.Method, operation) + } + + return doc +} + +// buildOperation builds an OpenAPI operation from a route. +func (g *Generator) buildOperation(route *Route, handlerInfo *HandlerInfo, schemas openapi3.Schemas) *openapi3.Operation { + // Generate deterministic OperationID/Summary for anonymous handlers + operationID := route.HandlerName + summary := route.HandlerName + if operationID == "" { + // Fallback: use METHOD + path pattern (e.g., "GET_users_id") + operationID = route.Method + "_" + strings.ReplaceAll(strings.Trim(route.FullPath, "/"), "/", "_") + summary = route.Method + " " + route.FullPath + } + + operation := &openapi3.Operation{ + OperationID: operationID, + Summary: summary, + } + + if handlerInfo == nil { + return operation + } + + // Add parameters + operation.Parameters = make(openapi3.Parameters, 0) + + // Path parameters + for _, param := range handlerInfo.PathParams { + operation.Parameters = append(operation.Parameters, &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: param.Name, + In: "path", + Required: param.Required, + Description: "Path parameter", + Schema: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + }, + }) + } + + // Query parameters + for _, param := range handlerInfo.QueryParams { + operation.Parameters = append(operation.Parameters, &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: param.Name, + In: "query", + Required: param.Required, + Description: "Query parameter", + Schema: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + }, + }) + } + + // Header parameters + for _, param := range handlerInfo.HeaderParams { + operation.Parameters = append(operation.Parameters, &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: param.Name, + In: "header", + Required: param.Required, + Description: "Header parameter", + Schema: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + }, + }) + } + + // Handle request body / form parameters based on binding source + g.buildRequestBody(operation, handlerInfo, schemas) + + // Responses + operation.Responses = openapi3.NewResponses() + for _, resp := range handlerInfo.Responses { + desc := fmt.Sprintf("HTTP %d response", resp.StatusCode) + response := &openapi3.Response{ + Description: &desc, + Content: openapi3.Content{}, + } + + if resp.GoType != "" { + if resp.GoType == ginHType { + response.Content["application/json"] = &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"object"}}}, + } + } else if _, exists := schemas[resp.GoType]; exists { + response.Content["application/json"] = &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{ + Ref: "#/components/schemas/" + resp.GoType, + }, + } + } else { + // Fallback to generic object if schema not found + response.Content["application/json"] = &openapi3.MediaType{ + Schema: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"object"}}}, + } + } + } + + statusCode := strconv.Itoa(resp.StatusCode) + operation.Responses.Set(statusCode, &openapi3.ResponseRef{Value: response}) + } + + // Default response if none specified + if len(handlerInfo.Responses) == 0 { + desc := "Success" + operation.Responses.Set("200", &openapi3.ResponseRef{ + Value: &openapi3.Response{ + Description: &desc, + }, + }) + } + + return operation +} + +// buildRequestBody builds the request body or form parameters based on binding source. +func (g *Generator) buildRequestBody(operation *openapi3.Operation, handlerInfo *HandlerInfo, schemas openapi3.Schemas) { + // Handle form parameters (application/x-www-form-urlencoded) + if len(handlerInfo.FormParams) > 0 { + for _, param := range handlerInfo.FormParams { + operation.Parameters = append(operation.Parameters, &openapi3.ParameterRef{ + Value: &openapi3.Parameter{ + Name: param.Name, + In: "query", + Required: param.Required, + Description: "Form parameter", + Schema: &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"string"}}}, + }, + }) + } + } + + // Handle file upload parameters (multipart/form-data) + if len(handlerInfo.FileParams) > 0 { + content := make(openapi3.Content) + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: make(openapi3.Schemas), + } + for _, param := range handlerInfo.FileParams { + schema.Properties[param.Name] = &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"string"}, + Format: "binary", + }, + } + if param.Required { + schema.Required = append(schema.Required, param.Name) + } + } + content["multipart/form-data"] = &openapi3.MediaType{Schema: &openapi3.SchemaRef{Value: schema}} + operation.RequestBody = &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Required: true, + Content: content, + }, + } + return + } + + // Handle body binding based on source + if handlerInfo.BodyType == "" || handlerInfo.BodyType == ginHType { + return + } + + var schemaRef *openapi3.SchemaRef + if _, exists := schemas[handlerInfo.BodyType]; exists { + schemaRef = &openapi3.SchemaRef{Ref: "#/components/schemas/" + handlerInfo.BodyType} + } else { + // Fallback to generic object if schema not found + schemaRef = &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"object"}}} + } + + // Determine content type based on binding source + contentType := "application/json" + switch handlerInfo.BindingSrc { + case BindingSourceXML: + contentType = "application/xml" + case BindingSourceYAML: + contentType = "application/x-yaml" + case BindingSourceQuery: + // Query binding - don't create request body, parameters are in query + return + case BindingSourceForm: + contentType = "application/x-www-form-urlencoded" + } + + operation.RequestBody = &openapi3.RequestBodyRef{ + Value: &openapi3.RequestBody{ + Content: openapi3.Content{ + contentType: { + Schema: schemaRef, + }, + }, + }, + } +} + +// setOperationForMethod sets the operation for the given HTTP method. +func setOperationForMethod(pathItem *openapi3.PathItem, method string, operation *openapi3.Operation) { + switch method { + case "GET": + pathItem.Get = operation + case "POST": + pathItem.Post = operation + case "PUT": + pathItem.Put = operation + case "DELETE": + pathItem.Delete = operation + case "PATCH": + pathItem.Patch = operation + case "HEAD": + pathItem.Head = operation + case "OPTIONS": + pathItem.Options = operation + } +} + +// writeOutput writes the OpenAPI document to file. +func (g *Generator) writeOutput(doc *openapi3.T, opts *extractor.GenerateOptions) (string, error) { + outputDir := opts.OutputDir + if outputDir == "" { + outputDir = "." + } + + outputFile := opts.OutputFile + if outputFile == "" { + outputFile = "openapi" + } + + format := opts.Format + if format == "" { + format = "json" + } + + var data []byte + var err error + var ext string + + switch format { + case "yaml", "yml": + data, err = yaml.Marshal(doc) + ext = ".yaml" + default: // json + data, err = doc.MarshalJSON() + ext = ".json" + } + + if err != nil { + return "", err + } + + // Create output directory if it doesn't exist + if err := os.MkdirAll(outputDir, 0o755); err != nil { + return "", fmt.Errorf("failed to create output directory: %w", err) + } + + outputPath := filepath.Join(outputDir, outputFile+ext) + if err := os.WriteFile(outputPath, data, 0o600); err != nil { + return "", err + } + + return outputPath, nil +} diff --git a/internal/extractor/gin/generator_test.go b/internal/extractor/gin/generator_test.go new file mode 100644 index 0000000..ad850c3 --- /dev/null +++ b/internal/extractor/gin/generator_test.go @@ -0,0 +1,206 @@ +package gin + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/getkin/kin-openapi/openapi3" + + "github.com/spencercjh/spec-forge/internal/extractor" +) + +func TestNewGenerator(t *testing.T) { + g := NewGenerator() + if g == nil { + t.Error("expected non-nil generator") + } +} + +func TestGenerator_Generate(t *testing.T) { + // Create a simple Gin project + dir := t.TempDir() + + goMod := `module test + +go 1.21 + +require github.com/gin-gonic/gin v1.9.1 +` + os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0o644) + + mainGo := `package main + +import "github.com/gin-gonic/gin" + +type User struct { + ID int ` + "`" + `json:"id"` + "`" + ` + Name string ` + "`" + `json:"name"` + "`" + ` +} + +func main() { + r := gin.Default() + r.GET("/users/:id", getUser) +} + +func getUser(c *gin.Context) { + id := c.Param("id") + _ = id + c.JSON(200, User{ID: 1, Name: "test"}) +} +` + os.WriteFile(filepath.Join(dir, "main.go"), []byte(mainGo), 0o644) + + g := NewGenerator() + ctx := context.Background() + info := &extractor.ProjectInfo{ + Framework: "gin", + FrameworkData: &Info{ + ModuleName: "test", + GinVersion: "v1.9.1", + }, + } + opts := &extractor.GenerateOptions{ + OutputDir: dir, + OutputFile: "openapi", + Format: "yaml", + } + + result, err := g.Generate(ctx, dir, info, opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result == nil { + t.Fatal("expected non-nil result") + } + + // Check output file exists + if _, err := os.Stat(result.SpecFilePath); err != nil { + t.Errorf("output file not created: %s", result.SpecFilePath) + } + + // Load and validate the spec + loader := openapi3.NewLoader() + spec, err := loader.LoadFromFile(result.SpecFilePath) + if err != nil { + t.Fatalf("failed to load spec: %v", err) + } + + // Check paths + if spec.Paths.Len() == 0 { + t.Error("expected at least one path") + } + + // Check if User schema exists + if spec.Components == nil || spec.Components.Schemas == nil { + t.Error("expected Components.Schemas to be defined") + } else if spec.Components.Schemas["User"] == nil { + t.Error("expected User schema to be defined") + } +} + +func TestGenerator_Generate_JSON(t *testing.T) { + dir := t.TempDir() + + goMod := `module test + +go 1.21 + +require github.com/gin-gonic/gin v1.9.1 +` + os.WriteFile(filepath.Join(dir, "go.mod"), []byte(goMod), 0o644) + + mainGo := `package main + +import "github.com/gin-gonic/gin" + +func main() { + r := gin.Default() + r.GET("/ping", func(c *gin.Context) { + c.JSON(200, gin.H{"message": "pong"}) + }) +} +` + os.WriteFile(filepath.Join(dir, "main.go"), []byte(mainGo), 0o644) + + g := NewGenerator() + ctx := context.Background() + info := &extractor.ProjectInfo{ + Framework: "gin", + FrameworkData: &Info{ + ModuleName: "test", + }, + } + opts := &extractor.GenerateOptions{ + OutputDir: dir, + OutputFile: "openapi", + Format: "json", + } + + result, err := g.Generate(ctx, dir, info, opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if !strings.HasSuffix(result.SpecFilePath, ".json") { + t.Errorf("expected json file extension, got %s", result.SpecFilePath) + } + + // Load and validate + loader := openapi3.NewLoader() + _, err = loader.LoadFromFile(result.SpecFilePath) + if err != nil { + t.Fatalf("failed to load spec: %v", err) + } +} + +func TestSetOperationForMethod(t *testing.T) { + tests := []struct { + method string + }{ + {"GET"}, + {"POST"}, + {"PUT"}, + {"DELETE"}, + {"PATCH"}, + {"HEAD"}, + {"OPTIONS"}, + } + + for _, tt := range tests { + t.Run(tt.method, func(t *testing.T) { + pathItem := &openapi3.PathItem{} + operation := &openapi3.Operation{Summary: "test"} + + setOperationForMethod(pathItem, tt.method, operation) + + var result *openapi3.Operation + switch tt.method { + case "GET": + result = pathItem.Get + case "POST": + result = pathItem.Post + case "PUT": + result = pathItem.Put + case "DELETE": + result = pathItem.Delete + case "PATCH": + result = pathItem.Patch + case "HEAD": + result = pathItem.Head + case "OPTIONS": + result = pathItem.Options + } + + if result == nil { + t.Errorf("expected %s operation to be set", tt.method) + } + if result != nil && result.Summary != "test" { + t.Errorf("expected operation summary 'test', got %s", result.Summary) + } + }) + } +} diff --git a/internal/extractor/gin/gin.go b/internal/extractor/gin/gin.go new file mode 100644 index 0000000..c20b17e --- /dev/null +++ b/internal/extractor/gin/gin.go @@ -0,0 +1,76 @@ +// Package gin provides Gin framework specific extraction functionality. +package gin + +import ( + "context" + + "github.com/spencercjh/spec-forge/internal/extractor" +) + +// ExtractorName is the name of the Gin extractor. +const ExtractorName = "gin" + +// Extractor implements extractor.Extractor for Gin projects. +type Extractor struct { + detector *Detector + patcher *Patcher + generator *Generator +} + +// ensureInitialized lazily initializes the extractor fields if needed. +// This handles the case where the extractor is used as a zero-value. +func (e *Extractor) ensureInitialized() { + if e.detector == nil { + e.detector = NewDetector() + } + if e.patcher == nil { + e.patcher = NewPatcher() + } + if e.generator == nil { + e.generator = NewGenerator() + } +} + +// NewExtractor creates a new Extractor instance. +func NewExtractor() *Extractor { + return &Extractor{ + detector: NewDetector(), + patcher: NewPatcher(), + generator: NewGenerator(), + } +} + +// Name returns the extractor name. +func (e *Extractor) Name() string { + return ExtractorName +} + +// Detect implements extractor.Extractor.Detect. +func (e *Extractor) Detect(projectPath string) (*extractor.ProjectInfo, error) { + e.ensureInitialized() + return e.detector.Detect(projectPath) +} + +// Patch implements extractor.Extractor.Patch. +func (e *Extractor) Patch(projectPath string, opts *extractor.PatchOptions) (*extractor.PatchResult, error) { + e.ensureInitialized() + // Gin projects don't need patching + info := &extractor.ProjectInfo{ + Framework: ExtractorName, + BuildTool: "gomodules", + FrameworkData: &Info{HasGin: true}, + } + return e.patcher.Patch(context.Background(), projectPath, info, opts) +} + +// Generate implements extractor.Extractor.Generate. +func (e *Extractor) Generate(ctx context.Context, projectPath string, info *extractor.ProjectInfo, opts *extractor.GenerateOptions) (*extractor.GenerateResult, error) { + e.ensureInitialized() + return e.generator.Generate(ctx, projectPath, info, opts) +} + +// Restore implements extractor.Extractor.Restore. +func (e *Extractor) Restore(_, _ string) error { + // Gin projects don't need restore (no patching) + return nil +} diff --git a/internal/extractor/gin/gin_test.go b/internal/extractor/gin/gin_test.go new file mode 100644 index 0000000..057650f --- /dev/null +++ b/internal/extractor/gin/gin_test.go @@ -0,0 +1,13 @@ +package gin + +import "testing" + +func TestNewExtractor(t *testing.T) { + e := NewExtractor() + if e == nil { + t.Error("expected non-nil extractor") + } + if e.Name() != ExtractorName { + t.Errorf("expected name %q, got %q", ExtractorName, e.Name()) + } +} diff --git a/internal/extractor/gin/handler_analyzer.go b/internal/extractor/gin/handler_analyzer.go new file mode 100644 index 0000000..3cd67e7 --- /dev/null +++ b/internal/extractor/gin/handler_analyzer.go @@ -0,0 +1,466 @@ +package gin + +import ( + "fmt" + "go/ast" + "go/token" + "log/slog" +) + +// HandlerAnalyzer analyzes Gin handler functions. +type HandlerAnalyzer struct { + fset *token.FileSet + files map[string]*ast.File + typeCache map[string]*ast.TypeSpec +} + +// NewHandlerAnalyzer creates a new HandlerAnalyzer instance. +func NewHandlerAnalyzer(fset *token.FileSet, files map[string]*ast.File) *HandlerAnalyzer { + return &HandlerAnalyzer{ + fset: fset, + files: files, + typeCache: make(map[string]*ast.TypeSpec), + } +} + +// AnalyzeHandler analyzes a handler function and extracts information. +func (a *HandlerAnalyzer) AnalyzeHandler(fn *ast.FuncDecl) (*HandlerInfo, error) { + slog.Debug("Analyzing handler", "name", fn.Name.Name) + + info := &HandlerInfo{ + PathParams: []ParamInfo{}, + QueryParams: []ParamInfo{}, + HeaderParams: []ParamInfo{}, + FormParams: []ParamInfo{}, + FileParams: []ParamInfo{}, + Responses: []ResponseInfo{}, + } + + // Inspect the function body + if fn.Body == nil { + slog.Debug("Handler has no body", "name", fn.Name.Name) + return info, nil + } + + // Build variable type map for type inference + varTypeMap := a.buildVarTypeMap(fn.Body) + + ast.Inspect(fn.Body, func(n ast.Node) bool { + if node, ok := n.(*ast.CallExpr); ok { + a.parseHandlerCall(node, info, varTypeMap) + } + return true + }) + + return info, nil +} + +// buildVarTypeMap builds a map of variable names to their types. +func (a *HandlerAnalyzer) buildVarTypeMap(body *ast.BlockStmt) map[string]string { + varTypeMap := make(map[string]string) + + ast.Inspect(body, func(n ast.Node) bool { + switch node := n.(type) { + case *ast.DeclStmt: + a.extractVarDeclTypes(node, varTypeMap) + case *ast.AssignStmt: + a.extractAssignTypes(node, varTypeMap) + } + return true + }) + + if len(varTypeMap) > 0 { + slog.Debug("Built variable type map", "entries", len(varTypeMap)) + } + return varTypeMap +} + +// extractVarDeclTypes extracts types from var declarations. +func (a *HandlerAnalyzer) extractVarDeclTypes(node *ast.DeclStmt, varTypeMap map[string]string) { + genDecl, ok := node.Decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.VAR { + return + } + for _, spec := range genDecl.Specs { + valueSpec, ok := spec.(*ast.ValueSpec) + if !ok { + continue + } + for i, name := range valueSpec.Names { + if i < len(valueSpec.Values) { + // Check if value is a composite literal with type + if comp, ok := valueSpec.Values[i].(*ast.CompositeLit); ok { + if ident, ok := comp.Type.(*ast.Ident); ok { + varTypeMap[name.Name] = ident.Name + } + } + } + // Also check explicit type + if valueSpec.Type != nil { + if ident, ok := valueSpec.Type.(*ast.Ident); ok { + varTypeMap[name.Name] = ident.Name + } + } + } + } +} + +// extractAssignTypes extracts types from short variable declarations. +func (a *HandlerAnalyzer) extractAssignTypes(node *ast.AssignStmt, varTypeMap map[string]string) { + if node.Tok != token.DEFINE { + return + } + for i, lhs := range node.Lhs { + if i >= len(node.Rhs) { + continue + } + ident, ok := lhs.(*ast.Ident) + if !ok { + continue + } + // Check if RHS is a composite literal with type + if comp, ok := node.Rhs[i].(*ast.CompositeLit); ok { + if typeIdent, ok := comp.Type.(*ast.Ident); ok { + varTypeMap[ident.Name] = typeIdent.Name + } + } else { + // Try to infer type from variable name using heuristic + if inferredType := inferTypeFromVarName(ident.Name); inferredType != "" { + varTypeMap[ident.Name] = inferredType + } + } + } +} + +// inferTypeFromVarName tries to infer type from variable name using common patterns. +// e.g., "user" -> "User", "users" -> "User", "result" -> "Result" +func inferTypeFromVarName(varName string) string { + if varName == "" { + return "" + } + + // Common variable name -> type mappings + mappings := map[string]string{ + "user": "User", + "users": "User", + "req": "", + "result": "", + "data": "", + "item": "", + "items": "", + } + + if typ, ok := mappings[varName]; ok { + return typ + } + + // Try to capitalize first letter (heuristic) + // e.g., "pageResult" -> "PageResult" + if varName != "" && varName[0] >= 'a' && varName[0] <= 'z' { + return string(varName[0]-'a'+'A') + varName[1:] + } + + return varName +} + +// parseHandlerCall parses a call expression in a handler. +func (a *HandlerAnalyzer) parseHandlerCall(call *ast.CallExpr, info *HandlerInfo, varTypeMap map[string]string) { + sel, ok := call.Fun.(*ast.SelectorExpr) + if !ok { + return + } + + // Check if receiver is a context variable + if _, ok = sel.X.(*ast.Ident); !ok { + return + } + + method := sel.Sel.Name + + // Categorize methods and dispatch to specialized handlers + switch method { + case "Param": + a.extractParam(call, info) + case "Query", "DefaultQuery": + a.extractQueryParam(call, info, method == "Query") + case "GetHeader": + a.extractHeaderParam(call, info) + case "ShouldBindJSON", "BindJSON", "ShouldBind", "Bind": + a.extractBodyType(call, info, varTypeMap, BindingSourceJSON) + case "ShouldBindXML", "BindXML": + a.extractBodyType(call, info, varTypeMap, BindingSourceXML) + case "ShouldBindYAML", "BindYAML": + a.extractBodyType(call, info, varTypeMap, BindingSourceYAML) + case "ShouldBindQuery", "BindQuery": + // Query binding extracts query parameters, not body + a.extractQueryBinding(call, info, varTypeMap) + case "PostForm": + a.extractFormParam(call, info, true) + case "DefaultPostForm": + a.extractFormParam(call, info, false) + case "FormFile": + a.extractFileParam(call, info) + case "JSON", "XML", "YAML": + a.extractResponse(call, info, varTypeMap, "") + case "String": + a.extractResponse(call, info, varTypeMap, "string") + case "Data": + a.extractResponse(call, info, varTypeMap, "binary") + case "Redirect": + a.extractResponse(call, info, varTypeMap, "") + } +} + +// extractParam extracts path parameter from c.Param() call. +func (a *HandlerAnalyzer) extractParam(call *ast.CallExpr, info *HandlerInfo) { + if len(call.Args) < 1 { + return + } + if name := extractStringLiteral(call.Args[0]); name != "" { + info.PathParams = append(info.PathParams, ParamInfo{ + Name: name, + GoType: "string", + Required: true, + }) + } +} + +// extractQueryParam extracts query parameter from c.Query() or c.DefaultQuery() call. +func (a *HandlerAnalyzer) extractQueryParam(call *ast.CallExpr, info *HandlerInfo, _ bool) { + if len(call.Args) < 1 { + return + } + if name := extractStringLiteral(call.Args[0]); name != "" { + // In Gin, Query returns empty string when missing and does not enforce presence + // Mark query params as optional (required=false) unless explicit validation exists + info.QueryParams = append(info.QueryParams, ParamInfo{ + Name: name, + GoType: "string", + Required: false, + }) + } +} + +// extractHeaderParam extracts header parameter from c.GetHeader() call. +func (a *HandlerAnalyzer) extractHeaderParam(call *ast.CallExpr, info *HandlerInfo) { + if len(call.Args) < 1 { + return + } + if name := extractStringLiteral(call.Args[0]); name != "" { + info.HeaderParams = append(info.HeaderParams, ParamInfo{ + Name: name, + GoType: "string", + Required: false, + }) + } +} + +// extractBodyType extracts body type from binding calls like c.ShouldBindJSON(). +func (a *HandlerAnalyzer) extractBodyType(call *ast.CallExpr, info *HandlerInfo, varTypeMap map[string]string, source BindingSource) { + if len(call.Args) < 1 { + return + } + if typeName := extractTypeFromArg(call.Args[0], varTypeMap); typeName != "" { + info.BodyType = typeName + info.BindingSrc = source + slog.Debug("Extracted body type", "type", typeName, "source", source) + } +} + +// extractQueryBinding handles ShouldBindQuery by extracting query parameters from struct tags. +// For now, we track that this is a query binding so the generator can handle it properly. +func (a *HandlerAnalyzer) extractQueryBinding(call *ast.CallExpr, info *HandlerInfo, varTypeMap map[string]string) { + if len(call.Args) < 1 { + return + } + if typeName := extractTypeFromArg(call.Args[0], varTypeMap); typeName != "" { + info.BodyType = typeName + info.BindingSrc = BindingSourceQuery + slog.Debug("Extracted query binding type", "type", typeName) + } +} + +// extractFormParam extracts form parameter from c.PostForm() or c.DefaultPostForm() call. +func (a *HandlerAnalyzer) extractFormParam(call *ast.CallExpr, info *HandlerInfo, required bool) { + if len(call.Args) < 1 { + return + } + if name := extractStringLiteral(call.Args[0]); name != "" { + info.FormParams = append(info.FormParams, ParamInfo{ + Name: name, + GoType: "string", + Required: required, + }) + } +} + +// extractFileParam extracts file parameter from c.FormFile() call. +func (a *HandlerAnalyzer) extractFileParam(call *ast.CallExpr, info *HandlerInfo) { + if len(call.Args) < 1 { + return + } + if name := extractStringLiteral(call.Args[0]); name != "" { + info.FileParams = append(info.FileParams, ParamInfo{ + Name: name, + GoType: "file", + Required: true, + }) + } +} + +// extractResponse extracts response information from c.JSON(), c.XML(), etc. calls. +func (a *HandlerAnalyzer) extractResponse(call *ast.CallExpr, info *HandlerInfo, varTypeMap map[string]string, goType string) { + if len(call.Args) < 2 { + return + } + statusCode := extractStatusCode(call.Args[0]) + if goType == "" { + goType = extractTypeFromResponse(call.Args[1], varTypeMap) + } + if goType != "" { + slog.Debug("Extracted response type", "status", statusCode, "type", goType) + } + info.Responses = append(info.Responses, ResponseInfo{ + StatusCode: statusCode, + GoType: goType, + }) +} + +// extractTypeFromArg extracts type name from a binding argument. +func extractTypeFromArg(expr ast.Expr, varTypeMap map[string]string) string { + // Pattern: &variable or &Struct{} + unary, ok := expr.(*ast.UnaryExpr) + if !ok || unary.Op != token.AND { + return "" + } + + // Check for composite literal: &Type{} + if comp, ok := unary.X.(*ast.CompositeLit); ok { + if ident, ok := comp.Type.(*ast.Ident); ok { + return ident.Name + } + if sel, ok := comp.Type.(*ast.SelectorExpr); ok { + if x, ok := sel.X.(*ast.Ident); ok { + return x.Name + "." + sel.Sel.Name + } + } + } + + // Check for variable: &variable + if ident, ok := unary.X.(*ast.Ident); ok { + // First check if we have type info from variable declaration + if varType, exists := varTypeMap[ident.Name]; exists { + return varType + } + // If we don't know the variable's type, treat it as unknown + return "" + } + + return "" +} + +// extractStatusCode extracts HTTP status code from expression. +func extractStatusCode(expr ast.Expr) int { + // Integer literal + if lit, ok := expr.(*ast.BasicLit); ok { + if lit.Kind == token.INT { + var code int + // Try to parse + if _, err := fmt.Sscanf(lit.Value, "%d", &code); err == nil { + return code + } + } + } + + // http.StatusOK reference + if sel, ok := expr.(*ast.SelectorExpr); ok { + if x, ok := sel.X.(*ast.Ident); ok && x.Name == "http" { + return statusCodeFromName(sel.Sel.Name) + } + } + + return 200 // Default +} + +// statusCodeFromName converts http.StatusXxx to code. +func statusCodeFromName(name string) int { + switch name { + case "StatusOK": + return 200 + case "StatusCreated": + return 201 + case "StatusAccepted": + return 202 + case "StatusNoContent": + return 204 + case "StatusBadRequest": + return 400 + case "StatusUnauthorized": + return 401 + case "StatusForbidden": + return 403 + case "StatusNotFound": + return 404 + case "StatusInternalServerError": + return 500 + case "StatusBadGateway": + return 502 + case "StatusServiceUnavailable": + return 503 + } + return 200 +} + +// extractTypeFromResponse extracts type from response argument. +func extractTypeFromResponse(expr ast.Expr, varTypeMap map[string]string) string { + switch e := expr.(type) { + case *ast.Ident: + // If it's a variable, try to get its actual type from the map + if actualType, ok := varTypeMap[e.Name]; ok { + return actualType + } + return e.Name + case *ast.CompositeLit: + // First check if this is ApiResponse{Data: user} pattern + // Try to extract the type from the Data field + for _, elt := range e.Elts { + if kv, ok := elt.(*ast.KeyValueExpr); ok { + if key, ok := kv.Key.(*ast.Ident); ok && key.Name == "Data" { + // Found Data field, recursively extract its type + dataType := extractTypeFromResponse(kv.Value, varTypeMap) + if dataType != "" { + return dataType + } + } + } + } + // Fall back to extracting the composite literal type itself + if ident, ok := e.Type.(*ast.Ident); ok { + return ident.Name + } + if sel, ok := e.Type.(*ast.SelectorExpr); ok { + if x, ok := sel.X.(*ast.Ident); ok { + return x.Name + "." + sel.Sel.Name + } + } + case *ast.CallExpr: + // gin.H or similar + if sel, ok := e.Fun.(*ast.SelectorExpr); ok { + if sel.Sel.Name == "H" { + return ginHType + } + } + // Could be a function call that returns a type, try to extract from return type + if ident, ok := e.Fun.(*ast.Ident); ok { + if actualType, ok := varTypeMap[ident.Name]; ok { + return actualType + } + } + case *ast.UnaryExpr: + // &variable -> dereference and get the type + if e.Op == token.AND { + return extractTypeFromResponse(e.X, varTypeMap) + } + } + return "" +} diff --git a/internal/extractor/gin/handler_analyzer_more_test.go b/internal/extractor/gin/handler_analyzer_more_test.go new file mode 100644 index 0000000..fd68018 --- /dev/null +++ b/internal/extractor/gin/handler_analyzer_more_test.go @@ -0,0 +1,229 @@ +package gin + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" +) + +func TestHandlerAnalyzer_XMLBinding(t *testing.T) { + src := `package main + +import "github.com/gin-gonic/gin" + +type User struct { + Name string +} + +func createUser(c *gin.Context) { + var user User + if err := c.ShouldBindXML(&user); err != nil { + c.XML(400, gin.H{"error": err.Error()}) + return + } + c.XML(201, user) +} +` + fset := token.NewFileSet() + file, _ := parser.ParseFile(fset, "test.go", src, 0) + files := map[string]*ast.File{"test.go": file} + analyzer := NewHandlerAnalyzer(fset, files) + + var handlerDecl *ast.FuncDecl + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == "createUser" { + handlerDecl = fn + break + } + } + + info, _ := analyzer.AnalyzeHandler(handlerDecl) + + if info.BodyType != "User" { + t.Errorf("expected BodyType 'User', got '%s'", info.BodyType) + } + + // Check responses - should have XML responses + found201 := false + found400 := false + for _, resp := range info.Responses { + if resp.StatusCode == 201 { + found201 = true + } + if resp.StatusCode == 400 { + found400 = true + } + } + if !found201 { + t.Error("expected 201 response") + } + if !found400 { + t.Error("expected 400 response") + } +} + +func TestHandlerAnalyzer_QueryBinding(t *testing.T) { + src := `package main + +import "github.com/gin-gonic/gin" + +type ListRequest struct { + Page int + Size int +} + +func listUsers(c *gin.Context) { + var req ListRequest + if err := c.ShouldBindQuery(&req); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + c.JSON(200, req) +} +` + fset := token.NewFileSet() + file, _ := parser.ParseFile(fset, "test.go", src, 0) + files := map[string]*ast.File{"test.go": file} + analyzer := NewHandlerAnalyzer(fset, files) + + var handlerDecl *ast.FuncDecl + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == "listUsers" { + handlerDecl = fn + break + } + } + + info, _ := analyzer.AnalyzeHandler(handlerDecl) + + if info.BodyType != "ListRequest" { + t.Errorf("expected BodyType 'ListRequest', got '%s'", info.BodyType) + } +} + +func TestHandlerAnalyzer_FormData(t *testing.T) { + src := `package main + +import "github.com/gin-gonic/gin" + +func uploadFile(c *gin.Context) { + file, _ := c.FormFile("file") + userID := c.PostForm("userId") + _ = file + _ = userID + c.JSON(200, gin.H{"message": "uploaded"}) +} +` + fset := token.NewFileSet() + file, _ := parser.ParseFile(fset, "test.go", src, 0) + files := map[string]*ast.File{"test.go": file} + analyzer := NewHandlerAnalyzer(fset, files) + + var handlerDecl *ast.FuncDecl + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == "uploadFile" { + handlerDecl = fn + break + } + } + + info, _ := analyzer.AnalyzeHandler(handlerDecl) + + // Check for file parameter in FileParams + foundFile := false + for _, param := range info.FileParams { + if param.Name == "file" { + foundFile = true + if param.GoType != "file" { + t.Errorf("expected file type for 'file', got '%s'", param.GoType) + } + } + } + if !foundFile { + t.Error("expected 'file' parameter in FileParams") + } + + // Check for form parameter in FormParams + foundUserID := false + for _, param := range info.FormParams { + if param.Name == "userId" { + foundUserID = true + } + } + if !foundUserID { + t.Error("expected 'userId' parameter in FormParams") + } +} + +func TestHandlerAnalyzer_StringResponse(t *testing.T) { + src := `package main + +import "github.com/gin-gonic/gin" + +func hello(c *gin.Context) { + c.String(200, "Hello World") +} +` + fset := token.NewFileSet() + file, _ := parser.ParseFile(fset, "test.go", src, 0) + files := map[string]*ast.File{"test.go": file} + analyzer := NewHandlerAnalyzer(fset, files) + + var handlerDecl *ast.FuncDecl + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == "hello" { + handlerDecl = fn + break + } + } + + info, _ := analyzer.AnalyzeHandler(handlerDecl) + + // Check for string response + found200 := false + for _, resp := range info.Responses { + if resp.StatusCode == 200 && resp.GoType == "string" { + found200 = true + } + } + if !found200 { + t.Error("expected 200 response with string type") + } +} + +func TestHandlerAnalyzer_Redirect(t *testing.T) { + src := `package main + +import "github.com/gin-gonic/gin" + +func redirect(c *gin.Context) { + c.Redirect(302, "/new-location") +} +` + fset := token.NewFileSet() + file, _ := parser.ParseFile(fset, "test.go", src, 0) + files := map[string]*ast.File{"test.go": file} + analyzer := NewHandlerAnalyzer(fset, files) + + var handlerDecl *ast.FuncDecl + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == "redirect" { + handlerDecl = fn + break + } + } + + info, _ := analyzer.AnalyzeHandler(handlerDecl) + + // Check for redirect response + found302 := false + for _, resp := range info.Responses { + if resp.StatusCode == 302 { + found302 = true + } + } + if !found302 { + t.Error("expected 302 redirect response") + } +} diff --git a/internal/extractor/gin/handler_analyzer_test.go b/internal/extractor/gin/handler_analyzer_test.go new file mode 100644 index 0000000..a9f41b1 --- /dev/null +++ b/internal/extractor/gin/handler_analyzer_test.go @@ -0,0 +1,216 @@ +package gin + +import ( + "go/ast" + "go/parser" + "go/token" + "testing" +) + +func TestNewHandlerAnalyzer(t *testing.T) { + a := NewHandlerAnalyzer(nil, nil) + if a == nil { + t.Error("expected non-nil analyzer") + } +} + +func TestHandlerAnalyzer_AnalyzeHandler(t *testing.T) { + // Create a simple handler function + src := `package main + +import "github.com/gin-gonic/gin" + +type User struct { + ID int + Name string +} + +func getUser(c *gin.Context) { + id := c.Param("id") + _ = id + c.JSON(200, User{ID: 1, Name: "test"}) +} +` + fset := token.NewFileSet() + file, err := parser.ParseFile(fset, "test.go", src, 0) + if err != nil { + t.Fatalf("failed to parse: %v", err) + } + + files := map[string]*ast.File{"test.go": file} + analyzer := NewHandlerAnalyzer(fset, files) + + // Find the getUser function + var handlerDecl *ast.FuncDecl + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == "getUser" { + handlerDecl = fn + break + } + } + + if handlerDecl == nil { + t.Fatal("getUser function not found") + } + + info, err := analyzer.AnalyzeHandler(handlerDecl) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Check path params + if len(info.PathParams) != 1 { + t.Errorf("expected 1 path param, got %d", len(info.PathParams)) + } + if len(info.PathParams) > 0 && info.PathParams[0].Name != "id" { + t.Errorf("expected param 'id', got %s", info.PathParams[0].Name) + } + + // Check responses + if len(info.Responses) != 1 { + t.Errorf("expected 1 response, got %d", len(info.Responses)) + } +} + +func TestHandlerAnalyzer_AnalyzeHandler_WithQueryParams(t *testing.T) { + src := `package main + +import "github.com/gin-gonic/gin" + +func listUsers(c *gin.Context) { + page := c.Query("page") + size := c.DefaultQuery("size", "10") + _ = page + _ = size + c.JSON(200, nil) +} +` + fset := token.NewFileSet() + file, _ := parser.ParseFile(fset, "test.go", src, 0) + files := map[string]*ast.File{"test.go": file} + analyzer := NewHandlerAnalyzer(fset, files) + + var handlerDecl *ast.FuncDecl + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == "listUsers" { + handlerDecl = fn + break + } + } + + info, _ := analyzer.AnalyzeHandler(handlerDecl) + + if len(info.QueryParams) != 2 { + t.Errorf("expected 2 query params, got %d", len(info.QueryParams)) + } +} + +func TestHandlerAnalyzer_AnalyzeHandler_WithBodyBinding(t *testing.T) { + src := `package main + +import "github.com/gin-gonic/gin" + +type CreateUserRequest struct { + Name string ` + "`" + `json:"name"` + "`" + ` +} + +func createUser(c *gin.Context) { + var req CreateUserRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(400, gin.H{"error": err.Error()}) + return + } + c.JSON(201, req) +} +` + fset := token.NewFileSet() + file, _ := parser.ParseFile(fset, "test.go", src, 0) + files := map[string]*ast.File{"test.go": file} + analyzer := NewHandlerAnalyzer(fset, files) + + var handlerDecl *ast.FuncDecl + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Name.Name == "createUser" { + handlerDecl = fn + break + } + } + + info, _ := analyzer.AnalyzeHandler(handlerDecl) + + if info.BodyType != "CreateUserRequest" { + t.Errorf("expected BodyType 'CreateUserRequest', got %s", info.BodyType) + } + + // Check that we have both error and success responses + if len(info.Responses) != 2 { + t.Errorf("expected 2 responses (400 and 201), got %d", len(info.Responses)) + } +} + +func TestExtractStatusCode(t *testing.T) { + tests := []struct { + name string + code string + expected int + }{ + {"200", "200", 200}, + {"201", "201", 201}, + {"400", "400", 400}, + {"404", "404", 404}, + {"500", "500", 500}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a simple expression with the status code + src := `package main +func test() { _ = ` + tt.code + ` }` + fset := token.NewFileSet() + file, _ := parser.ParseFile(fset, "test.go", src, 0) + + // Extract the basic lit from the assignment + var lit *ast.BasicLit + ast.Inspect(file, func(n ast.Node) bool { + if l, ok := n.(*ast.BasicLit); ok { + lit = l + return false + } + return true + }) + + if lit == nil { + t.Fatal("failed to find basic lit") + } + + result := extractStatusCode(lit) + if result != tt.expected { + t.Errorf("extractStatusCode() = %d, expected %d", result, tt.expected) + } + }) + } +} + +func TestStatusCodeFromName(t *testing.T) { + tests := []struct { + name string + expected int + }{ + {"StatusOK", 200}, + {"StatusCreated", 201}, + {"StatusNoContent", 204}, + {"StatusBadRequest", 400}, + {"StatusNotFound", 404}, + {"StatusInternalServerError", 500}, + {"UnknownStatus", 200}, // default + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := statusCodeFromName(tt.name) + if result != tt.expected { + t.Errorf("statusCodeFromName(%q) = %d, expected %d", tt.name, result, tt.expected) + } + }) + } +} diff --git a/internal/extractor/gin/patcher.go b/internal/extractor/gin/patcher.go new file mode 100644 index 0000000..e170713 --- /dev/null +++ b/internal/extractor/gin/patcher.go @@ -0,0 +1,29 @@ +package gin + +import ( + "context" + "log/slog" + + "github.com/spencercjh/spec-forge/internal/extractor" +) + +// Patcher is a no-op for Gin projects (no patching needed). +type Patcher struct{} + +// NewPatcher creates a new Patcher instance. +func NewPatcher() *Patcher { + return &Patcher{} +} + +// Patch performs no-op patching for Gin projects. +func (p *Patcher) Patch(ctx context.Context, _ string, info *extractor.ProjectInfo, _ *extractor.PatchOptions) (*extractor.PatchResult, error) { + slog.DebugContext(ctx, "Patching Gin project (no-op)") + + // Gin projects don't need patching, just mark as ready + if ginInfo, ok := info.FrameworkData.(*Info); ok { + ginInfo.HasGin = true + } + + slog.InfoContext(ctx, "Gin project patched successfully (no changes needed)") + return &extractor.PatchResult{}, nil +} diff --git a/internal/extractor/gin/patcher_test.go b/internal/extractor/gin/patcher_test.go new file mode 100644 index 0000000..77ed08c --- /dev/null +++ b/internal/extractor/gin/patcher_test.go @@ -0,0 +1,53 @@ +package gin + +import ( + "context" + "testing" + + "github.com/spencercjh/spec-forge/internal/extractor" +) + +func TestNewPatcher(t *testing.T) { + p := NewPatcher() + if p == nil { + t.Error("expected non-nil patcher") + } +} + +func TestPatcher_Patch(t *testing.T) { + p := NewPatcher() + ctx := context.Background() + info := &extractor.ProjectInfo{} + opts := &extractor.PatchOptions{} + + result, err := p.Patch(ctx, "/tmp", info, opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Patcher should return empty result for Gin + if result == nil { + t.Error("expected non-nil result") + } +} + +func TestPatcher_Patch_SetsFrameworkData(t *testing.T) { + p := NewPatcher() + ctx := context.Background() + ginInfo := &Info{HasGin: false} + info := &extractor.ProjectInfo{ + Framework: ExtractorName, + FrameworkData: ginInfo, + } + opts := &extractor.PatchOptions{} + + _, err := p.Patch(ctx, "/tmp", info, opts) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Gin projects don't need patching, just mark as ready + if !ginInfo.HasGin { + t.Error("expected HasGin to be true after patch") + } +} diff --git a/internal/extractor/gin/schema_extractor.go b/internal/extractor/gin/schema_extractor.go new file mode 100644 index 0000000..6e0e880 --- /dev/null +++ b/internal/extractor/gin/schema_extractor.go @@ -0,0 +1,341 @@ +package gin + +import ( + "fmt" + "go/ast" + "go/token" + "log/slog" + "slices" + "strconv" + "strings" + + "github.com/getkin/kin-openapi/openapi3" +) + +// Validation rule constants for goconst compliance +const ( + validateRuleEmail = "email" + validateRuleURL = "url" + validateRuleUUID = "uuid" +) + +// SchemaExtractor extracts OpenAPI schemas from Go structs. +type SchemaExtractor struct { + files map[string]*ast.File + typeCache map[string]*openapi3.SchemaRef +} + +// NewSchemaExtractor creates a new SchemaExtractor instance. +func NewSchemaExtractor(files map[string]*ast.File) *SchemaExtractor { + return &SchemaExtractor{ + files: files, + typeCache: make(map[string]*openapi3.SchemaRef), + } +} + +// ExtractSchema extracts an OpenAPI schema from a Go type. +func (e *SchemaExtractor) ExtractSchema(typeName string) (*openapi3.SchemaRef, error) { + slog.Debug("Extracting schema", "type", typeName) + + // Check cache + if cached, ok := e.typeCache[typeName]; ok { + slog.Debug("Using cached schema", "type", typeName) + return cached, nil + } + + // Find the type definition + typeSpec := e.findTypeSpec(typeName) + if typeSpec == nil { + slog.Warn("Type not found", "type", typeName) + return nil, fmt.Errorf("type %s not found", typeName) + } + + // Extract schema from struct + schema, err := e.extractStructSchema(typeSpec) + if err != nil { + slog.Error("Failed to extract struct schema", "type", typeName, "error", err) + return nil, err + } + + ref := &openapi3.SchemaRef{Value: schema} + e.typeCache[typeName] = ref + slog.Debug("Extracted schema", "type", typeName, "properties", len(schema.Properties)) + return ref, nil +} + +// findTypeSpec finds a type definition by name. +func (e *SchemaExtractor) findTypeSpec(name string) *ast.TypeSpec { + for _, file := range e.files { + for _, decl := range file.Decls { + genDecl, ok := decl.(*ast.GenDecl) + if !ok || genDecl.Tok != token.TYPE { + continue + } + for _, spec := range genDecl.Specs { + typeSpec, ok := spec.(*ast.TypeSpec) + if ok && typeSpec.Name.Name == name { + return typeSpec + } + } + } + } + return nil +} + +// extractStructSchema extracts schema from a struct type. +func (e *SchemaExtractor) extractStructSchema(typeSpec *ast.TypeSpec) (*openapi3.Schema, error) { + structType, ok := typeSpec.Type.(*ast.StructType) + if !ok { + return nil, fmt.Errorf("type %s is not a struct", typeSpec.Name.Name) + } + + schema := &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + Properties: make(openapi3.Schemas), + } + + for _, field := range structType.Fields.List { + if len(field.Names) == 0 { + continue // Embedded field - skip for now + } + + fieldName := field.Names[0].Name + propertyName := fieldName + fieldSchemaRef := e.fieldToSchemaRef(field.Type) + + // Parse struct tags - apply regardless of whether field is inline or $ref + if field.Tag != nil { + tag := strings.Trim(field.Tag.Value, "`") + propertyName = e.applyTags(fieldSchemaRef, tag, fieldName, schema) + } + + // Skip fields with json:"-" + if propertyName == "" { + continue + } + + schema.Properties[propertyName] = fieldSchemaRef + } + + return schema, nil +} + +// fieldToSchemaRef converts a Go type to OpenAPI schema reference. +// It returns a $ref for custom types and inline schemas for primitive types. +func (e *SchemaExtractor) fieldToSchemaRef(expr ast.Expr) *openapi3.SchemaRef { + switch t := expr.(type) { + case *ast.Ident: + // Check if it's a basic type first + schema := goTypeToSchema(t.Name) + if schema.Type != nil && (*schema.Type)[0] != "object" { + return &openapi3.SchemaRef{Value: schema} + } + // It's a custom type - create a reference + if e.findTypeSpec(t.Name) != nil { + ref := "#/components/schemas/" + t.Name + return &openapi3.SchemaRef{Ref: ref} + } + return &openapi3.SchemaRef{Value: schema} + case *ast.StarExpr: + // Pointer - unwrap and process underlying type + return e.fieldToSchemaRef(t.X) + case *ast.ArrayType: + itemSchemaRef := e.fieldToSchemaRef(t.Elt) + return &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Items: itemSchemaRef, + }, + } + case *ast.MapType: + valueSchemaRef := e.fieldToSchemaRef(t.Value) + return &openapi3.SchemaRef{ + Value: &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + AdditionalProperties: openapi3.AdditionalProperties{Schema: valueSchemaRef}, + }, + } + case *ast.SelectorExpr: + // Package qualified type (e.g., time.Time) + if x, ok := t.X.(*ast.Ident); ok { + fullName := x.Name + "." + t.Sel.Name + return &openapi3.SchemaRef{Value: goTypeToSchema(fullName)} + } + } + + return &openapi3.SchemaRef{Value: &openapi3.Schema{Type: &openapi3.Types{"object"}}} +} + +// fieldToSchema converts a Go type to OpenAPI schema. +func (e *SchemaExtractor) fieldToSchema(expr ast.Expr) *openapi3.Schema { + switch t := expr.(type) { + case *ast.Ident: + // Check if it's a basic type first + schema := goTypeToSchema(t.Name) + if schema.Type != nil && (*schema.Type)[0] != "object" { + return schema + } + // It's a custom type - check if we know about it + if e.findTypeSpec(t.Name) != nil { + // Return a placeholder schema with type object + // The reference will be created by the caller if needed + return &openapi3.Schema{Type: &openapi3.Types{"object"}} + } + return schema + case *ast.StarExpr: + // Pointer - unwrap and process underlying type + return e.fieldToSchema(t.X) + case *ast.ArrayType: + itemSchema := e.fieldToSchema(t.Elt) + return &openapi3.Schema{ + Type: &openapi3.Types{"array"}, + Items: &openapi3.SchemaRef{Value: itemSchema}, + } + case *ast.MapType: + valueSchema := e.fieldToSchema(t.Value) + return &openapi3.Schema{ + Type: &openapi3.Types{"object"}, + AdditionalProperties: openapi3.AdditionalProperties{Schema: &openapi3.SchemaRef{Value: valueSchema}}, + } + case *ast.SelectorExpr: + // Package qualified type (e.g., time.Time) + if x, ok := t.X.(*ast.Ident); ok { + fullName := x.Name + "." + t.Sel.Name + return goTypeToSchema(fullName) + } + } + + return &openapi3.Schema{Type: &openapi3.Types{"object"}} +} + +// goTypeToSchema converts a Go type name to OpenAPI schema. +func goTypeToSchema(goType string) *openapi3.Schema { + switch goType { + case "string": + return &openapi3.Schema{Type: &openapi3.Types{"string"}} + case "int", "int32": + return &openapi3.Schema{Type: &openapi3.Types{"integer"}, Format: "int32"} + case "int64": + return &openapi3.Schema{Type: &openapi3.Types{"integer"}, Format: "int64"} + case "uint", "uint32": + return &openapi3.Schema{Type: &openapi3.Types{"integer"}} + case "float32": + return &openapi3.Schema{Type: &openapi3.Types{"number"}, Format: "float"} + case "float64": + return &openapi3.Schema{Type: &openapi3.Types{"number"}, Format: "double"} + case "bool": + return &openapi3.Schema{Type: &openapi3.Types{"boolean"}} + case "time.Time": + return &openapi3.Schema{Type: &openapi3.Types{"string"}, Format: "date-time"} + default: + return &openapi3.Schema{Type: &openapi3.Types{"object"}} + } +} + +// applyTags processes struct tags and updates schema. +// Returns the final property name (may be different from fieldName due to json tag). +// Returns empty string if the field should be skipped (json:"-"). +func (e *SchemaExtractor) applyTags(schemaRef *openapi3.SchemaRef, tag, fieldName string, parentSchema *openapi3.Schema) string { + propertyName := fieldName + isOmitEmpty := false + + // Parse json tag + if jsonTag := extractTagValue(tag, "json"); jsonTag != "" { + parts := strings.Split(jsonTag, ",") + // Handle json:"-" - skip field entirely + if parts[0] == "-" { + return "" + } + if parts[0] != "" { + propertyName = parts[0] + } + // Check for omitempty + if slices.Contains(parts[1:], "omitempty") { + isOmitEmpty = true + } + } + + // Parse binding tag (handle multiple comma-separated rules like "required,email") + if bindingTag := extractTagValue(tag, "binding"); bindingTag != "" { + if strings.Contains(bindingTag, "required") && !isOmitEmpty { + parentSchema.Required = append(parentSchema.Required, propertyName) + } + } + + // Parse validate tag - always apply validation even with omitempty + if validateTag := extractTagValue(tag, "validate"); validateTag != "" { + // Only apply validation if we have a non-nil schema value + if schemaRef.Value != nil { + e.applyValidation(schemaRef.Value, validateTag, propertyName, parentSchema) + } + } + + return propertyName +} + +// extractTagValue extracts a specific tag value. +func extractTagValue(tag, key string) string { + prefix := key + ":\"" + start := strings.Index(tag, prefix) + if start == -1 { + return "" + } + start += len(prefix) + end := strings.Index(tag[start:], "\"") + if end == -1 { + return "" + } + return tag[start : start+end] +} + +// applyValidation applies validation rules to schema. +func (e *SchemaExtractor) applyValidation(schema *openapi3.Schema, validateTag, fieldName string, parentSchema *openapi3.Schema) { + for rule := range strings.SplitSeq(validateTag, ",") { + if rule == "required" { + parentSchema.Required = append(parentSchema.Required, fieldName) + continue + } + + // Parse min/max for numeric types + if valStr, ok := strings.CutPrefix(rule, "min="); ok { + if val, err := strconv.ParseFloat(valStr, 64); err == nil { + schema.Min = &val + } + } + if valStr, ok := strings.CutPrefix(rule, "max="); ok { + if val, err := strconv.ParseFloat(valStr, 64); err == nil { + schema.Max = &val + } + } + + // Parse minLength/maxLength for strings + if valStr, ok := strings.CutPrefix(rule, "minLength="); ok { + if val, err := strconv.ParseUint(valStr, 10, 64); err == nil { + schema.MinLength = val + } + } + if valStr, ok := strings.CutPrefix(rule, "maxLength="); ok { + if val, err := strconv.ParseUint(valStr, 10, 64); err == nil { + ui := uint64(val) + schema.MaxLength = &ui + } + } + + // Parse format validators + switch rule { + case validateRuleEmail: + schema.Format = validateRuleEmail + case validateRuleURL: + schema.Format = "uri" + case validateRuleUUID: + schema.Format = "uuid" + } + + // Parse oneof enum + if valStr, ok := strings.CutPrefix(rule, "oneof="); ok { + for v := range strings.SplitSeq(valStr, " ") { + schema.Enum = append(schema.Enum, v) + } + } + } +} diff --git a/internal/extractor/gin/schema_extractor_test.go b/internal/extractor/gin/schema_extractor_test.go new file mode 100644 index 0000000..7e4158f --- /dev/null +++ b/internal/extractor/gin/schema_extractor_test.go @@ -0,0 +1,261 @@ +package gin + +import ( + "go/ast" + "go/parser" + "go/token" + "slices" + "testing" +) + +func TestNewSchemaExtractor(t *testing.T) { + e := NewSchemaExtractor(nil) + if e == nil { + t.Error("expected non-nil extractor") + } +} + +func TestSchemaExtractor_ExtractSchema(t *testing.T) { + src := `package main + +type User struct { + ID int ` + "`" + `json:"id" binding:"required"` + "`" + ` + Name string ` + "`" + `json:"name"` + "`" + ` + Email string ` + "`" + `json:"email" validate:"email"` + "`" + ` + Age int ` + "`" + `json:"age,omitempty" validate:"min=0,max=150"` + "`" + ` +} +` + fset := token.NewFileSet() + file, _ := parser.ParseFile(fset, "test.go", src, 0) + files := map[string]*ast.File{"test.go": file} + + extractor := NewSchemaExtractor(files) + schema, err := extractor.ExtractSchema("User") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if schema == nil { + t.Fatal("expected non-nil schema") + } + + // Check required fields + required := schema.Value.Required + if len(required) != 1 || required[0] != "id" { + t.Errorf("expected required=['id'], got %v", required) + } + + // Check properties + props := schema.Value.Properties + if len(props) != 4 { + t.Errorf("expected 4 properties, got %d", len(props)) + } + + // Check email format + if emailProp := props["email"]; emailProp != nil { + if emailProp.Value.Format != "email" { + t.Errorf("expected email format, got %s", emailProp.Value.Format) + } + } +} + +func TestGoTypeToSchema(t *testing.T) { + tests := []struct { + goType string + expectedType string + expectedFmt string + }{ + {"string", "string", ""}, + {"int", "integer", "int32"}, + {"int32", "integer", "int32"}, + {"int64", "integer", "int64"}, + {"uint", "integer", ""}, + {"float32", "number", "float"}, + {"float64", "number", "double"}, + {"bool", "boolean", ""}, + {"time.Time", "string", "date-time"}, + {"unknown", "object", ""}, + } + + for _, tt := range tests { + t.Run(tt.goType, func(t *testing.T) { + schema := goTypeToSchema(tt.goType) + if schema.Type == nil || len(*schema.Type) == 0 || (*schema.Type)[0] != tt.expectedType { + t.Errorf("expected type %s, got %v", tt.expectedType, schema.Type) + } + if schema.Format != tt.expectedFmt { + t.Errorf("expected format %s, got %s", tt.expectedFmt, schema.Format) + } + }) + } +} + +func TestExtractTagValue(t *testing.T) { + tests := []struct { + tag string + key string + expected string + }{ + {`json:"name"`, "json", "name"}, + {`json:"name,omitempty"`, "json", "name,omitempty"}, + {`binding:"required"`, "binding", "required"}, + {`validate:"email"`, "validate", "email"}, + {`json:"name" binding:"required"`, "binding", "required"}, + {`json:"name"`, "xml", ""}, + {``, "json", ""}, + } + + for _, tt := range tests { + t.Run(tt.key+"_"+tt.expected, func(t *testing.T) { + result := extractTagValue(tt.tag, tt.key) + if result != tt.expected { + t.Errorf("extractTagValue(%q, %q) = %q, expected %q", tt.tag, tt.key, result, tt.expected) + } + }) + } +} + +func TestSchemaExtractor_Caching(t *testing.T) { + src := `package main + +type User struct { + ID int ` + "`" + `json:"id"` + "`" + ` +} +` + fset := token.NewFileSet() + file, _ := parser.ParseFile(fset, "test.go", src, 0) + files := map[string]*ast.File{"test.go": file} + + extractor := NewSchemaExtractor(files) + + // First extraction + schema1, err := extractor.ExtractSchema("User") + if err != nil { + t.Fatalf("first extraction failed: %v", err) + } + + // Second extraction should return cached + schema2, err := extractor.ExtractSchema("User") + if err != nil { + t.Fatalf("second extraction failed: %v", err) + } + + // Should be the same pointer (cached) + if schema1 != schema2 { + t.Error("expected cached schema to be same pointer") + } +} + +func TestSchemaExtractor_ArrayType(t *testing.T) { + src := `package main + +type ListResponse struct { + Items []string ` + "`" + `json:"items"` + "`" + ` + Count int ` + "`" + `json:"count"` + "`" + ` +} +` + fset := token.NewFileSet() + file, _ := parser.ParseFile(fset, "test.go", src, 0) + files := map[string]*ast.File{"test.go": file} + + extractor := NewSchemaExtractor(files) + schema, err := extractor.ExtractSchema("ListResponse") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Check Items is an array + itemsProp := schema.Value.Properties["items"] + if itemsProp == nil { + t.Fatal("expected 'items' property") + } + if itemsProp.Value.Type == nil || len(*itemsProp.Value.Type) == 0 || (*itemsProp.Value.Type)[0] != "array" { + t.Errorf("expected array type, got %s", itemsProp.Value.Type) + } +} + +func TestSchemaExtractor_WithValidation(t *testing.T) { + src := `package main + +type ValidatedUser struct { + Name string ` + "`" + `json:"name" validate:"minLength=2,maxLength=50"` + "`" + ` + Email string ` + "`" + `json:"email" validate:"email,required"` + "`" + ` + Role string ` + "`" + `json:"role" validate:"oneof=admin user guest"` + "`" + ` +} +` + fset := token.NewFileSet() + file, _ := parser.ParseFile(fset, "test.go", src, 0) + files := map[string]*ast.File{"test.go": file} + + extractor := NewSchemaExtractor(files) + schema, err := extractor.ExtractSchema("ValidatedUser") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Check required fields (both name and email should be required) + if !slices.Contains(schema.Value.Required, "email") { + t.Error("expected 'email' to be in required list") + } + + // Check enum for role + roleProp := schema.Value.Properties["role"] + if roleProp != nil && len(roleProp.Value.Enum) > 0 { + // Check if enum contains expected values + hasAdmin := false + for _, v := range roleProp.Value.Enum { + if v == "admin" { + hasAdmin = true + break + } + } + if !hasAdmin { + t.Error("expected 'admin' in role enum") + } + } +} + +func TestFieldToSchema(t *testing.T) { + e := NewSchemaExtractor(nil) + + tests := []struct { + name string + src string + expected string + }{ + {"string", `type T struct { F string }`, "string"}, + {"int", `type T struct { F int }`, "integer"}, + {"bool", `type T struct { F bool }`, "boolean"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fset := token.NewFileSet() + file, _ := parser.ParseFile(fset, "test.go", "package main\n"+tt.src, 0) + + // Get the struct type + var structType *ast.StructType + for _, decl := range file.Decls { + if gen, ok := decl.(*ast.GenDecl); ok { + for _, spec := range gen.Specs { + if ts, ok := spec.(*ast.TypeSpec); ok { + if st, ok := ts.Type.(*ast.StructType); ok { + structType = st + break + } + } + } + } + } + + if structType == nil || len(structType.Fields.List) == 0 { + t.Fatal("failed to find struct field") + } + + schema := e.fieldToSchema(structType.Fields.List[0].Type) + if schema.Type == nil || len(*schema.Type) == 0 || (*schema.Type)[0] != tt.expected { + t.Errorf("expected type %s, got %s", tt.expected, schema.Type) + } + }) + } +} diff --git a/internal/extractor/gin/types.go b/internal/extractor/gin/types.go new file mode 100644 index 0000000..18ac1f3 --- /dev/null +++ b/internal/extractor/gin/types.go @@ -0,0 +1,74 @@ +// internal/extractor/gin/types.go +package gin + +// Info contains information about a Gin project. +type Info struct { + GoVersion string // Go version from go.mod + ModuleName string // Module path + GinVersion string // gin dependency version + HasGin bool // Has gin dependency + MainFiles []string // main.go or files with route registration + HandlerFiles []string // Handler file list + RouterGroups []RouterGroup // Detected router groups +} + +// RouterGroup represents a Gin router group. +type RouterGroup struct { + BasePath string // Group base path (e.g., "/api/v1") + Routes []Route // Routes in this group +} + +// Route represents a single Gin route. +type Route struct { + Method string // HTTP method: GET, POST, PUT, DELETE, PATCH + Path string // Route path (e.g., "/users/:id") + FullPath string // Full path including group prefix + HandlerName string // Handler function name + HandlerFile string // File containing handler definition + Middlewares []string // Middleware names +} + +// BindingSource indicates the source of request binding. +type BindingSource string + +const ( + BindingSourceJSON BindingSource = "json" // application/json + BindingSourceXML BindingSource = "xml" // application/xml + BindingSourceYAML BindingSource = "yaml" // application/x-yaml + BindingSourceQuery BindingSource = "query" // query parameters + BindingSourceForm BindingSource = "form" // application/x-www-form-urlencoded + BindingSourceMultipart BindingSource = "multipart" // multipart/form-data +) + +// HandlerInfo contains information extracted from a handler function. +type HandlerInfo struct { + PathParams []ParamInfo // Path parameters (c.Param) + QueryParams []ParamInfo // Query parameters (c.Query) + HeaderParams []ParamInfo // Header parameters (c.GetHeader) + FormParams []ParamInfo // Form parameters (c.PostForm) + FileParams []ParamInfo // File upload parameters (c.FormFile) + BodyType string // Request body type (from ShouldBindJSON) + BindingSrc BindingSource // Source of binding (json, xml, query, form, etc.) + Responses []ResponseInfo // Response info (from c.JSON calls) +} + +// ParamInfo represents a parameter extracted from handler. +type ParamInfo struct { + Name string // Parameter name + GoType string // Go type name + Required bool // Whether parameter is required +} + +// ResponseInfo represents a response from handler analysis. +type ResponseInfo struct { + StatusCode int // HTTP status code + GoType string // Response Go type +} + +// HandlerRef represents a reference to a handler function. +type HandlerRef struct { + Name string // Function name (empty for anonymous functions) + File string // File path + IsAnonymous bool // Whether it's an anonymous function + ReceiverType string // Receiver type for methods (empty for functions) +} diff --git a/internal/extractor/gin/types_test.go b/internal/extractor/gin/types_test.go new file mode 100644 index 0000000..68ea572 --- /dev/null +++ b/internal/extractor/gin/types_test.go @@ -0,0 +1,106 @@ +package gin + +import "testing" + +func TestInfo(t *testing.T) { + info := Info{ + GoVersion: "1.21", + ModuleName: "github.com/example/app", + GinVersion: "v1.9.1", + HasGin: true, + } + + if info.GoVersion != "1.21" { + t.Errorf("expected GoVersion '1.21', got %s", info.GoVersion) + } + if info.ModuleName != "github.com/example/app" { + t.Errorf("expected ModuleName 'github.com/example/app', got %s", info.ModuleName) + } +} + +func TestRouterGroup(t *testing.T) { + rg := RouterGroup{ + BasePath: "/api/v1", + Routes: []Route{ + {Method: "GET", Path: "/users"}, + {Method: "POST", Path: "/users"}, + }, + } + + if rg.BasePath != "/api/v1" { + t.Errorf("expected BasePath '/api/v1', got %s", rg.BasePath) + } + if len(rg.Routes) != 2 { + t.Errorf("expected 2 routes, got %d", len(rg.Routes)) + } +} + +func TestRoute(t *testing.T) { + route := Route{ + Method: "GET", + Path: "/users/:id", + FullPath: "/api/v1/users/:id", + HandlerName: "GetUser", + HandlerFile: "user_handler.go", + Middlewares: []string{"Auth"}, + } + + if route.Method != "GET" { + t.Errorf("expected Method 'GET', got %s", route.Method) + } + if route.FullPath != "/api/v1/users/:id" { + t.Errorf("expected FullPath '/api/v1/users/:id', got %s", route.FullPath) + } +} + +func TestHandlerInfo(t *testing.T) { + hi := HandlerInfo{ + PathParams: []ParamInfo{ + {Name: "id", GoType: "string", Required: true}, + }, + QueryParams: []ParamInfo{ + {Name: "page", GoType: "string", Required: false}, + }, + BodyType: "CreateUserRequest", + Responses: []ResponseInfo{ + {StatusCode: 200, GoType: "User"}, + {StatusCode: 404, GoType: "ErrorResponse"}, + }, + } + + if len(hi.PathParams) != 1 { + t.Errorf("expected 1 path param, got %d", len(hi.PathParams)) + } + if len(hi.Responses) != 2 { + t.Errorf("expected 2 responses, got %d", len(hi.Responses)) + } +} + +func TestParamInfo(t *testing.T) { + param := ParamInfo{ + Name: "id", + GoType: "string", + Required: true, + } + + if param.Name != "id" { + t.Errorf("expected Name 'id', got %s", param.Name) + } + if !param.Required { + t.Error("expected Required to be true") + } +} + +func TestResponseInfo(t *testing.T) { + resp := ResponseInfo{ + StatusCode: 200, + GoType: "User", + } + + if resp.StatusCode != 200 { + t.Errorf("expected StatusCode 200, got %d", resp.StatusCode) + } + if resp.GoType != "User" { + t.Errorf("expected GoType 'User', got %s", resp.GoType) + } +}