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
42 changes: 38 additions & 4 deletions cmd/main.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Package main provides the entry point for the Sulawesi Tengah COVID-19 Data API
//
// @title Sulawesi Tengah COVID-19 Data API
// @version 2.7.0
// @version 2.8.0
// @description A comprehensive REST API for COVID-19 data in Sulawesi Tengah (Central Sulawesi), with additional national and provincial data for context. Features enhanced ODP/PDP grouping, hybrid pagination, and rate limiting protection. Rate limiting: 100 requests per minute per IP address by default, with appropriate HTTP headers for client guidance.
// @termsOfService http://swagger.io/terms/
//
Expand Down Expand Up @@ -34,15 +34,16 @@ import (
"fmt"
"log"
"net/http"

"os"
"time"

"github.com/banua-coder/pico-api-go/docs"
"github.com/banua-coder/pico-api-go/internal/config"
"github.com/banua-coder/pico-api-go/internal/handler"
"github.com/banua-coder/pico-api-go/internal/middleware"
"github.com/banua-coder/pico-api-go/internal/repository"
"github.com/banua-coder/pico-api-go/internal/service"
"github.com/banua-coder/pico-api-go/pkg/cache"
"github.com/banua-coder/pico-api-go/pkg/database"
)

Expand All @@ -65,15 +66,47 @@ func main() {
provinceRepo := repository.NewProvinceRepository(db)
provinceCaseRepo := repository.NewProvinceCaseRepository(db)

covidService := service.NewCovidService(nationalCaseRepo, provinceRepo, provinceCaseRepo)
// Initialize cache — use Redis-backed dual-layer if REDIS_ADDR is set, otherwise in-memory only
var c *cache.Cache
var cacheInvalidator service.CacheInvalidator

redisAddr := os.Getenv("REDIS_ADDR")
if redisAddr != "" {
rac, err := cache.NewRedisAwareCache(time.Hour, cache.RedisOptions{
Addr: redisAddr,
Password: os.Getenv("REDIS_PASSWORD"),
DB: 0,
})
if err != nil {
log.Printf("Redis unavailable (%v), falling back to in-memory cache only", err)
c = cache.New(time.Hour)
cacheInvalidator = c
} else {
log.Printf("Redis connected: %s (dual-layer cache active)", redisAddr)
c = rac.Unwrap()
cacheInvalidator = rac
}
} else {
c = cache.New(time.Hour)
cacheInvalidator = c
}
c.StartCleanup(5 * time.Minute)

covidService := service.NewCachedCovidService(
service.NewCovidService(nationalCaseRepo, provinceRepo, provinceCaseRepo),
c,
)

// New repositories and services for migrated Lumen endpoints
regencyRepo := repository.NewRegencyRepository(db)
regencyCaseRepo := repository.NewRegencyCaseRepository(db)
hospitalRepo := repository.NewHospitalRepository(db)
taskForceRepo := repository.NewTaskForceRepository(db)

regencyService := service.NewRegencyService(regencyRepo, regencyCaseRepo)
regencyService := service.NewCachedRegencyService(
service.NewRegencyService(regencyRepo, regencyCaseRepo),
c,
)
hospitalService := service.NewHospitalService(hospitalRepo)
taskForceService := service.NewTaskForceService(taskForceRepo)

Expand All @@ -98,6 +131,7 @@ func main() {
svc := handler.Services{
CovidService: covidService,
RegencyService: regencyService,
CacheInvalidator: cacheInvalidator,
HospitalService: hospitalService,
TaskForceService: taskForceService,
VaccinationService: vaccinationService,
Expand Down
4 changes: 4 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ require (
github.com/go-sql-driver/mysql v1.9.3
github.com/gorilla/mux v1.8.1
github.com/joho/godotenv v1.5.1
github.com/redis/go-redis/v9 v9.18.0
github.com/stretchr/testify v1.11.1
github.com/swaggo/http-swagger v1.3.4
github.com/swaggo/swag v1.16.6
Expand All @@ -17,7 +18,9 @@ require (
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/KyleBanks/depth v1.2.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/go-openapi/jsonpointer v0.22.0 // indirect
github.com/go-openapi/jsonreference v0.21.1 // indirect
github.com/go-openapi/spec v0.21.0 // indirect
Expand All @@ -39,6 +42,7 @@ require (
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/stretchr/objx v0.5.2 // indirect
github.com/swaggo/files v1.0.1 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/mod v0.27.0 // indirect
golang.org/x/net v0.43.0 // indirect
golang.org/x/sync v0.17.0 // indirect
Expand Down
16 changes: 16 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,16 @@ github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7Oputl
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc=
github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/go-openapi/jsonpointer v0.22.0 h1:TmMhghgNef9YXxTu1tOopo+0BGEytxA+okbry0HjZsM=
github.com/go-openapi/jsonpointer v0.22.0/go.mod h1:xt3jV88UtExdIkkL7NloURjRQjbeUgcxFblMjq2iaiU=
github.com/go-openapi/jsonreference v0.21.1 h1:bSKrcl8819zKiOgxkbVNRUBIr6Wwj9KYrDbMjRs0cDA=
Expand Down Expand Up @@ -47,6 +55,8 @@ github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwA
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/klauspost/cpuid/v2 v2.0.9 h1:lgaqFMSdTdQYdZ04uHyN2d/eKdOMyi2YLSvlQIBFYa4=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
Expand All @@ -55,6 +65,8 @@ github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
Expand All @@ -68,6 +80,10 @@ github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4
github.com/swaggo/swag v1.16.6 h1:qBNcx53ZaX+M5dxVyTrgQ0PJ/ACK+NzhwcbieTt+9yI=
github.com/swaggo/swag v1.16.6/go.mod h1:ngP2etMK5a0P3QBizic5MEwpRmluJZPHjXcMoj4Xesg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
Expand Down
42 changes: 42 additions & 0 deletions internal/handler/admin_handler.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package handler

import (
"net/http"
"os"

"github.com/banua-coder/pico-api-go/internal/service"
)

// AdminHandler handles admin endpoints.
type AdminHandler struct {
invalidator service.CacheInvalidator
}

// NewAdminHandler creates a new AdminHandler.
func NewAdminHandler(invalidator service.CacheInvalidator) *AdminHandler {
return &AdminHandler{invalidator: invalidator}
}

// ClearCache godoc
//
// @Summary Clear all in-memory cache
// @Description Clears all cached data. Requires X-Admin-Key header matching ADMIN_KEY env var.
// @Tags admin
// @Produce json
// @Param X-Admin-Key header string true "Admin key"
// @Success 200 {object} map[string]string
// @Failure 401 {object} map[string]string
// @Router /admin/cache/clear [post]
func (h *AdminHandler) ClearCache(w http.ResponseWriter, r *http.Request) {
adminKey := os.Getenv("ADMIN_KEY")
if adminKey == "" || r.Header.Get("X-Admin-Key") != adminKey {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusUnauthorized)
w.Write([]byte(`{"error":"unauthorized"}`)) //nolint:errcheck
return
}
h.invalidator.Clear()
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"message":"cache cleared"}`)) //nolint:errcheck
}
92 changes: 92 additions & 0 deletions internal/handler/admin_handler_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package handler

import (
"net/http"
"net/http/httptest"
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)

type MockCacheInvalidator struct {
mock.Mock
}

func (m *MockCacheInvalidator) Clear() {
m.Called()
}

func TestNewAdminHandler(t *testing.T) {
invalidator := new(MockCacheInvalidator)
h := NewAdminHandler(invalidator)
assert.NotNil(t, h)
}

func TestAdminHandler_ClearCache_Success(t *testing.T) {
t.Setenv("ADMIN_KEY", "test-secret-key")

invalidator := new(MockCacheInvalidator)
invalidator.On("Clear").Once()

h := NewAdminHandler(invalidator)

req := httptest.NewRequest(http.MethodPost, "/admin/cache/clear", nil)
req.Header.Set("X-Admin-Key", "test-secret-key")
w := httptest.NewRecorder()

h.ClearCache(w, req)

assert.Equal(t, http.StatusOK, w.Code)
assert.Contains(t, w.Body.String(), "cache cleared")
invalidator.AssertExpectations(t)
}

func TestAdminHandler_ClearCache_WrongKey(t *testing.T) {
t.Setenv("ADMIN_KEY", "test-secret-key")

invalidator := new(MockCacheInvalidator)
h := NewAdminHandler(invalidator)

req := httptest.NewRequest(http.MethodPost, "/admin/cache/clear", nil)
req.Header.Set("X-Admin-Key", "wrong-key")
w := httptest.NewRecorder()

h.ClearCache(w, req)

assert.Equal(t, http.StatusUnauthorized, w.Code)
assert.Contains(t, w.Body.String(), "unauthorized")
invalidator.AssertNotCalled(t, "Clear")
}

func TestAdminHandler_ClearCache_NoKey(t *testing.T) {
t.Setenv("ADMIN_KEY", "test-secret-key")

invalidator := new(MockCacheInvalidator)
h := NewAdminHandler(invalidator)

req := httptest.NewRequest(http.MethodPost, "/admin/cache/clear", nil)
w := httptest.NewRecorder()

h.ClearCache(w, req)

assert.Equal(t, http.StatusUnauthorized, w.Code)
invalidator.AssertNotCalled(t, "Clear")
}

func TestAdminHandler_ClearCache_EmptyAdminKeyEnv(t *testing.T) {
t.Setenv("ADMIN_KEY", "")

invalidator := new(MockCacheInvalidator)
h := NewAdminHandler(invalidator)

req := httptest.NewRequest(http.MethodPost, "/admin/cache/clear", strings.NewReader(""))
req.Header.Set("X-Admin-Key", "any-key")
w := httptest.NewRecorder()

h.ClearCache(w, req)

assert.Equal(t, http.StatusUnauthorized, w.Code)
invalidator.AssertNotCalled(t, "Clear")
}
4 changes: 2 additions & 2 deletions internal/handler/covid_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,7 +350,7 @@ func (h *CovidHandler) HealthCheck(w http.ResponseWriter, r *http.Request) {
health := map[string]interface{}{
"status": "healthy",
"service": "COVID-19 API",
"version": "2.7.0",
"version": "2.8.0",
"timestamp": time.Now().UTC().Format(time.RFC3339),
}

Expand Down Expand Up @@ -459,7 +459,7 @@ func (h *CovidHandler) GetAPIIndex(w http.ResponseWriter, r *http.Request) {
endpoints := map[string]interface{}{
"api": map[string]interface{}{
"title": "Sulawesi Tengah COVID-19 Data API",
"version": "2.7.0",
"version": "2.8.0",
"description": "A comprehensive REST API for COVID-19 data in Sulawesi Tengah (Central Sulawesi)",
},
"documentation": map[string]interface{}{
Expand Down
4 changes: 2 additions & 2 deletions internal/handler/covid_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -645,7 +645,7 @@ func TestCovidHandler_GetAPIIndex(t *testing.T) {
apiInfo, ok := data["api"].(map[string]interface{})
assert.True(t, ok)
assert.Equal(t, "Sulawesi Tengah COVID-19 Data API", apiInfo["title"])
assert.Equal(t, "2.7.0", apiInfo["version"])
assert.Equal(t, "2.8.0", apiInfo["version"])

// Verify endpoints structure
endpoints, ok := data["endpoints"].(map[string]interface{})
Expand Down Expand Up @@ -676,7 +676,7 @@ func TestCovidHandler_HealthCheck(t *testing.T) {
assert.True(t, ok)
assert.Equal(t, "degraded", data["status"])
assert.Equal(t, "COVID-19 API", data["service"])
assert.Equal(t, "2.7.0", data["version"])
assert.Equal(t, "2.8.0", data["version"])
assert.Contains(t, data, "database")

dbData, ok := data["database"].(map[string]interface{})
Expand Down
9 changes: 8 additions & 1 deletion internal/handler/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,12 @@ import (
// Services holds all service dependencies for route setup
type Services struct {
CovidService service.CovidService
RegencyService *service.RegencyService
RegencyService service.RegencyServiceInterface
HospitalService *service.HospitalService
TaskForceService *service.TaskForceService
VaccinationService *service.VaccinationService
ProvinceStatsService service.ProvinceStatsServiceInterface
CacheInvalidator service.CacheInvalidator
}

func SetupRoutes(svc Services, db *database.DB, enableSwagger bool) *mux.Router {
Expand Down Expand Up @@ -78,6 +79,12 @@ func SetupRoutes(svc Services, db *database.DB, enableSwagger bool) *mux.Router
api.HandleFunc("/stats/test-types", statsHandler.GetTestTypes).Methods("GET", "OPTIONS")
}

// Admin endpoints
if svc.CacheInvalidator != nil {
adminHandler := NewAdminHandler(svc.CacheInvalidator)
router.HandleFunc("/admin/cache/clear", adminHandler.ClearCache).Methods("POST", "OPTIONS")
}

// Conditionally add Swagger documentation based on environment
if enableSwagger {
router.PathPrefix("/swagger/").Handler(httpSwagger.WrapHandler)
Expand Down
Loading
Loading