diff --git a/cmd/config_manager.go b/cmd/config_manager.go index 32de678..0a711fd 100644 --- a/cmd/config_manager.go +++ b/cmd/config_manager.go @@ -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 @@ -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 diff --git a/cmd/feature.go b/cmd/feature.go index 6a2b88c..4fd9381 100644 --- a/cmd/feature.go +++ b/cmd/feature.go @@ -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() @@ -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...") diff --git a/cmd/handler.go b/cmd/handler.go index 56722da..017b39c 100644 --- a/cmd/handler.go +++ b/cmd/handler.go @@ -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 GetOutput 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", @@ -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.ListOutput (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", diff --git a/cmd/handler_other.go b/cmd/handler_other.go index da07c6e..89b2b1b 100644 --- a/cmd/handler_other.go +++ b/cmd/handler_other.go @@ -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) { diff --git a/cmd/interfaces.go b/cmd/interfaces.go index ea992d7..832c356 100644 --- a/cmd/interfaces.go +++ b/cmd/interfaces.go @@ -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 @@ -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") @@ -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 @@ -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") } diff --git a/cmd/interfaces_helpers_test.go b/cmd/interfaces_helpers_test.go index 7621296..0f2c854 100644 --- a/cmd/interfaces_helpers_test.go +++ b/cmd/interfaces_helpers_test.go @@ -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") }) @@ -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") }) } diff --git a/cmd/messages.go b/cmd/messages.go index 7fe5f5e..87f3b96 100644 --- a/cmd/messages.go +++ b/cmd/messages.go @@ -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...) @@ -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) diff --git a/cmd/repository.go b/cmd/repository.go index 264a33d..b72ac49 100644 --- a/cmd/repository.go +++ b/cmd/repository.go @@ -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 diff --git a/cmd/repository_other_db.go b/cmd/repository_other_db.go index 4c7e0db..c218c1c 100644 --- a/cmd/repository_other_db.go +++ b/cmd/repository_other_db.go @@ -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 @@ -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") @@ -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")