Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions cmd/config_manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,26 @@ func (cm *ConfigManager) SaveConfig(filePath string) error {
return nil
}

// defaultPortForDatabase returns the conventional default port for a database
// type, so the generated .goca.yaml records a port that matches the chosen
// database instead of always recording Postgres' 5432.
func defaultPortForDatabase(database string) int {
switch database {
case "mysql":
return 3306
case "mongodb":
return 27017
case "sqlserver":
return 1433
case "sqlite", "dynamodb":
return 0
case "elasticsearch":
return 9200
default: // postgres, postgres-json and anything else
return 5432
}
}

// GenerateDefaultConfig generates a default .goca.yaml file.
func (cm *ConfigManager) GenerateDefaultConfig(projectPath, projectName, module, database string) error {
// Create default config with provided parameters
Expand All @@ -574,6 +594,7 @@ func (cm *ConfigManager) GenerateDefaultConfig(projectPath, projectName, module,
}
if database != "" {
config.Database.Type = database
config.Database.Port = defaultPortForDatabase(database)
}

cm.config = config
Expand Down
15 changes: 10 additions & 5 deletions cmd/feature.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,21 +110,24 @@ including domain, use cases, repository and handlers in a single operation.`,
fileNamingConvention = configIntegration.GetNamingConvention("file")
}

generateCompleteFeature(featureName, fields, effectiveDatabase, effectiveHandlers, effectiveValidation, effectiveBusinessRules, cacheFlag, fileNamingConvention, safetyMgr)

// Generate middleware package if requested
// Generate the middleware package FIRST (when requested) so the HTTP
// routes generated below detect it and wire middleware.CORS/Logging from
// the package instead of emitting inline duplicates that leave the
// package orphaned.
if middlewareTypesStr != "" {
mwTypes := parseMiddlewareTypes(middlewareTypesStr)
if err := validateMiddlewareTypes(mwTypes); err != nil {
ui.Error(fmt.Sprintf("middleware-types: %v", err))
os.Exit(1)
}
ui.Step(9, "Generating middleware package...")
ui.Step(0, "Generating middleware package...")
if err := generateMiddlewarePackage(featureName, mwTypes, safetyMgr); err != nil {
ui.Warning(fmt.Sprintf("Could not generate middleware: %v", err))
}
}

generateCompleteFeature(featureName, fields, effectiveDatabase, effectiveHandlers, effectiveValidation, effectiveBusinessRules, cacheFlag, fileNamingConvention, safetyMgr)

// Show dry-run summary
if dryRun {
safetyMgr.PrintSummary()
Expand Down Expand Up @@ -257,7 +260,9 @@ func generateCompleteFeature(featureName, fields, database, handlers string, val

// 3. Generate Repository
ui.Step(3, "Generating repository...")
generateRepository(featureName, database, false, true, cache, false, fields, safetyMgr)
// implementation=false so BOTH the interface and the implementation are
// generated (the DI container and use case depend on the interface type).
generateRepository(featureName, database, false, false, cache, false, fields, safetyMgr)

// 4. Generate Handlers
ui.Step(4, "Generating handlers...")
Expand Down
6 changes: 4 additions & 2 deletions cmd/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,8 @@ func generateGetHandlerMethod(content *strings.Builder, entity, handlerName stri
entityLower := strings.ToLower(entity)

if swagger {
writeSwaggerAnnotations(content, entity, fmt.Sprintf("Get %s by ID", entityLower), "get", "/"+entityLower+"s/{id}", "200", fmt.Sprintf("usecase.Get%sOutput", entity), "")
// Get returns the domain entity (there is no Get<Entity>Output DTO).
writeSwaggerAnnotations(content, entity, fmt.Sprintf("Get %s by ID", entityLower), "get", "/"+entityLower+"s/{id}", "200", fmt.Sprintf("domain.%s", entity), "")
}

fmt.Fprintf(content, "func (%s *%s) Get%s(w http.ResponseWriter, r *http.Request) {\n",
Expand Down Expand Up @@ -480,7 +481,8 @@ func generateListHandlerMethod(content *strings.Builder, entity, handlerName str
entityLower := strings.ToLower(entity)

if swagger {
writeSwaggerAnnotations(content, entity, fmt.Sprintf("List %ss", entityLower), "get", "/"+entityLower+"s", "200", fmt.Sprintf("usecase.List%ssOutput", entity), "")
// The use case returns usecase.List<Entity>Output (singular entity name).
writeSwaggerAnnotations(content, entity, fmt.Sprintf("List %ss", entityLower), "get", "/"+entityLower+"s", "200", fmt.Sprintf("usecase.List%sOutput", entity), "")
}

fmt.Fprintf(content, "func (%s *%s) List%ss(w http.ResponseWriter, r *http.Request) {\n",
Expand Down
58 changes: 58 additions & 0 deletions cmd/handler_other.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,64 @@ func generateGRPCHandler(entity, fileNamingConvention string, sm ...*SafetyManag

generateProtoFile(grpcDir, entity, fileNamingConvention, sm...)
generateGRPCServerFile(grpcDir, entity, fileNamingConvention, sm...)
generateGRPCStubPackage(grpcDir, entity, sm...)
}

// generateGRPCStubPackage writes a placeholder protobuf package so a freshly
// generated project resolves (go mod tidy / go build / go vet) without a remote
// module lookup for the not-yet-generated pb package. Like the server, it is
// gated behind the "proto" build tag, so the default build ignores it; building
// with -tags proto compiles the scaffold against these stubs. Once the real
// *.pb.go files are produced with protoc, this file should be deleted.
func generateGRPCStubPackage(grpcDir, entity string, sm ...*SafetyManager) {
entityLower := strings.ToLower(entity)
pkgDir := filepath.Join(grpcDir, entityLower)
_ = os.MkdirAll(pkgDir, 0o755)

fields := grpcEntityFields(entity)

var c strings.Builder
c.WriteString("//go:build proto\n")
c.WriteString("// +build proto\n\n")
fmt.Fprintf(&c, "// Package %s is a PLACEHOLDER for the protobuf-generated code for %s.\n//\n", entityLower, entity)
c.WriteString("// It exists so the gRPC server scaffold compiles (under -tags proto) and so\n")
c.WriteString("// `go mod tidy`/`go vet` resolve the import locally instead of attempting a\n")
c.WriteString("// remote module lookup. Generate the real code with protoc, e.g.:\n//\n")
fmt.Fprintf(&c, "//\tprotoc --go_out=. --go-grpc_out=. internal/handler/grpc/%s.proto\n//\n", entityLower)
c.WriteString("// then DELETE this placeholder file (its types would collide with the\n")
c.WriteString("// generated ones).\n")
fmt.Fprintf(&c, "package %s\n\n", entityLower)

fmt.Fprintf(&c, "type Unimplemented%sServiceServer struct{}\n\n", entity)

fmt.Fprintf(&c, "type %s struct {\n", entity)
c.WriteString("\tId int32\n")
for _, f := range fields {
fmt.Fprintf(&c, "\t%s %s\n", protoGoFieldName(f.Name), f.Type)
}
c.WriteString("}\n\n")

fmt.Fprintf(&c, "type Create%sRequest struct {\n", entity)
for _, f := range fields {
fmt.Fprintf(&c, "\t%s %s\n", protoGoFieldName(f.Name), f.Type)
}
c.WriteString("}\n\n")

fmt.Fprintf(&c, "type Create%sResponse struct {\n", entity)
fmt.Fprintf(&c, "\t%s *%s\n", entity, entity)
c.WriteString("\tMessage string\n")
c.WriteString("}\n\n")

fmt.Fprintf(&c, "type Get%sRequest struct {\n\tId int32\n}\n\n", entity)

fmt.Fprintf(&c, "type %sResponse struct {\n", entity)
fmt.Fprintf(&c, "\t%s *%s\n", entity, entity)
c.WriteString("}\n")

filename := filepath.Join(pkgDir, "placeholder.pb.go")
if err := writeGoFile(filename, c.String(), sm...); err != nil {
ui.Error(fmt.Sprintf("Error writing grpc stub package: %v", err))
}
}

func generateProtoFile(dir, entity, fileNamingConvention string, sm ...*SafetyManager) {
Expand Down
11 changes: 7 additions & 4 deletions cmd/interfaces.go
Original file line number Diff line number Diff line change
Expand Up @@ -199,11 +199,14 @@ func generateHandlerInterfaceFile(dir, entity string, sm ...*SafetyManager) {
entityLower := strings.ToLower(entity)
filename := filepath.Join(dir, entityLower+"_handler.go")

moduleName := getModuleName()

var content strings.Builder
content.WriteString("package interfaces\n\n")
content.WriteString("import (\n")
content.WriteString("\t\"net/http\"\n")
content.WriteString("\t\"context\"\n")
content.WriteString("\t\"net/http\"\n\n")
content.WriteString(fmt.Sprintf("\t\"%s/internal/domain\"\n", moduleName))
content.WriteString(")\n\n")

// HTTP Handler interface
Expand Down Expand Up @@ -259,7 +262,7 @@ func generateGRPCRequestResponseInterfaces(content *strings.Builder, entity stri

// Create Response interface
fmt.Fprintf(content, "type Create%sResponse interface {\n", entity)
fmt.Fprintf(content, "\tGet%s() *%s\n", entity, entity)
fmt.Fprintf(content, "\tGet%s() *domain.%s\n", entity, entity)
content.WriteString("\tGetMessage() string\n")
content.WriteString("}\n\n")

Expand All @@ -270,7 +273,7 @@ func generateGRPCRequestResponseInterfaces(content *strings.Builder, entity stri

// Get Response interface
fmt.Fprintf(content, "type %sResponse interface {\n", entity)
fmt.Fprintf(content, "\tGet%s() *%s\n", entity, entity)
fmt.Fprintf(content, "\tGet%s() *domain.%s\n", entity, entity)
content.WriteString("}\n\n")

// Update Request interface
Expand Down Expand Up @@ -302,7 +305,7 @@ func generateGRPCRequestResponseInterfaces(content *strings.Builder, entity stri

// List Response interface
fmt.Fprintf(content, "type List%ssResponse interface {\n", entity)
fmt.Fprintf(content, "\tGet%ss() []*%s\n", entity, entity)
fmt.Fprintf(content, "\tGet%ss() []*domain.%s\n", entity, entity)
content.WriteString("\tGetTotal() int32\n")
content.WriteString("}\n")
}
Expand Down
8 changes: 4 additions & 4 deletions cmd/interfaces_helpers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,9 +31,9 @@ func TestGenerateGRPCRequestResponseInterfaces(t *testing.T) {
assert.Contains(t, result, "GetName() string")
assert.Contains(t, result, "GetEmail() string")
assert.Contains(t, result, "GetId() int32")
assert.Contains(t, result, "GetProduct() *Product")
assert.Contains(t, result, "GetProduct() *domain.Product")
assert.Contains(t, result, "GetMessage() string")
assert.Contains(t, result, "GetProducts() []*Product")
assert.Contains(t, result, "GetProducts() []*domain.Product")
assert.Contains(t, result, "GetTotal() int32")
})

Expand All @@ -44,8 +44,8 @@ func TestGenerateGRPCRequestResponseInterfaces(t *testing.T) {
result := sb.String()

assert.Contains(t, result, "type CreateUserRequest interface")
assert.Contains(t, result, "GetUser() *User")
assert.Contains(t, result, "GetUsers() []*User")
assert.Contains(t, result, "GetUser() *domain.User")
assert.Contains(t, result, "GetUsers() []*domain.User")
})
}

Expand Down
16 changes: 5 additions & 11 deletions cmd/messages.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,13 +71,11 @@ organized by feature to maintain consistency in the application.`,
}

func generateMessages(entity string, errors, responses, constants bool, sm ...*SafetyManager) {
// Create messages directory
// Directory paths only — the file writers (SafetyManager.WriteFile) create
// the parent directory when a file is actually written, which keeps
// --dry-run from leaving empty directories behind.
messagesDir := filepath.Join("internal", "messages")
_ = os.MkdirAll(messagesDir, 0o755)

// Create constants directory
constantsDir := filepath.Join("internal", "constants")
_ = os.MkdirAll(constantsDir, 0o755)

if errors {
generateUseCaseMessages(entity, sm...)
Expand All @@ -93,13 +91,9 @@ func generateMessages(entity string, errors, responses, constants bool, sm ...*S
}

func generateUseCaseMessages(entity string, sm ...*SafetyManager) {
// Create messages directory and file in internal/messages
// The writer creates internal/messages/ when the file is actually written,
// so --dry-run does not leave an empty directory behind.
messagesDir := filepath.Join("internal", "messages")
if err := os.MkdirAll(messagesDir, 0o755); err != nil {
fmt.Printf("Error creating messages directory: %v\n", err)
return
}

filename := filepath.Join(messagesDir, "messages.go")
entityLower := strings.ToLower(entity)

Expand Down
22 changes: 13 additions & 9 deletions cmd/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,15 +132,19 @@ func generateRepository(entity, database string, interfaceOnly, implementation,
parsedFields = parseFields(fs)
}

// Generate interface:
// - Always generate interface UNLESS explicitly skipped
// - interfaceOnly=true: only interface
// - interfaceOnly=false, implementation=true: both
// - interfaceOnly=false, implementation=false: both (default)
if len(parsedFields) > 0 {
generateRepositoryInterfaceWithFields(repoDir, entity, parsedFields, transactions, sm...)
} else {
generateRepositoryInterface(repoDir, entity, transactions, sm...)
// Generate interface unless the caller asked for the implementation only
// (--implementation), which assumes the interface already exists (e.g. from
// a prior --interface-only run). This mirrors --interface-only, which emits
// only the contract.
// - interfaceOnly=true: only the interface
// - implementation=true: only the implementation
// - neither (default): both
if !implementation {
if len(parsedFields) > 0 {
generateRepositoryInterfaceWithFields(repoDir, entity, parsedFields, transactions, sm...)
} else {
generateRepositoryInterface(repoDir, entity, transactions, sm...)
}
}

// Generate implementation if not interface-only and database is specified
Expand Down
34 changes: 28 additions & 6 deletions cmd/repository_other_db.go
Original file line number Diff line number Diff line change
Expand Up @@ -189,10 +189,14 @@ func generateElasticsearchRepository(dir, entity string, cache, transactions boo
content.WriteString("\tres, err := req.Do(context.Background(), e.client)\n")
content.WriteString("\tif err != nil {\n\t\treturn nil, err\n\t}\n")
content.WriteString("\tdefer res.Body.Close()\n")
content.WriteString(fmt.Sprintf("\tvar doc domain.%s\n", entity))
content.WriteString("\tif err := json.NewDecoder(res.Body).Decode(&doc); err != nil {\n")
// An Elasticsearch GET wraps the document under "_source"; decode that
// rather than the envelope so the returned entity is actually populated.
content.WriteString("\tvar envelope struct {\n")
content.WriteString(fmt.Sprintf("\t\tSource domain.%s `json:\"_source\"`\n", entity))
content.WriteString("\t}\n")
content.WriteString("\tif err := json.NewDecoder(res.Body).Decode(&envelope); err != nil {\n")
content.WriteString("\t\treturn nil, err\n\t}\n")
content.WriteString("\treturn &doc, nil\n")
content.WriteString("\treturn &envelope.Source, nil\n")
content.WriteString("}\n\n")

// FullTextSearch method
Expand All @@ -215,10 +219,18 @@ func generateElasticsearchRepository(dir, entity string, cache, transactions boo
content.WriteString("\tres, err := req.Do(context.Background(), e.client)\n")
content.WriteString("\tif err != nil {\n\t\treturn nil, err\n\t}\n")
content.WriteString("\tdefer res.Body.Close()\n")
content.WriteString(fmt.Sprintf("\tvar results []domain.%s\n", entity))
content.WriteString("\tvar sr map[string]interface{}\n")
// Parse the Elasticsearch search response and extract each hit's _source
// into the domain slice; previously the decoded body was discarded and an
// empty slice was returned.
content.WriteString("\tvar sr struct {\n")
content.WriteString("\t\tHits struct {\n")
content.WriteString(fmt.Sprintf("\t\t\tHits []struct {\n\t\t\t\tSource domain.%s `json:\"_source\"`\n\t\t\t} `json:\"hits\"`\n", entity))
content.WriteString("\t\t} `json:\"hits\"`\n")
content.WriteString("\t}\n")
content.WriteString("\tif err := json.NewDecoder(res.Body).Decode(&sr); err != nil {\n")
content.WriteString("\t\treturn nil, err\n\t}\n")
content.WriteString(fmt.Sprintf("\tresults := make([]domain.%s, 0, len(sr.Hits.Hits))\n", entity))
content.WriteString("\tfor _, h := range sr.Hits.Hits {\n\t\tresults = append(results, h.Source)\n\t}\n")
content.WriteString("\treturn results, nil\n")
content.WriteString("}\n\n")

Expand All @@ -239,7 +251,17 @@ func generateElasticsearchRepository(dir, entity string, cache, transactions boo
content.WriteString("\tres, err := req.Do(context.Background(), e.client)\n")
content.WriteString("\tif err != nil {\n\t\treturn nil, err\n\t}\n")
content.WriteString("\tdefer res.Body.Close()\n")
content.WriteString(fmt.Sprintf("\tvar results []domain.%s\n", entity))
// Extract each hit's _source into the domain slice; previously the response
// body was never read and an empty slice was returned.
content.WriteString("\tvar sr struct {\n")
content.WriteString("\t\tHits struct {\n")
content.WriteString(fmt.Sprintf("\t\t\tHits []struct {\n\t\t\t\tSource domain.%s `json:\"_source\"`\n\t\t\t} `json:\"hits\"`\n", entity))
content.WriteString("\t\t} `json:\"hits\"`\n")
content.WriteString("\t}\n")
content.WriteString("\tif err := json.NewDecoder(res.Body).Decode(&sr); err != nil {\n")
content.WriteString("\t\treturn nil, err\n\t}\n")
content.WriteString(fmt.Sprintf("\tresults := make([]domain.%s, 0, len(sr.Hits.Hits))\n", entity))
content.WriteString("\tfor _, h := range sr.Hits.Hits {\n\t\tresults = append(results, h.Source)\n\t}\n")
content.WriteString("\treturn results, nil\n")
content.WriteString("}\n\n")

Expand Down
Loading