From 6692d31ea66e496eb7c0d352281ca71e5f7d68ae Mon Sep 17 00:00:00 2001 From: descholar-ceo Date: Sun, 12 Apr 2026 10:42:33 +0200 Subject: [PATCH 1/8] bg: Fix generated project starting issues --- internal/commands/dev.go | 53 +++++++++++++++++++---- internal/commands/dev_test.go | 79 ++++++++++++++++++++++++++++++++++- 2 files changed, 121 insertions(+), 11 deletions(-) diff --git a/internal/commands/dev.go b/internal/commands/dev.go index d331bd1..c39ace9 100644 --- a/internal/commands/dev.go +++ b/internal/commands/dev.go @@ -5,6 +5,7 @@ import ( "os" "os/signal" "syscall" + "time" "github.com/gofastadev/cli/internal/commands/configutil" "github.com/gofastadev/cli/internal/termcolor" @@ -55,16 +56,15 @@ func runDev() error { termcolor.PrintStep("📋 Loaded %d variables from .env", loaded) } - // Try running migrations + // Try running migrations. The database container may still be starting + // (docker compose up db -d takes 1-3 seconds to accept connections), so + // we retry once after a short pause before giving up. The error from the + // migrate CLI is printed verbatim so the developer can see what actually + // went wrong instead of guessing from a generic warning. termcolor.PrintStep("🗄 Running migrations...") - dbURL := configutil.BuildMigrationURL() - if dbURL != "" { - migrateCmd := execCommand("migrate", "-path", "db/migrations", "-database", dbURL, "up") - migrateCmd.Stdout = os.Stdout - migrateCmd.Stderr = os.Stderr - if err := migrateCmd.Run(); err != nil { - termcolor.PrintWarn("Migrations skipped (database may not be running)") - } + if migErr := runMigrations(); migErr != nil { + termcolor.PrintWarn("Migrations skipped: %v", migErr) + termcolor.PrintHint("If the database is still starting, migrations will be applied on the next file save (Air rebuild).") } port := configutil.GetPort() @@ -93,3 +93,38 @@ func runDev() error { return airCmd.Run() } + +// runMigrations checks for the `migrate` CLI, builds the database URL from +// config, and applies pending migrations. If the first attempt fails (common +// when the database container is still starting), it waits briefly and +// retries once. Returns nil on success (including "no change"), or the +// underlying error on failure so the caller can print it verbatim. +func runMigrations() error { + if _, err := execLookPath("migrate"); err != nil { + return fmt.Errorf("migrate CLI not found on $PATH — install with: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.18.1") + } + + // configutil always builds a URL from defaults (at minimum + // postgres://:@localhost:5432/?sslmode=disable), so a "" return is + // not expected and not checked. If the URL is structurally wrong, + // the migrate CLI will surface the error on the first attempt below. + dbURL := configutil.BuildMigrationURL() + + // First attempt. + if err := runMigrateUp(dbURL); err == nil { + return nil + } + + // Retry once after a short pause — gives the database container time + // to finish accepting connections after `docker compose up db -d`. + termcolor.PrintHint("Database not ready, retrying in 2 seconds...") + time.Sleep(2 * time.Second) + return runMigrateUp(dbURL) +} + +func runMigrateUp(dbURL string) error { + cmd := execCommand("migrate", "-path", "db/migrations", "-database", dbURL, "up") + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + return cmd.Run() +} diff --git a/internal/commands/dev_test.go b/internal/commands/dev_test.go index 3caec91..5b0ddf9 100644 --- a/internal/commands/dev_test.go +++ b/internal/commands/dev_test.go @@ -1,6 +1,7 @@ package commands import ( + "fmt" "os" "path/filepath" "testing" @@ -92,10 +93,84 @@ func TestRunDev_UnreadableDotEnv(t *testing.T) { func TestRunDev_MigrationFails(t *testing.T) { setupDevTempdir(t) withFakeExec(t, 1) // every exec returns non-zero + // Provide a migrate binary on PATH so runMigrations doesn't short-circuit + // at the LookPath check. fakeExecCommand produces the binary path from + // os.Args[0] (the test binary itself) which is always on PATH. + origLookPath := execLookPath + execLookPath = func(name string) (string, error) { return "/usr/bin/migrate", nil } + t.Cleanup(func() { execLookPath = origLookPath }) err := runDev() // Air also fails (same fakeExec) — runDev returns the air error. - // The important coverage is that the migration warn branch fired before - // reaching the air invocation. assert.Error(t, err) } + +// runMigrations with migrate not installed — returns a clear error message +// mentioning where to install from. +func TestRunMigrations_MigrateNotFound(t *testing.T) { + setupDevTempdir(t) + origLookPath := execLookPath + execLookPath = func(name string) (string, error) { return "", fmt.Errorf("not found") } + t.Cleanup(func() { execLookPath = origLookPath }) + + err := runMigrations() + assert.Error(t, err) + assert.Contains(t, err.Error(), "migrate CLI not found") + assert.Contains(t, err.Error(), "v4.18.1") +} + +// runMigrations with empty DB URL — returns error about config. +func TestRunMigrations_EmptyDBURL(t *testing.T) { + // Empty temp dir with no config.yaml → BuildMigrationURL returns something + // with empty fields but non-empty string; so this particular test won't + // trigger the empty-URL path. Use a dir without config.yaml AND no env + // vars set — but configutil always returns a non-empty URL with defaults. + // Skip this — the branch is defensive and practically unreachable since + // configutil always returns at least the default postgres URL. +} + +// runMigrations succeeds on first attempt. +func TestRunMigrations_SuccessFirstAttempt(t *testing.T) { + setupDevTempdir(t) + origLookPath := execLookPath + execLookPath = func(name string) (string, error) { return "/usr/bin/migrate", nil } + t.Cleanup(func() { execLookPath = origLookPath }) + withFakeExec(t, 0) + + err := runMigrations() + assert.NoError(t, err) +} + +// runMigrations fails first attempt but succeeds on retry. +func TestRunMigrations_SuccessOnRetry(t *testing.T) { + setupDevTempdir(t) + origLookPath := execLookPath + execLookPath = func(name string) (string, error) { return "/usr/bin/migrate", nil } + t.Cleanup(func() { execLookPath = origLookPath }) + // First call (migrate up) fails, second call (retry) succeeds. + stagedFakeExec(t, 1, 0) + + err := runMigrations() + assert.NoError(t, err) +} + +// runMigrations fails both attempts — returns the error from the second try. +func TestRunMigrations_FailsBothAttempts(t *testing.T) { + setupDevTempdir(t) + origLookPath := execLookPath + execLookPath = func(name string) (string, error) { return "/usr/bin/migrate", nil } + t.Cleanup(func() { execLookPath = origLookPath }) + withFakeExec(t, 1) // both attempts fail + + err := runMigrations() + assert.Error(t, err) +} + +// runMigrateUp — direct test of the single-attempt function. +func TestRunMigrateUp(t *testing.T) { + withFakeExec(t, 0) + assert.NoError(t, runMigrateUp("postgres://test:test@localhost:5432/testdb")) + + withFakeExec(t, 1) + assert.Error(t, runMigrateUp("postgres://test:test@localhost:5432/testdb")) +} From d915f0f2865e86e3926aab6cbcf1548b932b1829 Mon Sep 17 00:00:00 2001 From: descholar-ceo Date: Sun, 12 Apr 2026 10:48:57 +0200 Subject: [PATCH 2/8] bg: Fix migrate installation instructions --- internal/commands/dev.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/internal/commands/dev.go b/internal/commands/dev.go index c39ace9..e3c57c2 100644 --- a/internal/commands/dev.go +++ b/internal/commands/dev.go @@ -101,7 +101,8 @@ func runDev() error { // underlying error on failure so the caller can print it verbatim. func runMigrations() error { if _, err := execLookPath("migrate"); err != nil { - return fmt.Errorf("migrate CLI not found on $PATH — install with: go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.18.1") + return fmt.Errorf("migrate CLI not found on $PATH — install with:\n" + + " go install -tags 'postgres mysql sqlite3 sqlserver clickhouse' github.com/golang-migrate/migrate/v4/cmd/migrate@v4.18.1") } // configutil always builds a URL from defaults (at minimum From 8b9cadf83520a13dc54daeceffb20e7cf1f6d1bb Mon Sep 17 00:00:00 2001 From: descholar-ceo Date: Sun, 12 Apr 2026 11:20:58 +0200 Subject: [PATCH 3/8] ft: Add swagger docs --- internal/commands/new.go | 6 ++++ internal/commands/routes.go | 35 ++++++++++++++----- internal/commands/routes_test.go | 26 ++++++++++++++ .../app/rest/routes/index.routes.go.tmpl | 6 ++++ internal/skeleton/project/cmd/serve.go.tmpl | 1 + internal/skeleton/project/docs/docs.go | 6 ++++ 6 files changed, 71 insertions(+), 9 deletions(-) create mode 100644 internal/skeleton/project/docs/docs.go diff --git a/internal/commands/new.go b/internal/commands/new.go index 5ba59fb..f3cec5e 100644 --- a/internal/commands/new.go +++ b/internal/commands/new.go @@ -301,6 +301,7 @@ func runNew(nameOrPath string, includeGraphQL bool) error { _ = runCmdSilent("go", "get", "github.com/google/wire/cmd/wire@latest") _ = runCmdSilent("go", "get", "github.com/air-verse/air@latest") _ = runCmdSilent("go", "get", "github.com/swaggo/swag/cmd/swag@latest") + _ = runCmdSilent("go", "get", "github.com/swaggo/http-swagger/v2@latest") // Register as Go tools if includeGraphQL { _ = runCmdSilent("go", "mod", "edit", "-tool", "github.com/99designs/gqlgen") @@ -327,6 +328,11 @@ func runNew(nameOrPath string, includeGraphQL bool) error { } } + termcolor.PrintStep("📝 Generating Swagger/OpenAPI docs...") + if err := runCmdSilent("go", "tool", "swag", "init", "-g", "app/main/main.go", "-o", "docs/"); err != nil { + termcolor.PrintWarn("Swagger generation skipped (can be run later with: gofasta swagger)") + } + // Initialize git fmt.Println() termcolor.PrintStep("🔧 Initializing git repository...") diff --git a/internal/commands/routes.go b/internal/commands/routes.go index 590aa82..ef0885e 100644 --- a/internal/commands/routes.go +++ b/internal/commands/routes.go @@ -38,8 +38,9 @@ type routeEntry struct { } var ( - handleFuncRe = regexp.MustCompile(`\.HandleFunc\("([^"]+)",.+\.Methods\("([^"]+)"\)`) - prefixRe = regexp.MustCompile(`\.PathPrefix\("([^"]+)"\)`) + handleFuncRe = regexp.MustCompile(`\.HandleFunc\("([^"]+)",.+\.Methods\("([^"]+)"\)`) + prefixRe = regexp.MustCompile(`\.PathPrefix\("([^"]+)"\)\.Subrouter\(\)`) + pathHandlerRe = regexp.MustCompile(`\.PathPrefix\("([^"]+)"\)\.Handler\(`) ) func runRoutes() error { @@ -97,16 +98,32 @@ func runRoutes() error { } func extractRoutes(content, prefix, filename string) []routeEntry { - matches := handleFuncRe.FindAllStringSubmatch(content, -1) - routes := make([]routeEntry, 0, len(matches)) - for _, m := range matches { - path := prefix + m[1] - method := m[2] + var routes []routeEntry + + // Match .HandleFunc("path", ...).Methods("METHOD") + for _, m := range handleFuncRe.FindAllStringSubmatch(content, -1) { routes = append(routes, routeEntry{ - method: method, - path: path, + method: m[2], + path: prefix + m[1], filename: filename, }) } + + // Match .PathPrefix("path").Handler(...) — used by swagger UI and + // other prefix-mounted handlers. Shown as GET since they serve content. + for _, m := range pathHandlerRe.FindAllStringSubmatch(content, -1) { + path := m[1] + // Don't pick up the API subrouter prefix (e.g. /api/v1) — that's + // already handled by prefixRe and used as a prefix for child routes. + if path == prefix || path == prefix+"/" { + continue + } + routes = append(routes, routeEntry{ + method: "GET", + path: path + "*", + filename: filename, + }) + } + return routes } diff --git a/internal/commands/routes_test.go b/internal/commands/routes_test.go index c5f7186..26b2f6b 100644 --- a/internal/commands/routes_test.go +++ b/internal/commands/routes_test.go @@ -66,6 +66,32 @@ func InitApiRoutes(config *RouteConfig) *mux.Router { assert.Equal(t, "/health/ready", routes[2].path) } +func TestExtractRoutes_PathPrefixHandler(t *testing.T) { + content := `package routes + +func InitApiRoutes(config *RouteConfig) *mux.Router { + r.HandleFunc("/health", httputil.Handle(config.HealthController.Check)).Methods("GET") + r.PathPrefix("/swagger/").Handler(httpSwagger.WrapHandler) +}` + + routes := extractRoutes(content, "", "index.routes.go") + + assert.Len(t, routes, 2) + assert.Equal(t, "GET", routes[0].method) + assert.Equal(t, "/health", routes[0].path) + // PathPrefix-mounted handlers show as GET with a trailing wildcard + assert.Equal(t, "GET", routes[1].method) + assert.Equal(t, "/swagger/*", routes[1].path) +} + +func TestExtractRoutes_PathPrefixSkipsAPIPrefix(t *testing.T) { + // The API subrouter prefix (/api/v1) is used by the route extraction + // as a prefix for child routes — it should not appear as its own entry. + content := `api := r.PathPrefix("/api/v1").Subrouter()` + routes := extractRoutes(content, "/api/v1", "index.routes.go") + assert.Empty(t, routes, "API subrouter prefix should not produce a route entry") +} + func TestExtractRoutes_EmptyContent(t *testing.T) { routes := extractRoutes("package routes", "/api/v1", "empty.routes.go") assert.Empty(t, routes) diff --git a/internal/skeleton/project/app/rest/routes/index.routes.go.tmpl b/internal/skeleton/project/app/rest/routes/index.routes.go.tmpl index 0b21440..89aabd7 100644 --- a/internal/skeleton/project/app/rest/routes/index.routes.go.tmpl +++ b/internal/skeleton/project/app/rest/routes/index.routes.go.tmpl @@ -4,7 +4,9 @@ import ( "net/http" "github.com/gorilla/mux" + httpSwagger "github.com/swaggo/http-swagger/v2" "{{.ModulePath}}/app/rest/controllers" + _ "{{.ModulePath}}/docs" "github.com/gofastadev/gofasta/pkg/health" "github.com/gofastadev/gofasta/pkg/httputil" "github.com/gofastadev/gofasta/pkg/websocket" @@ -27,6 +29,10 @@ func InitApiRoutes(config *RouteConfig) *mux.Router { r.HandleFunc("/health/ready", httputil.Handle(config.HealthController.Ready)).Methods("GET") } + // Swagger UI — OpenAPI docs generated by `gofasta swagger`. + // Visit /swagger/index.html in the browser to explore the API. + r.PathPrefix("/swagger/").Handler(httpSwagger.WrapHandler) + // WebSocket endpoint if config.WebSocketHub != nil { r.HandleFunc("/ws", func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/skeleton/project/cmd/serve.go.tmpl b/internal/skeleton/project/cmd/serve.go.tmpl index babb821..9e0de29 100644 --- a/internal/skeleton/project/cmd/serve.go.tmpl +++ b/internal/skeleton/project/cmd/serve.go.tmpl @@ -77,6 +77,7 @@ func startServer() error { mux.Handle(cfg.GraphQL.PlaygroundRoute, playground.Handler("GraphQL playground", cfg.GraphQL.GeneralRoute)) mux.Handle(cfg.GraphQL.GeneralRoute, graphqlHandler) {{- end}} + mux.Handle("/", apiRouter) // Prometheus metrics endpoint diff --git a/internal/skeleton/project/docs/docs.go b/internal/skeleton/project/docs/docs.go new file mode 100644 index 0000000..a0d4de5 --- /dev/null +++ b/internal/skeleton/project/docs/docs.go @@ -0,0 +1,6 @@ +// Package docs is a stub that satisfies the blank import in routes/index.routes.go. +// Running `gofasta swagger` (or `go tool swag init -g app/main/main.go -o docs/`) +// overwrites this file with the auto-generated OpenAPI spec and Swagger UI +// registration. The stub ensures the project compiles even before Swagger docs +// have been generated for the first time. +package docs From 56d54e20b00f3878958fd575f57e627964d1b458 Mon Sep 17 00:00:00 2001 From: descholar-ceo Date: Sun, 12 Apr 2026 11:42:53 +0200 Subject: [PATCH 4/8] Fix the swagger synthax --- internal/commands/new.go | 4 +++- internal/commands/swagger.go | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/internal/commands/new.go b/internal/commands/new.go index f3cec5e..99b172d 100644 --- a/internal/commands/new.go +++ b/internal/commands/new.go @@ -329,7 +329,9 @@ func runNew(nameOrPath string, includeGraphQL bool) error { } termcolor.PrintStep("📝 Generating Swagger/OpenAPI docs...") - if err := runCmdSilent("go", "tool", "swag", "init", "-g", "app/main/main.go", "-o", "docs/"); err != nil { + if err := runCmdSilent("go", "tool", "swag", "init", + "-g", "app/main/main.go", "-o", "docs/", + "--parseDependency", "--parseInternal"); err != nil { termcolor.PrintWarn("Swagger generation skipped (can be run later with: gofasta swagger)") } diff --git a/internal/commands/swagger.go b/internal/commands/swagger.go index 18fa332..39f16c1 100644 --- a/internal/commands/swagger.go +++ b/internal/commands/swagger.go @@ -19,7 +19,11 @@ Entry point is hard-coded to app/main/main.go and output to docs/; run be registered in go.mod — ` + "`gofasta new`" + ` and ` + "`gofasta init`" + ` do this automatically.`, RunE: func(cmd *cobra.Command, args []string) error { - swag := execCommand("go", "tool", "swag", "init", "-g", "app/main/main.go", "-o", "docs/") + swag := execCommand("go", "tool", "swag", "init", + "-g", "app/main/main.go", + "-o", "docs/", + "--parseDependency", "--parseInternal", + ) swag.Stdout = os.Stdout swag.Stderr = os.Stderr return swag.Run() From 290f63d73fec885ea6eb11a9994f5e3f60a85183 Mon Sep 17 00:00:00 2001 From: descholar-ceo Date: Sun, 12 Apr 2026 11:51:56 +0200 Subject: [PATCH 5/8] Add swagger in init command --- internal/commands/init_cmd.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/commands/init_cmd.go b/internal/commands/init_cmd.go index 5f83f6b..e83bc61 100644 --- a/internal/commands/init_cmd.go +++ b/internal/commands/init_cmd.go @@ -72,7 +72,16 @@ func runInit() error { termcolor.PrintStep("📊 Skipping GraphQL (no gqlgen.yml found)") } - // Step 5: Run migrations + // Step 5: Generate Swagger docs + fmt.Println() + termcolor.PrintStep("📝 Generating Swagger/OpenAPI docs...") + if err := runCmd("go", "tool", "swag", "init", + "-g", "app/main/main.go", "-o", "docs/", + "--parseDependency", "--parseInternal"); err != nil { + termcolor.PrintWarn("Swagger generation skipped (can be run later with: gofasta swagger)") + } + + // Step 6: Run migrations fmt.Println() termcolor.PrintStep("🗄 Running database migrations...") dbURL := configutil.BuildMigrationURL() From 333044faf865b8f097c6308131c3052185bb2e70 Mon Sep 17 00:00:00 2001 From: descholar-ceo Date: Sun, 12 Apr 2026 12:43:41 +0200 Subject: [PATCH 6/8] Improve swagger coverage --- internal/generate/commands.go | 15 ++++ internal/generate/templates/controller.go | 69 +++++++++++++++++++ internal/generate/templates/templates_test.go | 1 + internal/generate/types.go | 1 + .../rest/controllers/user.controller.go.tmpl | 17 +++-- 5 files changed, 98 insertions(+), 5 deletions(-) diff --git a/internal/generate/commands.go b/internal/generate/commands.go index 2e26d5e..d823034 100644 --- a/internal/generate/commands.go +++ b/internal/generate/commands.go @@ -79,6 +79,11 @@ func init() { cmd.Flags().Bool("graphql", false, "Also generate GraphQL schema and wire resolver") cmd.Flags().Bool("gql", false, "Shorthand for --graphql") } + + // Register --swagger flag on commands that produce controllers + for _, cmd := range []*cobra.Command{scaffoldCmd, controllerCmd} { + cmd.Flags().Bool("swagger", false, "Add Swagger/OpenAPI annotations to the generated controller") + } } // --- Step chain builders --- @@ -252,6 +257,11 @@ func hasGraphQLFlag(cmd *cobra.Command) bool { return gql || gqlShort } +func hasSwaggerFlag(cmd *cobra.Command) bool { + swagger, _ := cmd.Flags().GetBool("swagger") + return swagger +} + // --- Cobra command definitions --- var scaffoldCmd = &cobra.Command{ @@ -302,6 +312,7 @@ logic in app/services/.service.go.`, d := buildFromArgs(args) d.IncludeController = true d.IncludeGraphQL = hasGraphQLFlag(cmd) + d.IncludeSwagger = hasSwaggerFlag(cmd) if err := RunSteps(d, scaffoldSteps(d)); err != nil { return err } @@ -309,6 +320,9 @@ logic in app/services/.service.go.`, termcolor.PrintSuccess("Scaffold complete for %s. All files generated and wired.", termcolor.CBold(d.Name)) fmt.Printf(" %s %s\n", termcolor.CDim("Run migrations:"), termcolor.CBold("gofasta migrate up")) fmt.Printf(" %s %s\n", termcolor.CDim("Write logic:"), termcolor.CBold(fmt.Sprintf("app/services/%s.service.go", d.SnakeName))) + if d.IncludeSwagger { + fmt.Printf(" %s %s\n", termcolor.CDim("Regenerate docs:"), termcolor.CBold("gofasta swagger")) + } return nil }, } @@ -386,6 +400,7 @@ step.`, d := buildFromArgs(args) d.IncludeController = true d.IncludeGraphQL = hasGraphQLFlag(cmd) + d.IncludeSwagger = hasSwaggerFlag(cmd) return RunSteps(d, controllerSteps(d)) }, } diff --git a/internal/generate/templates/controller.go b/internal/generate/templates/controller.go index 59f9cd7..ac5e839 100644 --- a/internal/generate/templates/controller.go +++ b/internal/generate/templates/controller.go @@ -23,6 +23,18 @@ func New{{.Name}}ControllerInstance(svc svcInterfaces.{{.Name}}ServiceInterface) return &{{.Name}}Controller{ {{.Name}}Service: svc} } +{{- if .IncludeSwagger}} +// List godoc +// +// @Summary List {{.PluralLower}} +// @Description Get all {{.PluralLower}} with optional filtering, pagination, and sorting +// @Tags {{.PluralLower}} +// @Produce json +// @Param sortByField query string false "Field to sort by" +// @Success 200 {object} dtos.T{{.PluralName}}ResponseDto +// @Failure 500 {object} dtos.TCommonAPIErrorDto +// @Router /{{.PluralLower}} [get] +{{- end}} func (c *{{.Name}}Controller) List(w http.ResponseWriter, r *http.Request) error { filters := dtos.{{.Name}}FiltersDto{ Pagination: &dtos.TPaginationInputDto{}, @@ -35,6 +47,20 @@ func (c *{{.Name}}Controller) List(w http.ResponseWriter, r *http.Request) error return httputil.OK(w, res) } +{{- if .IncludeSwagger}} +// GetByID godoc +// +// @Summary Get a {{.LowerName}} +// @Description Get a single {{.LowerName}} by ID +// @Tags {{.PluralLower}} +// @Produce json +// @Param id path string true "{{.Name}} ID" +// @Success 200 {object} dtos.T{{.Name}}ResponseDto +// @Failure 400 {object} dtos.TCommonAPIErrorDto +// @Failure 404 {object} dtos.TCommonAPIErrorDto +// @Failure 500 {object} dtos.TCommonAPIErrorDto +// @Router /{{.PluralLower}}/{id} [get] +{{- end}} func (c *{{.Name}}Controller) GetByID(w http.ResponseWriter, r *http.Request) error { id, err := utils.ParseIDStringIsValidUUID(mux.Vars(r)["id"]) if err != nil { @@ -47,6 +73,20 @@ func (c *{{.Name}}Controller) GetByID(w http.ResponseWriter, r *http.Request) er return httputil.OK(w, res) } +{{- if .IncludeSwagger}} +// Create godoc +// +// @Summary Create a {{.LowerName}} +// @Description Create a new {{.LowerName}} +// @Tags {{.PluralLower}} +// @Accept json +// @Produce json +// @Param {{.LowerName}} body dtos.TCreate{{.Name}}Dto true "{{.Name}} data" +// @Success 201 {object} dtos.T{{.Name}}ResponseDto +// @Failure 400 {object} dtos.TCommonAPIErrorDto +// @Failure 500 {object} dtos.TCommonAPIErrorDto +// @Router /{{.PluralLower}} [post] +{{- end}} func (c *{{.Name}}Controller) Create(w http.ResponseWriter, r *http.Request) error { var input dtos.TCreate{{.Name}}Dto if err := json.NewDecoder(r.Body).Decode(&input); err != nil { @@ -59,6 +99,21 @@ func (c *{{.Name}}Controller) Create(w http.ResponseWriter, r *http.Request) err return httputil.Created(w, res) } +{{- if .IncludeSwagger}} +// Update godoc +// +// @Summary Update a {{.LowerName}} +// @Description Update {{.LowerName}} fields by ID +// @Tags {{.PluralLower}} +// @Accept json +// @Produce json +// @Param id path string true "{{.Name}} ID" +// @Param {{.LowerName}} body dtos.TUpdate{{.Name}}Dto true "Fields to update" +// @Success 200 {object} dtos.T{{.Name}}ResponseDto +// @Failure 400 {object} dtos.TCommonAPIErrorDto +// @Failure 500 {object} dtos.TCommonAPIErrorDto +// @Router /{{.PluralLower}}/{id} [put] +{{- end}} func (c *{{.Name}}Controller) Update(w http.ResponseWriter, r *http.Request) error { id, err := utils.ParseIDStringIsValidUUID(mux.Vars(r)["id"]) if err != nil { @@ -76,6 +131,20 @@ func (c *{{.Name}}Controller) Update(w http.ResponseWriter, r *http.Request) err return httputil.OK(w, res) } +{{- if .IncludeSwagger}} +// Archive godoc +// +// @Summary Archive a {{.LowerName}} +// @Description Soft-delete a {{.LowerName}} by ID +// @Tags {{.PluralLower}} +// @Produce json +// @Param id path string true "{{.Name}} ID" +// @Success 200 {object} dtos.T{{.Name}}ResponseDto +// @Failure 400 {object} dtos.TCommonAPIErrorDto +// @Failure 404 {object} dtos.TCommonAPIErrorDto +// @Failure 500 {object} dtos.TCommonAPIErrorDto +// @Router /{{.PluralLower}}/{id} [delete] +{{- end}} func (c *{{.Name}}Controller) Archive(w http.ResponseWriter, r *http.Request) error { id, err := utils.ParseIDStringIsValidUUID(mux.Vars(r)["id"]) if err != nil { diff --git a/internal/generate/templates/templates_test.go b/internal/generate/templates/templates_test.go index c36c666..37ad5c5 100644 --- a/internal/generate/templates/templates_test.go +++ b/internal/generate/templates/templates_test.go @@ -21,6 +21,7 @@ type testScaffoldData struct { MigrationNum string IncludeController bool IncludeGraphQL bool + IncludeSwagger bool DBDriver string ModulePath string } diff --git a/internal/generate/types.go b/internal/generate/types.go index 80a42d7..a8bf188 100644 --- a/internal/generate/types.go +++ b/internal/generate/types.go @@ -29,6 +29,7 @@ type ScaffoldData struct { MigrationNum string IncludeController bool IncludeGraphQL bool + IncludeSwagger bool Schedule string // cron expression for job generator DBDriver string // database driver from config (postgres, mysql, sqlite, sqlserver, clickhouse) ModulePath string // Go module path read from go.mod (e.g., "github.com/myorg/myapp") diff --git a/internal/skeleton/project/app/rest/controllers/user.controller.go.tmpl b/internal/skeleton/project/app/rest/controllers/user.controller.go.tmpl index dbd1c18..5e743bd 100644 --- a/internal/skeleton/project/app/rest/controllers/user.controller.go.tmpl +++ b/internal/skeleton/project/app/rest/controllers/user.controller.go.tmpl @@ -37,7 +37,8 @@ func NewUserControllerInstance(userService svcInterfaces.UserServiceInterface) * // @Produce json // @Param sortByField query string true "Field to sort by" // @Success 200 {object} dtos.TUsersResponseDto -// @Failure 400 {object} map[string]string +// @Failure 400 {object} dtos.TCommonAPIErrorDto +// @Failure 500 {object} dtos.TCommonAPIErrorDto // @Router /users [get] func (uc *UserController) ListUsers(w http.ResponseWriter, r *http.Request) error { if err := r.ParseForm(); err != nil { @@ -84,7 +85,8 @@ func (uc *UserController) ListUsers(w http.ResponseWriter, r *http.Request) erro // @Produce json // @Param user body dtos.TCreateUserDto true "User data" // @Success 201 {object} dtos.TUserResponseDto -// @Failure 400 {object} map[string]string +// @Failure 400 {object} dtos.TCommonAPIErrorDto +// @Failure 500 {object} dtos.TCommonAPIErrorDto // @Router /users [post] func (uc *UserController) CreateUser(w http.ResponseWriter, r *http.Request) error { var user dtos.TCreateUserDto @@ -109,7 +111,8 @@ func (uc *UserController) CreateUser(w http.ResponseWriter, r *http.Request) err // @Param id path string true "User ID" // @Param user body dtos.TUserFieldsForUpdateDto true "Fields to update" // @Success 200 {object} dtos.TUserResponseDto -// @Failure 400 {object} map[string]string +// @Failure 400 {object} dtos.TCommonAPIErrorDto +// @Failure 500 {object} dtos.TCommonAPIErrorDto // @Router /users/{id} [put] func (uc *UserController) UpdateUser(w http.ResponseWriter, r *http.Request) error { userId, err := utils.ParseIDStringIsValidUUID(mux.Vars(r)["id"]) @@ -138,7 +141,9 @@ func (uc *UserController) UpdateUser(w http.ResponseWriter, r *http.Request) err // @Produce json // @Param id path string true "User ID" // @Success 200 {object} dtos.TUserResponseDto -// @Failure 400 {object} map[string]string +// @Failure 400 {object} dtos.TCommonAPIErrorDto +// @Failure 404 {object} dtos.TCommonAPIErrorDto +// @Failure 500 {object} dtos.TCommonAPIErrorDto // @Router /users/{id} [get] func (uc *UserController) GetUser(w http.ResponseWriter, r *http.Request) error { userId, err := utils.ParseIDStringIsValidUUID(mux.Vars(r)["id"]) @@ -161,7 +166,9 @@ func (uc *UserController) GetUser(w http.ResponseWriter, r *http.Request) error // @Produce json // @Param id path string true "User ID" // @Success 200 {object} dtos.TCommonResponseDto -// @Failure 400 {object} map[string]string +// @Failure 400 {object} dtos.TCommonAPIErrorDto +// @Failure 404 {object} dtos.TCommonAPIErrorDto +// @Failure 500 {object} dtos.TCommonAPIErrorDto // @Router /users/{id} [delete] func (uc *UserController) ArchiveUser(w http.ResponseWriter, r *http.Request) error { userId, err := utils.ParseIDStringIsValidUUID(mux.Vars(r)["id"]) From 7b78fc1057ca71a523915567defcbcec76fa48f2 Mon Sep 17 00:00:00 2001 From: descholar-ceo Date: Sun, 12 Apr 2026 12:52:31 +0200 Subject: [PATCH 7/8] Improve test coverage --- internal/commands/commands_exec_test.go | 40 ++++++++++++++++++++++++- internal/commands/routes_test.go | 14 ++++++--- 2 files changed, 49 insertions(+), 5 deletions(-) diff --git a/internal/commands/commands_exec_test.go b/internal/commands/commands_exec_test.go index cf7a9a5..408b804 100644 --- a/internal/commands/commands_exec_test.go +++ b/internal/commands/commands_exec_test.go @@ -567,10 +567,14 @@ func TestRunRoutes_SampleProject(t *testing.T) { routesDir := "app/rest/routes" require.NoError(t, os.MkdirAll(routesDir, 0755)) + // Index file includes a .PathPrefix("...").Subrouter() call so the + // prefix extraction regex matches and apiPrefix gets set to "/api/v1". index := `package routes func InitApi(r *mux.Router) { - r.PathPrefix("/api/v1") + api := r.PathPrefix("/api/v1").Subrouter() r.HandleFunc("/health", httputil.Handle(c.Ok)).Methods("GET") + r.PathPrefix("/swagger/").Handler(httpSwagger.WrapHandler) + _ = api }` require.NoError(t, os.WriteFile(routesDir+"/index.routes.go", []byte(index), 0644)) @@ -600,6 +604,40 @@ func TestRunRoutes_NoIndexFile(t *testing.T) { assert.NoError(t, runRoutes()) } +func TestRunRoutes_ReadDirError(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses chmod-based access denial") + } + chdirTemp(t) + routesDir := "app/rest/routes" + require.NoError(t, os.MkdirAll(routesDir, 0755)) + // os.Stat passes (dir exists), but drop read permission so ReadDir fails. + require.NoError(t, os.Chmod(routesDir, 0o111)) + t.Cleanup(func() { _ = os.Chmod(routesDir, 0o755) }) + + err := runRoutes() + assert.Error(t, err) + assert.Contains(t, err.Error(), "failed to read routes directory") +} + +func TestRunRoutes_UnreadableRouteFile(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root bypasses chmod-based access denial") + } + chdirTemp(t) + routesDir := "app/rest/routes" + require.NoError(t, os.MkdirAll(routesDir, 0755)) + // Write a valid route file, then revoke read permission. ReadDir can + // list the file (only needs execute on the parent dir), but ReadFile + // fails with EACCES. The file should be silently skipped. + require.NoError(t, os.WriteFile(routesDir+"/blocked.routes.go", + []byte(`r.HandleFunc("/x", h).Methods("GET")`), 0o000)) + t.Cleanup(func() { _ = os.Chmod(routesDir+"/blocked.routes.go", 0o644) }) + + // Should not error — unreadable files are skipped (continue branch). + assert.NoError(t, runRoutes()) +} + func TestRunRoutes_SkipsNonRouteFiles(t *testing.T) { chdirTemp(t) routesDir := "app/rest/routes" diff --git a/internal/commands/routes_test.go b/internal/commands/routes_test.go index 26b2f6b..b5d8256 100644 --- a/internal/commands/routes_test.go +++ b/internal/commands/routes_test.go @@ -85,11 +85,17 @@ func InitApiRoutes(config *RouteConfig) *mux.Router { } func TestExtractRoutes_PathPrefixSkipsAPIPrefix(t *testing.T) { - // The API subrouter prefix (/api/v1) is used by the route extraction - // as a prefix for child routes — it should not appear as its own entry. - content := `api := r.PathPrefix("/api/v1").Subrouter()` + // When a .PathPrefix("...").Handler(...) call uses the same path as the + // API prefix passed to extractRoutes, it should be skipped — it's the + // subrouter setup, not a user-facing endpoint. Test both exact match + // and trailing-slash match. + content := `r.PathPrefix("/api/v1").Handler(someHandler)` routes := extractRoutes(content, "/api/v1", "index.routes.go") - assert.Empty(t, routes, "API subrouter prefix should not produce a route entry") + assert.Empty(t, routes, "exact prefix match should be skipped") + + content2 := `r.PathPrefix("/api/v1/").Handler(someHandler)` + routes2 := extractRoutes(content2, "/api/v1", "index.routes.go") + assert.Empty(t, routes2, "prefix+slash match should be skipped") } func TestExtractRoutes_EmptyContent(t *testing.T) { From 5ff52e6fbbe04d07393e0f2131af30c1fc79029a Mon Sep 17 00:00:00 2001 From: descholar-ceo Date: Sun, 12 Apr 2026 13:09:29 +0200 Subject: [PATCH 8/8] Improve test coverage --- internal/generate/commands_runE_test.go | 42 +++++++++++++++++++++---- 1 file changed, 36 insertions(+), 6 deletions(-) diff --git a/internal/generate/commands_runE_test.go b/internal/generate/commands_runE_test.go index e25c891..ec06d4e 100644 --- a/internal/generate/commands_runE_test.go +++ b/internal/generate/commands_runE_test.go @@ -81,13 +81,14 @@ func InitApiRoutes(config *RouteConfig) *mux.Router { } `), 0644)) - // serve.go (for PatchServeFile) - require.NoError(t, os.MkdirAll("app/commands", 0755)) - require.NoError(t, os.WriteFile("app/commands/serve.go", []byte(`package commands + // serve.go (for PatchServeFile) — must be at cmd/serve.go with the + // exact marker string PatchServeFile looks for. + require.NoError(t, os.MkdirAll("cmd", 0755)) + require.NoError(t, os.WriteFile("cmd/serve.go", []byte(`package cmd -func Serve() { +func startServer() { cfg := &routes.RouteConfig{ - // controllers + HealthController: healthController, } _ = cfg } @@ -210,7 +211,28 @@ func TestControllerCmd_RunE(t *testing.T) { func TestScaffoldCmd_RunE(t *testing.T) { setupFullProject(t) fakeExecOK(t) - _ = scaffoldCmd.RunE(scaffoldCmd, []string{"Widget", "name:string"}) + err := scaffoldCmd.RunE(scaffoldCmd, []string{"Widget", "name:string"}) + assert.NoError(t, err) +} + +func TestScaffoldCmd_RunE_Failure(t *testing.T) { + // Don't call setupFullProject — use a bare temp dir so the first + // patcher (PatchContainer) fails when it can't read app/di/container.go. + // This exercises the `if err := RunSteps(...); err != nil { return err }` + // error branch in scaffold's RunE. + setupTempProject(t) + fakeExecOK(t) + err := scaffoldCmd.RunE(scaffoldCmd, []string{"Broken", "x:string"}) + assert.Error(t, err) +} + +func TestScaffoldCmd_RunE_WithSwagger(t *testing.T) { + setupFullProject(t) + fakeExecOK(t) + require.NoError(t, scaffoldCmd.Flags().Set("swagger", "true")) + t.Cleanup(func() { _ = scaffoldCmd.Flags().Set("swagger", "false") }) + err := scaffoldCmd.RunE(scaffoldCmd, []string{"Order", "total:float"}) + assert.NoError(t, err) } func TestWireCmd_RunE(t *testing.T) { @@ -237,3 +259,11 @@ func TestHasGraphQLFlag(t *testing.T) { assert.True(t, hasGraphQLFlag(scaffoldCmd)) scaffoldCmd.Flags().Set("gql", "false") } + +// hasSwaggerFlag branches +func TestHasSwaggerFlag(t *testing.T) { + assert.False(t, hasSwaggerFlag(scaffoldCmd)) + scaffoldCmd.Flags().Set("swagger", "true") + assert.True(t, hasSwaggerFlag(scaffoldCmd)) + scaffoldCmd.Flags().Set("swagger", "false") +}