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
40 changes: 39 additions & 1 deletion internal/commands/commands_exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))

Expand Down Expand Up @@ -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"
Expand Down
54 changes: 45 additions & 9 deletions internal/commands/dev.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import (
"os"
"os/signal"
"syscall"
"time"

"github.com/gofastadev/cli/internal/commands/configutil"
"github.com/gofastadev/cli/internal/termcolor"
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -93,3 +93,39 @@ 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:\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
// 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()
}
79 changes: 77 additions & 2 deletions internal/commands/dev_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package commands

import (
"fmt"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -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"))
}
11 changes: 10 additions & 1 deletion internal/commands/init_cmd.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
8 changes: 8 additions & 0 deletions internal/commands/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -327,6 +328,13 @@ 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/",
"--parseDependency", "--parseInternal"); err != nil {
termcolor.PrintWarn("Swagger generation skipped (can be run later with: gofasta swagger)")
}

// Initialize git
fmt.Println()
termcolor.PrintStep("🔧 Initializing git repository...")
Expand Down
35 changes: 26 additions & 9 deletions internal/commands/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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
}
32 changes: 32 additions & 0 deletions internal/commands/routes_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,38 @@ 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) {
// 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, "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) {
routes := extractRoutes("package routes", "/api/v1", "empty.routes.go")
assert.Empty(t, routes)
Expand Down
6 changes: 5 additions & 1 deletion internal/commands/swagger.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading
Loading