package main import ( "context" "database/sql" "fmt" "net/url" "os" "strings" "github.com/danielgtaylor/huma/v2" "github.com/danielgtaylor/huma/v2/adapters/humaecho" "github.com/golang-migrate/migrate/v4" _ "github.com/golang-migrate/migrate/v4/database/pgx/v5" "github.com/golang-migrate/migrate/v4/source/iofs" "github.com/jackc/pgx/v5/pgxpool" _ "github.com/jackc/pgx/v5/stdlib" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" "completed/db/migrations" "completed/internal/api" "completed/internal/db" ) // ensureDatabase creates the target database if it doesn't exist yet, by // connecting to the server's default "postgres" database first. This is a // convenience for local/compose use; a real deployment would provision the // database up front and this would be a no-op every time. func ensureDatabase(dbURL string) error { u, err := url.Parse(dbURL) if err != nil { return fmt.Errorf("parse DATABASE_URL: %w", err) } dbName := strings.TrimPrefix(u.Path, "/") if dbName == "" { return fmt.Errorf("database name is empty in URL: %s", dbURL) } u.Path = "/postgres" adminDB, err := sql.Open("pgx", u.String()) if err != nil { return fmt.Errorf("open admin connection: %w", err) } defer adminDB.Close() var exists bool err = adminDB.QueryRow("SELECT EXISTS(SELECT 1 FROM pg_database WHERE datname = $1)", dbName).Scan(&exists) if err != nil { return fmt.Errorf("check database existence: %w", err) } if exists { return nil } fmt.Printf("Database %q does not exist. Creating...\n", dbName) // Table/database identifiers can't be parameterized; dbName comes from // our own DATABASE_URL, not untrusted request input. if _, err := adminDB.Exec(fmt.Sprintf(`CREATE DATABASE "%s"`, dbName)); err != nil { return fmt.Errorf("create database %q: %w", dbName, err) } fmt.Printf("Database %q created.\n", dbName) return nil } // runMigrations applies pending migrations, embedded into the binary via // db/migrations/embed.go. A failure here is fatal: the alternative is an API // that starts and answers requests against a schema that was never created. func runMigrations(dbURL string) error { source, err := iofs.New(migrations.FS, ".") if err != nil { return fmt.Errorf("load embedded migrations: %w", err) } // golang-migrate's pgx/v5 driver is registered under the "pgx5" scheme; // swap it in regardless of whether DATABASE_URL says postgres:// or // postgresql://. u, err := url.Parse(dbURL) if err != nil { return fmt.Errorf("parse DATABASE_URL: %w", err) } u.Scheme = "pgx5" m, err := migrate.NewWithSourceInstance("iofs", source, u.String()) if err != nil { return fmt.Errorf("init migrator: %w", err) } defer m.Close() if err := m.Up(); err != nil && err != migrate.ErrNoChange { return fmt.Errorf("apply migrations: %w", err) } fmt.Println("Migrations applied.") return nil } // reservedAPIPrefixes are the paths the API itself owns: everything under // /api, plus what Huma's default config registers for docs/schemas. The // web UI's SPA fallback (below) must never shadow these. var reservedAPIPrefixes = []string{"/api/", "/docs", "/schemas/", "/openapi.json", "/openapi.yaml", "/hello"} // registerWebUI serves the Angular build as a single-page app if one has // been placed at dir (WEB_DIST_DIR, default /frontend - matching where the // Dockerfile's web build stage copies it to). Unknown paths fall back to // index.html so Angular's router can handle a direct link to e.g. /games. // // Outside the Docker image, dir won't exist; this is a deliberate no-op in // that case; so `go run ./cmd/api` and the CI build keep working without // ever needing Node, and the API alone can still be deployed without a UI. func registerWebUI(e *echo.Echo) { dir := os.Getenv("WEB_DIST_DIR") if dir == "" { dir = "/frontend" } if info, err := os.Stat(dir); err != nil || !info.IsDir() { fmt.Printf("WEB_DIST_DIR %q not found; serving the API only, no web UI.\n", dir) return } e.Use(middleware.StaticWithConfig(middleware.StaticConfig{ Root: dir, HTML5: true, Skipper: func(c echo.Context) bool { p := c.Request().URL.Path for _, prefix := range reservedAPIPrefixes { if strings.HasPrefix(p, prefix) { return true } } return false }, })) fmt.Printf("Serving the web UI from %q.\n", dir) } func main() { dbURL := os.Getenv("DATABASE_URL") if dbURL == "" { // Matches docker-compose.yaml's db service, so `just run-api` // against `just docker-up`'s database works without extra setup. dbURL = "postgres://postgres:password@localhost:5432/gamedb?sslmode=disable" } if err := ensureDatabase(dbURL); err != nil { fmt.Fprintf(os.Stderr, "Failed to ensure database exists: %v\n", err) os.Exit(1) } if err := runMigrations(dbURL); err != nil { fmt.Fprintf(os.Stderr, "Failed to run migrations: %v\n", err) os.Exit(1) } pool, err := pgxpool.New(context.Background(), dbURL) if err != nil { fmt.Fprintf(os.Stderr, "Unable to create connection pool: %v\n", err) os.Exit(1) } defer pool.Close() if err := pool.Ping(context.Background()); err != nil { fmt.Fprintf(os.Stderr, "Unable to connect to database: %v\n", err) os.Exit(1) } queries := db.New(pool) e := echo.New() e.Use(middleware.Logger()) e.Use(middleware.Recover()) // The web client (e.g. the Angular dev server on localhost:4200) calls // this API from a different origin. It authenticates with a bearer // token, not cookies, so this doesn't need AllowCredentials - which // means a wildcard origin is safe here. Tighten AllowOrigins to the // real deployed origin(s) if this ever serves more than local dev. e.Use(middleware.CORSWithConfig(middleware.CORSConfig{ AllowOrigins: []string{"*"}, AllowHeaders: []string{echo.HeaderContentType, echo.HeaderAuthorization}, })) humaApi := humaecho.New(e, huma.DefaultConfig("Completed API", "1.0.0")) api.RegisterRoutes(humaApi, queries) registerWebUI(e) port := os.Getenv("PORT") if port == "" { port = "8080" } e.Logger.Fatal(e.Start(":" + port)) }