diff --git a/README.md b/README.md index d270a2b..14128ad 100644 --- a/README.md +++ b/README.md @@ -1 +1,17 @@ -# NBA_Go \ No newline at end of file +# NBA_Go + +### First‑time bootstrap + +```bash +# 1. build + run +docker-compose up --build -d + +# 2. create API key (ADMIN_SECRET is loaded from .env) +curl -XPOST http://localhost:8080/admin/keys \ + -H "X-Admin-Secret: $ADMIN_SECRET" \ + -d '{"label":"local-test"}' +# → { "id":1, "apiKey":"ab12cd…" } + +# 3. call a protected endpoint +curl http://localhost:8080/api/playeradvancedstats \ + -H "X-API-Key: ab12cd…" \ No newline at end of file diff --git a/cmd/generate_key.go b/cmd/generate_key.go new file mode 100644 index 0000000..214a6d5 --- /dev/null +++ b/cmd/generate_key.go @@ -0,0 +1,11 @@ +package main + +import ( + "fmt" + "github.com/nprasad2077/NBA_Go/utils/security" +) + +func main() { + raw, _ := security.GenerateRawKey() + fmt.Println(raw) +} \ No newline at end of file diff --git a/config/database.go b/config/database.go index 9069941..d4e4f25 100644 --- a/config/database.go +++ b/config/database.go @@ -21,6 +21,8 @@ func InitDB() *gorm.DB { // Auto migrate models db.AutoMigrate(&models.PlayerAdvancedStat{}) db.AutoMigrate(&models.PlayerTotalStat{}) + + db.AutoMigrate(&models.APIKey{}) metrics.DBOperationsTotal.WithLabelValues("migrate", "database").Inc() diff --git a/controllers/api_key_controller.go b/controllers/api_key_controller.go new file mode 100644 index 0000000..cbd7d01 --- /dev/null +++ b/controllers/api_key_controller.go @@ -0,0 +1,62 @@ +package controllers + +import ( + "net/http" + "os" + + "github.com/gofiber/fiber/v2" + "github.com/nprasad2077/NBA_Go/models" + "github.com/nprasad2077/NBA_Go/utils/security" + "gorm.io/gorm" +) + +// very small middleware: require header X‑Admin‑Secret == $ADMIN_SECRET +func adminGuard() fiber.Handler { + secret := os.Getenv("ADMIN_SECRET") + return func(c *fiber.Ctx) error { + if c.Get("X-Admin-Secret") != secret { + return c.SendStatus(http.StatusUnauthorized) + } + return c.Next() + } +} + +func RegisterKeyAdminRoutes(app *fiber.App, db *gorm.DB) { + admin := app.Group("/admin/keys", adminGuard()) + + admin.Get("/", func(c *fiber.Ctx) error { + var keys []models.APIKey + db.Find(&keys) + return c.JSON(keys) + }) + + admin.Post("/", func(c *fiber.Ctx) error { + var body struct{ Label string } + if err := c.BodyParser(&body); err != nil { + return err + } + + raw, err := security.GenerateRawKey() + if err != nil { + return err + } + + key := models.APIKey{ + Hash: security.HashKey(raw), + Label: body.Label, + } + db.Create(&key) + + // return ONLY the raw key once + return c.JSON(fiber.Map{ + "apiKey": raw, + "id": key.ID, + }) + }) + + admin.Post("/:id/revoke", func(c *fiber.Ctx) error { + id := c.Params("id") + db.Model(&models.APIKey{}).Where("id = ?", id).Update("revoked", true) + return c.JSON(fiber.Map{"revoked": id}) + }) +} \ No newline at end of file diff --git a/controllers/player_advanced_controller.go b/controllers/player_advanced_controller.go index e0e1477..147532d 100644 --- a/controllers/player_advanced_controller.go +++ b/controllers/player_advanced_controller.go @@ -24,6 +24,7 @@ func FetchPlayerAdvancedStats(db *gorm.DB) fiber.Handler { // GetAllPlayerStats godoc +// @Security ApiKeyAuth // @Summary Get player stats // @Description Returns filtered and paginated player stats // @Tags PlayerStats diff --git a/controllers/player_total_controller.go b/controllers/player_total_controller.go index 3424234..71cf63c 100644 --- a/controllers/player_total_controller.go +++ b/controllers/player_total_controller.go @@ -33,6 +33,7 @@ func FetchPlayerTotalStats(db *gorm.DB) fiber.Handler { // GetPlayerTotalStats godoc +// @Security ApiKeyAuth // @Summary Get player total stats // @Description Filter and paginate player totals // @Tags PlayerTotals diff --git a/docker-compose.yml b/docker-compose.yml index 2462cdd..93c6034 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,6 +10,7 @@ services: networks: - api.network api1: + env_file: .env volumes: - './data:/app/data' build: . @@ -22,6 +23,7 @@ services: networks: - api.network api2: + env_file: .env volumes: - './data:/app/data' build: . @@ -34,6 +36,7 @@ services: networks: - api.network api3: + env_file: .env volumes: - './data:/app/data' build: . diff --git a/docs/api_doc.go b/docs/api_doc.go new file mode 100644 index 0000000..fabe709 --- /dev/null +++ b/docs/api_doc.go @@ -0,0 +1,27 @@ +//go:build docs +// +build docs + +// Package docs Only contains Swagger annotations that are **global**. +// Run `swag init --parseDependency --parseInternal` after editing. +package docs + +// ------------------------------------------------------------ +// General API meta (kept here so CI can inject version/build). +// ------------------------------------------------------------ + +// @title NBA_Go API +// @version 1.0 +// @description Stats service with API‑key auth +// @BasePath / +// @schemes http https + +// ------------------------------------------------------------ +// 🔐 SECURITY – this block is what you asked for +// ------------------------------------------------------------ +// +// @securityDefinitions.apikey ApiKeyAuth +// @in header +// @name X-API-Key +// +// (every protected endpoint still needs `@Security ApiKeyAuth`) +// ------------------------------------------------------------ \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go index 9383a90..7e18700 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -17,6 +17,11 @@ const docTemplate = `{ "paths": { "/api/playeradvancedstats": { "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], "description": "Returns filtered and paginated player stats", "consumes": [ "application/json" @@ -87,6 +92,11 @@ const docTemplate = `{ }, "/api/playertotals": { "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], "description": "Filter and paginate player totals", "consumes": [ "application/json" diff --git a/docs/swagger.json b/docs/swagger.json index 24002ea..60820de 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -6,6 +6,11 @@ "paths": { "/api/playeradvancedstats": { "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], "description": "Returns filtered and paginated player stats", "consumes": [ "application/json" @@ -76,6 +81,11 @@ }, "/api/playertotals": { "get": { + "security": [ + { + "ApiKeyAuth": [] + } + ], "description": "Filter and paginate player totals", "consumes": [ "application/json" diff --git a/docs/swagger.yaml b/docs/swagger.yaml index be018ff..736d0b4 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -45,6 +45,8 @@ paths: schema: additionalProperties: true type: object + security: + - ApiKeyAuth: [] summary: Get player stats tags: - PlayerStats @@ -98,6 +100,8 @@ paths: additionalProperties: type: string type: object + security: + - ApiKeyAuth: [] summary: Get player total stats tags: - PlayerTotals diff --git a/main.go b/main.go index f57d8f2..cb430a2 100644 --- a/main.go +++ b/main.go @@ -1,36 +1,65 @@ package main import ( - "log" - "time" - - "github.com/gofiber/fiber/v2" - "github.com/gofiber/fiber/v2/middleware/logger" - "github.com/gofiber/fiber/v2/middleware/adaptor" - "github.com/nprasad2077/NBA_Go/config" - "github.com/nprasad2077/NBA_Go/routes" + "context" + "errors" + "log" + "os" + "os/signal" + "syscall" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/adaptor" + "github.com/gofiber/fiber/v2/middleware/logger" + "github.com/nprasad2077/NBA_Go/config" "github.com/nprasad2077/NBA_Go/services" - "github.com/nprasad2077/NBA_Go/utils/middleware" - _ "github.com/nprasad2077/NBA_Go/docs" - fiberswagger "github.com/swaggo/fiber-swagger" - "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/nprasad2077/NBA_Go/controllers" + "github.com/nprasad2077/NBA_Go/routes" + "github.com/nprasad2077/NBA_Go/utils/middleware" + _ "github.com/nprasad2077/NBA_Go/docs" + fiberswagger "github.com/swaggo/fiber-swagger" + "github.com/prometheus/client_golang/prometheus/promhttp" + "net/http" ) func main() { - app := fiber.New() + // graceful shutdown context + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + defer stop() - // Add standard logger middleware - app.Use(logger.New()) - - // Add metrics middleware - app.Use(middleware.MetricsMiddleware()) + app := fiber.New(fiber.Config{ + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + ErrorHandler: func(c *fiber.Ctx, err error) error { + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{"error": err.Error()}) + }, + }) - // Initialize database - db := config.InitDB() + // middlewares + app.Use(logger.New()) + app.Use(middleware.MetricsMiddleware()) - // Original data fetching code, no environment variable check - go func() { - for season := 1993; season <= 2025; season++ { + // DB + db := config.InitDB() + + /* ---------- PUBLIC ROUTES (no API key) ---------- */ + app.Get("/metrics", adaptor.HTTPHandler(promhttp.Handler())) + app.Get("/swagger/*", fiberswagger.WrapHandler) + controllers.RegisterKeyAdminRoutes(app, db) // guarded only by X‑Admin‑Secret + + /* ---------- PROTECTED ROUTES ---------- */ + app.Use(middleware.APIKeyAuth(db)) + routes.RegisterPlayerAdvancedRoutes(app, db) + routes.RegisterPlayerTotalRoutes(app, db) + + /* ---------- Optional import job ---------- */ + go func() { + for season := 2023; season <= 2025; season++ { if err := services.FetchAndStorePlayerAdvancedStats(db, season); err != nil { log.Printf("Fetch failed for player advanced season %d: %v\n", season, err) } else { @@ -42,7 +71,7 @@ func main() { }() go func() { - for season := 1993; season <= 2025; season++ { + for season := 2023; season <= 2025; season++ { if err := services.FetchAndStorePlayerTotalStats(db, season); err != nil { log.Printf("Fetch failed for player totals season %d: %v\n", season, err) } else { @@ -53,15 +82,16 @@ func main() { log.Printf("player totals Import Success") }() - // Add Prometheus metrics endpoint - app.Get("/metrics", adaptor.HTTPHandler(promhttp.Handler())) + /* ---------- START & SHUTDOWN ---------- */ + go func() { + if err := app.Listen(":5000"); err != nil && !errors.Is(err, http.ErrServerClosed) { + log.Fatalf("listen: %v", err) + } + }() - // Register routes - routes.RegisterPlayerAdvancedRoutes(app, db) - routes.RegisterPlayerTotalRoutes(app, db) - - // Swagger endpoint - app.Get("/swagger/*", fiberswagger.WrapHandler) - - app.Listen(":5000") -} \ No newline at end of file + <-ctx.Done() // wait for SIGTERM/CTRL‑C + stop() // stop receiving more signals + log.Println("shutting down…") + _ = app.Shutdown() // stop accepting new conns + log.Println("bye") +} diff --git a/main_test.go b/main_test.go index 4f7ced7..8fb7a21 100644 --- a/main_test.go +++ b/main_test.go @@ -1,52 +1,79 @@ +// main_test.go package main import ( "io" "net/http" "testing" - "github.com/stretchr/testify/assert" + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "gorm.io/driver/sqlite" + "gorm.io/gorm" + + "github.com/nprasad2077/NBA_Go/models" "github.com/nprasad2077/NBA_Go/routes" - "github.com/nprasad2077/NBA_Go/config" + "github.com/nprasad2077/NBA_Go/utils/security" ) -func setupTestApp() *fiber.App { +// ----------------------------------------------------------------------------- +// test bootstrap +// ----------------------------------------------------------------------------- +func setupTestApp() (*fiber.App, string) { app := fiber.New() - db := config.InitDB() // uses SQLite file; can be mocked or in-memory for advanced testing + + // in‑memory SQLite so tests don’t touch real file + db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) + if err != nil { + panic(err) + } + _ = db.AutoMigrate(&models.PlayerAdvancedStat{}, &models.APIKey{}) + + // seed one API key we can use in the requests + rawKey := "testkey123" + db.Create(&models.APIKey{Hash: security.HashKey(rawKey)}) + + // register only the routes we need routes.RegisterPlayerAdvancedRoutes(app, db) - return app + + return app, rawKey } +// ----------------------------------------------------------------------------- +// actual test +// ----------------------------------------------------------------------------- func TestGetPlayerAdvancedStats(t *testing.T) { - app := setupTestApp() + app, key := setupTestApp() tests := []struct { - description string + name string route string - expectedCode int - expectInBody string + wantCode int + wantSubstring string }{ { - description: "valid route", - route: "/api/playeradvancedstats", - expectedCode: 200, - expectInBody: `"data"`, // assuming a JSON object with "data" + name: "valid route", + route: "/api/playeradvancedstats/", + wantCode: 200, + wantSubstring: `"data"`, // expect JSON payload has "data" }, { - description: "invalid route", - route: "/api/invalid", - expectedCode: 404, - expectInBody: "Cannot GET /api/invalid", + name: "invalid route", + route: "/api/invalid", + wantCode: 404, + wantSubstring: "Cannot GET /api/invalid", }, } for _, tc := range tests { - req, _ := http.NewRequest("GET", tc.route, nil) + req, _ := http.NewRequest(http.MethodGet, tc.route, nil) + req.Header.Set("X-API-Key", key) + resp, err := app.Test(req, -1) - assert.Nil(t, err, tc.description) - assert.Equal(t, tc.expectedCode, resp.StatusCode, tc.description) + assert.NoError(t, err, tc.name) + assert.Equal(t, tc.wantCode, resp.StatusCode, tc.name) body, _ := io.ReadAll(resp.Body) - assert.Contains(t, string(body), tc.expectInBody, tc.description) + assert.Contains(t, string(body), tc.wantSubstring, tc.name) } } \ No newline at end of file diff --git a/models/api_key.go b/models/api_key.go new file mode 100644 index 0000000..7aa2720 --- /dev/null +++ b/models/api_key.go @@ -0,0 +1,17 @@ +package models + +import ( + "time" + + "gorm.io/gorm" +) + +// APIKey is stored **hashed** (SHA‑256) and may be revoked at any time. +type APIKey struct { + ID uint `gorm:"primaryKey"` + Hash []byte `gorm:"uniqueIndex"` + Label string // e.g. “mobile‑app”, “data‑partner‑X” + Revoked bool + CreatedAt time.Time + RevokedAt gorm.DeletedAt `gorm:"index"` +} \ No newline at end of file diff --git a/utils/http_client.go b/utils/http_client.go index 05d4d37..2dcc07f 100644 --- a/utils/http_client.go +++ b/utils/http_client.go @@ -1,16 +1,21 @@ package utils import ( + "context" "errors" - "io/ioutil" + "io" "net/http" "time" ) func GetJSON(url string) ([]byte, error) { - client := &http.Client{Timeout: 10 * time.Second} + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() - resp, err := client.Get(url) + req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + client := &http.Client{} + + resp, err := client.Do(req) if err != nil { return nil, err } @@ -20,7 +25,7 @@ func GetJSON(url string) ([]byte, error) { return nil, errors.New("received non-200 response: " + resp.Status) } - body, err := ioutil.ReadAll(resp.Body) + body, err := io.ReadAll(resp.Body) if err != nil { return nil, err } diff --git a/utils/middleware/api_key_middleware.go b/utils/middleware/api_key_middleware.go new file mode 100644 index 0000000..c561851 --- /dev/null +++ b/utils/middleware/api_key_middleware.go @@ -0,0 +1,35 @@ +package middleware + +import ( + "crypto/subtle" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/keyauth" + "github.com/nprasad2077/NBA_Go/models" + "github.com/nprasad2077/NBA_Go/utils/security" + "gorm.io/gorm" +) + +// APIKeyAuth returns a Fiber Handler that validates X‑API‑Key +func APIKeyAuth(db *gorm.DB) fiber.Handler { + return keyauth.New(keyauth.Config{ + KeyLookup: "header:X-API-Key", + Validator: func(c *fiber.Ctx, rawKey string) (bool, error) { + var rec models.APIKey + hash := security.HashKey(rawKey) + + err := db. + Where("hash = ? AND revoked = FALSE", hash). + First(&rec).Error + if err != nil { + return false, keyauth.ErrMissingOrMalformedAPIKey + } + if subtle.ConstantTimeCompare(rec.Hash, hash) != 1 { + return false, keyauth.ErrMissingOrMalformedAPIKey + } + // put the key ID on the context for rate‑limiting or logging later + c.Locals("apiKeyID", rec.ID) + return true, nil + }, + }) +} \ No newline at end of file diff --git a/utils/security/api_key.go b/utils/security/api_key.go new file mode 100644 index 0000000..3cf871a --- /dev/null +++ b/utils/security/api_key.go @@ -0,0 +1,23 @@ +package security + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/hex" +) + +// GenerateRawKey returns a 32‑byte cryptographically‑random string +func GenerateRawKey() (string, error) { + b := make([]byte, 32) + _, err := rand.Read(b) + if err != nil { + return "", err + } + return hex.EncodeToString(b), nil +} + +// HashKey returns the SHA‑256 hash suitable for storage. +func HashKey(raw string) []byte { + h := sha256.Sum256([]byte(raw)) + return h[:] +} \ No newline at end of file