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
2 changes: 1 addition & 1 deletion 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.4.0
// @version 2.5.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
2 changes: 1 addition & 1 deletion docs/docs.go
Original file line number Diff line number Diff line change
Expand Up @@ -851,7 +851,7 @@ const docTemplate = `{

// SwaggerInfo holds exported Swagger Info so clients can modify it
var SwaggerInfo = &swag.Spec{
Version: "2.4.0",
Version: "2.5.0",
Host: "pico-api.banuacoder.com",
BasePath: "/api/v1",
Schemes: []string{"https", "http"},
Expand Down
2 changes: 1 addition & 1 deletion docs/swagger.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"name": "MIT",
"url": "https://opensource.org/licenses/MIT"
},
"version": "2.4.0"
"version": "2.5.0"
},
"host": "pico-api.banuacoder.com",
"basePath": "/api/v1",
Expand Down
2 changes: 1 addition & 1 deletion docs/swagger.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,7 @@ info:
url: https://opensource.org/licenses/MIT
termsOfService: http://swagger.io/terms/
title: Sulawesi Tengah COVID-19 Data API
version: 2.4.0
version: 2.5.0
paths:
/:
get:
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
module github.com/banua-coder/pico-api-go
// Version: 2.5.0

go 1.24.0

Expand Down
200 changes: 122 additions & 78 deletions internal/handler/covid_handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,64 +25,107 @@ func NewCovidHandler(covidService service.CovidService, db *database.DB) *CovidH

// GetNationalCases godoc
//
// @Summary Get national COVID-19 cases
// @Description Retrieve national COVID-19 cases data with optional date range filtering and sorting
// @Tags national
// @Accept json
// @Produce json
// @Param start_date query string false "Start date (YYYY-MM-DD)"
// @Param end_date query string false "End date (YYYY-MM-DD)"
// @Param sort query string false "Sort by field:order (e.g., date:desc, positive:asc). Default: date:asc"
// @Success 200 {object} Response{data=[]models.NationalCaseResponse}
// @Failure 400 {object} Response
// @Failure 429 {object} Response "Rate limit exceeded"
// @Failure 500 {object} Response
// @Header 200 {string} X-RateLimit-Limit "Request limit per window"
// @Header 200 {string} X-RateLimit-Remaining "Requests remaining in current window"
// @Header 429 {string} X-RateLimit-Reset "Unix timestamp when rate limit resets"
// @Header 429 {string} Retry-After "Seconds to wait before retrying"
// @Router /national [get]
// @Summary Get national COVID-19 cases
// @Description Retrieve national COVID-19 cases data with optional date range filtering, sorting, and pagination
// @Tags national
// @Accept json
// @Produce json
// @Param limit query integer false "Records per page (default: 50, max: 1000)"
// @Param offset query integer false "Records to skip (default: 0)"
// @Param page query integer false "Page number (1-based, alternative to offset)"
// @Param all query boolean false "Return all data without pagination"
// @Param start_date query string false "Start date (YYYY-MM-DD)"
// @Param end_date query string false "End date (YYYY-MM-DD)"
// @Param sort query string false "Sort by field:order (e.g., date:desc, positive:asc). Default: date:asc"
// @Success 200 {object} Response{data=models.PaginatedResponse{data=[]models.NationalCaseResponse}} "Paginated response"
// @Success 200 {object} Response{data=[]models.NationalCaseResponse} "All data response when all=true"
// @Failure 400 {object} Response
// @Failure 429 {object} Response "Rate limit exceeded"
// @Failure 500 {object} Response
// @Header 200 {string} X-RateLimit-Limit "Request limit per window"
// @Header 200 {string} X-RateLimit-Remaining "Requests remaining in current window"
// @Header 429 {string} X-RateLimit-Reset "Unix timestamp when rate limit resets"
// @Header 429 {string} Retry-After "Seconds to wait before retrying"
// @Router /national [get]
func (h *CovidHandler) GetNationalCases(w http.ResponseWriter, r *http.Request) {
// Parse query parameters
limit := utils.ParseIntQueryParam(r, "limit", 50)
offset := utils.ParseIntQueryParam(r, "offset", 0)
all := utils.ParseBoolQueryParam(r, "all")
startDate := r.URL.Query().Get("start_date")
endDate := r.URL.Query().Get("end_date")

// Parse sort parameters (default: date ascending)
sortParams := utils.ParseSortParam(r, "date")

if startDate != "" && endDate != "" {
cases, err := h.covidService.GetNationalCasesByDateRangeSorted(startDate, endDate, sortParams)
// Validate pagination params
limit, offset = utils.ValidatePaginationParams(limit, offset)

if all {
// Return all data without pagination
if startDate != "" && endDate != "" {
cases, err := h.covidService.GetNationalCasesByDateRangeSorted(startDate, endDate, sortParams)
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, err.Error())
return
}
responseData := models.TransformSliceToResponse(cases)
writeSuccessResponse(w, responseData)
return
}

cases, err := h.covidService.GetNationalCasesSorted(sortParams)
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, err.Error())
return
}
// Transform to new response structure
responseData := models.TransformSliceToResponse(cases)
writeSuccessResponse(w, responseData)
return
}

cases, err := h.covidService.GetNationalCasesSorted(sortParams)
// Return paginated data
if startDate != "" && endDate != "" {
cases, total, err := h.covidService.GetNationalCasesByDateRangePaginatedSorted(startDate, endDate, limit, offset, sortParams)
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, err.Error())
return
}
responseData := models.TransformSliceToResponse(cases)
pagination := models.CalculatePaginationMeta(limit, offset, total)
paginatedResponse := models.PaginatedResponse{
Data: responseData,
Pagination: pagination,
}
writeSuccessResponse(w, paginatedResponse)
return
}

cases, total, err := h.covidService.GetNationalCasesPaginatedSorted(limit, offset, sortParams)
if err != nil {
writeErrorResponse(w, http.StatusInternalServerError, err.Error())
return
}

// Transform to new response structure
responseData := models.TransformSliceToResponse(cases)
writeSuccessResponse(w, responseData)
pagination := models.CalculatePaginationMeta(limit, offset, total)
paginatedResponse := models.PaginatedResponse{
Data: responseData,
Pagination: pagination,
}
writeSuccessResponse(w, paginatedResponse)
}

// GetLatestNationalCase godoc
//
// @Summary Get latest national COVID-19 case
// @Description Retrieve the most recent national COVID-19 case data
// @Tags national
// @Accept json
// @Produce json
// @Success 200 {object} Response{data=models.NationalCaseResponse}
// @Failure 404 {object} Response
// @Failure 500 {object} Response
// @Router /national/latest [get]
// @Summary Get latest national COVID-19 case
// @Description Retrieve the most recent national COVID-19 case data
// @Tags national
// @Accept json
// @Produce json
// @Success 200 {object} Response{data=models.NationalCaseResponse}
// @Failure 404 {object} Response
// @Failure 500 {object} Response
// @Router /national/latest [get]
func (h *CovidHandler) GetLatestNationalCase(w http.ResponseWriter, r *http.Request) {
nationalCase, err := h.covidService.GetLatestNationalCase()
if err != nil {
Expand All @@ -102,16 +145,16 @@ func (h *CovidHandler) GetLatestNationalCase(w http.ResponseWriter, r *http.Requ

// GetProvinces godoc
//
// @Summary Get provinces with COVID-19 data
// @Description Retrieve all provinces with their latest COVID-19 case data by default. Use exclude_latest_case=true for basic province list only.
// @Tags provinces
// @Accept json
// @Produce json
// @Param exclude_latest_case query boolean false "Exclude latest case data (default: false)"
// @Success 200 {object} Response{data=[]models.ProvinceWithLatestCase} "Provinces with latest case data"
// @Success 200 {object} Response{data=[]models.Province} "Basic province list when exclude_latest_case=true"
// @Failure 500 {object} Response
// @Router /provinces [get]
// @Summary Get provinces with COVID-19 data
// @Description Retrieve all provinces with their latest COVID-19 case data by default. Use exclude_latest_case=true for basic province list only.
// @Tags provinces
// @Accept json
// @Produce json
// @Param exclude_latest_case query boolean false "Exclude latest case data (default: false)"
// @Success 200 {object} Response{data=[]models.ProvinceWithLatestCase} "Provinces with latest case data"
// @Success 200 {object} Response{data=[]models.Province} "Basic province list when exclude_latest_case=true"
// @Failure 500 {object} Response
// @Router /provinces [get]
func (h *CovidHandler) GetProvinces(w http.ResponseWriter, r *http.Request) {
// Check if exclude_latest_case query parameter is set to get basic province list only
excludeLatestCase := r.URL.Query().Get("exclude_latest_case") == "true"
Expand All @@ -137,24 +180,25 @@ func (h *CovidHandler) GetProvinces(w http.ResponseWriter, r *http.Request) {

// GetProvinceCases godoc
//
// @Summary Get province COVID-19 cases
// @Description Retrieve COVID-19 cases for all provinces or a specific province with hybrid pagination support
// @Tags province-cases
// @Accept json
// @Produce json
// @Param provinceId path string false "Province ID (e.g., '31' for Jakarta)"
// @Param limit query integer false "Records per page (default: 50, max: 1000)"
// @Param offset query integer false "Records to skip (default: 0)"
// @Param all query boolean false "Return all data without pagination"
// @Param start_date query string false "Start date (YYYY-MM-DD)"
// @Param end_date query string false "End date (YYYY-MM-DD)"
// @Param sort query string false "Sort by field:order (e.g., date:desc, positive:asc). Default: date:asc"
// @Success 200 {object} Response{data=models.PaginatedResponse{data=[]models.ProvinceCaseResponse}} "Paginated response"
// @Success 200 {object} Response{data=[]models.ProvinceCaseResponse} "All data response when all=true"
// @Failure 400 {object} Response
// @Failure 500 {object} Response
// @Router /provinces/cases [get]
// @Router /provinces/{provinceId}/cases [get]
// @Summary Get province COVID-19 cases
// @Description Retrieve COVID-19 cases for all provinces or a specific province with hybrid pagination support
// @Tags province-cases
// @Accept json
// @Produce json
// @Param provinceId path string false "Province ID (e.g., '31' for Jakarta)"
// @Param limit query integer false "Records per page (default: 50, max: 1000)"
// @Param offset query integer false "Records to skip (default: 0)"
// @Param page query integer false "Page number (1-based, alternative to offset)"
// @Param all query boolean false "Return all data without pagination"
// @Param start_date query string false "Start date (YYYY-MM-DD)"
// @Param end_date query string false "End date (YYYY-MM-DD)"
// @Param sort query string false "Sort by field:order (e.g., date:desc, positive:asc). Default: date:asc"
// @Success 200 {object} Response{data=models.PaginatedResponse{data=[]models.ProvinceCaseResponse}} "Paginated response"
// @Success 200 {object} Response{data=[]models.ProvinceCaseResponse} "All data response when all=true"
// @Failure 400 {object} Response
// @Failure 500 {object} Response
// @Router /provinces/cases [get]
// @Router /provinces/{provinceId}/cases [get]
func (h *CovidHandler) GetProvinceCases(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
provinceID := vars["provinceId"]
Expand Down Expand Up @@ -286,19 +330,19 @@ func (h *CovidHandler) GetProvinceCases(w http.ResponseWriter, r *http.Request)

// HealthCheck godoc
//
// @Summary Health check
// @Description Check API health status and database connectivity
// @Tags health
// @Accept json
// @Produce json
// @Success 200 {object} Response{data=map[string]interface{}} "API is healthy"
// @Success 503 {object} Response{data=map[string]interface{}} "API is degraded (database issues)"
// @Router /health [get]
// @Summary Health check
// @Description Check API health status and database connectivity
// @Tags health
// @Accept json
// @Produce json
// @Success 200 {object} Response{data=map[string]interface{}} "API is healthy"
// @Success 503 {object} Response{data=map[string]interface{}} "API is degraded (database issues)"
// @Router /health [get]
func (h *CovidHandler) HealthCheck(w http.ResponseWriter, r *http.Request) {
health := map[string]interface{}{
"status": "healthy",
"service": "COVID-19 API",
"version": "2.4.0",
"version": "2.5.0",
"timestamp": time.Now().UTC().Format(time.RFC3339),
}

Expand Down Expand Up @@ -344,18 +388,18 @@ func (h *CovidHandler) HealthCheck(w http.ResponseWriter, r *http.Request) {

// GetAPIIndex godoc
//
// @Summary API endpoint index
// @Description Get a list of all available API endpoints with descriptions
// @Tags health
// @Accept json
// @Produce json
// @Success 200 {object} Response{data=map[string]interface{}}
// @Router / [get]
// @Summary API endpoint index
// @Description Get a list of all available API endpoints with descriptions
// @Tags health
// @Accept json
// @Produce json
// @Success 200 {object} Response{data=map[string]interface{}}
// @Router / [get]
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.4.0",
"version": "2.5.0",
"description": "A comprehensive REST API for COVID-19 data in Sulawesi Tengah (Central Sulawesi)",
},
"documentation": map[string]interface{}{
Expand Down
30 changes: 25 additions & 5 deletions internal/handler/covid_handler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,26 @@ func (m *MockCovidService) GetNationalCasesByDateRangeSorted(startDate, endDate
return args.Get(0).([]models.NationalCase), args.Error(1)
}

func (m *MockCovidService) GetNationalCasesPaginated(limit, offset int) ([]models.NationalCase, int, error) {
args := m.Called(limit, offset)
return args.Get(0).([]models.NationalCase), args.Int(1), args.Error(2)
}

func (m *MockCovidService) GetNationalCasesPaginatedSorted(limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) {
args := m.Called(limit, offset, sortParams)
return args.Get(0).([]models.NationalCase), args.Int(1), args.Error(2)
}

func (m *MockCovidService) GetNationalCasesByDateRangePaginated(startDate, endDate string, limit, offset int) ([]models.NationalCase, int, error) {
args := m.Called(startDate, endDate, limit, offset)
return args.Get(0).([]models.NationalCase), args.Int(1), args.Error(2)
}

func (m *MockCovidService) GetNationalCasesByDateRangePaginatedSorted(startDate, endDate string, limit, offset int, sortParams utils.SortParams) ([]models.NationalCase, int, error) {
args := m.Called(startDate, endDate, limit, offset, sortParams)
return args.Get(0).([]models.NationalCase), args.Int(1), args.Error(2)
}

func (m *MockCovidService) GetProvinceCasesSorted(provinceID string, sortParams utils.SortParams) ([]models.ProvinceCaseWithDate, error) {
args := m.Called(provinceID, sortParams)
return args.Get(0).([]models.ProvinceCaseWithDate), args.Error(1)
Expand Down Expand Up @@ -148,7 +168,7 @@ func TestCovidHandler_GetNationalCases(t *testing.T) {
{ID: 1, Positive: 100, Recovered: 80, Deceased: 5},
}

mockService.On("GetNationalCasesSorted", utils.SortParams{Field: "date", Order: "asc"}).Return(expectedCases, nil)
mockService.On("GetNationalCasesPaginatedSorted", 50, 0, utils.SortParams{Field: "date", Order: "asc"}).Return(expectedCases, len(expectedCases), nil)

req, err := http.NewRequest("GET", "/api/v1/national", nil)
assert.NoError(t, err)
Expand All @@ -175,7 +195,7 @@ func TestCovidHandler_GetNationalCases_WithDateRange(t *testing.T) {
{ID: 1, Positive: 100, Date: time.Date(2020, 3, 15, 0, 0, 0, 0, time.UTC)},
}

mockService.On("GetNationalCasesByDateRangeSorted", "2020-03-01", "2020-03-31", utils.SortParams{Field: "date", Order: "asc"}).Return(expectedCases, nil)
mockService.On("GetNationalCasesByDateRangePaginatedSorted", "2020-03-01", "2020-03-31", 50, 0, utils.SortParams{Field: "date", Order: "asc"}).Return(expectedCases, len(expectedCases), nil)

req, err := http.NewRequest("GET", "/api/v1/national?start_date=2020-03-01&end_date=2020-03-31", nil)
assert.NoError(t, err)
Expand All @@ -197,7 +217,7 @@ func TestCovidHandler_GetNationalCases_ServiceError(t *testing.T) {
mockService := new(MockCovidService)
handler := NewCovidHandler(mockService, nil)

mockService.On("GetNationalCasesSorted", utils.SortParams{Field: "date", Order: "asc"}).Return([]models.NationalCase{}, errors.New("database error"))
mockService.On("GetNationalCasesPaginatedSorted", 50, 0, utils.SortParams{Field: "date", Order: "asc"}).Return([]models.NationalCase{}, 0, errors.New("database error"))

req, err := http.NewRequest("GET", "/api/v1/national", nil)
assert.NoError(t, err)
Expand Down Expand Up @@ -607,7 +627,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.4.0", apiInfo["version"])
assert.Equal(t, "2.5.0", apiInfo["version"])

// Verify endpoints structure
endpoints, ok := data["endpoints"].(map[string]interface{})
Expand Down Expand Up @@ -638,7 +658,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.4.0", data["version"])
assert.Equal(t, "2.5.0", data["version"])
assert.Contains(t, data, "database")

dbData, ok := data["database"].(map[string]interface{})
Expand Down
Loading