From 98f5e8d02ac62eba0203d25aa494e82639cbd8eb Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Sun, 7 Sep 2025 20:34:45 +0700 Subject: [PATCH 01/13] feat: group ODP/PDP data in province case response - Add ObservationData and SupervisionData structs - Group ODP fields under 'odp' key with active, finished, total - Group PDP fields under 'pdp' key with active, finished, total - Maintain backward compatibility with data calculation --- internal/models/province_case_response.go | 90 ++++++++++++++--------- 1 file changed, 54 insertions(+), 36 deletions(-) diff --git a/internal/models/province_case_response.go b/internal/models/province_case_response.go index e802282..93f2b24 100644 --- a/internal/models/province_case_response.go +++ b/internal/models/province_case_response.go @@ -14,28 +14,36 @@ type ProvinceCaseResponse struct { // ProvinceDailyCases represents new cases for a single day in a province type ProvinceDailyCases struct { - Positive int64 `json:"positive"` - Recovered int64 `json:"recovered"` - Deceased int64 `json:"deceased"` - Active int64 `json:"active"` - PersonUnderObservation int64 `json:"person_under_observation"` - FinishedPersonUnderObservation int64 `json:"finished_person_under_observation"` - PersonUnderSupervision int64 `json:"person_under_supervision"` - FinishedPersonUnderSupervision int64 `json:"finished_person_under_supervision"` + Positive int64 `json:"positive"` + Recovered int64 `json:"recovered"` + Deceased int64 `json:"deceased"` + Active int64 `json:"active"` + ODP ObservationData `json:"odp"` + PDP SupervisionData `json:"pdp"` } // ProvinceCumulativeCases represents total cases accumulated over time in a province type ProvinceCumulativeCases struct { - Positive int64 `json:"positive"` - Recovered int64 `json:"recovered"` - Deceased int64 `json:"deceased"` - Active int64 `json:"active"` - PersonUnderObservation int64 `json:"person_under_observation"` - ActivePersonUnderObservation int64 `json:"active_person_under_observation"` - FinishedPersonUnderObservation int64 `json:"finished_person_under_observation"` - PersonUnderSupervision int64 `json:"person_under_supervision"` - ActivePersonUnderSupervision int64 `json:"active_person_under_supervision"` - FinishedPersonUnderSupervision int64 `json:"finished_person_under_supervision"` + Positive int64 `json:"positive"` + Recovered int64 `json:"recovered"` + Deceased int64 `json:"deceased"` + Active int64 `json:"active"` + ODP ObservationData `json:"odp"` + PDP SupervisionData `json:"pdp"` +} + +// ObservationData represents Person Under Observation (ODP) data +type ObservationData struct { + Active int64 `json:"active"` + Finished int64 `json:"finished"` + Total int64 `json:"total"` +} + +// SupervisionData represents Patient Under Supervision (PDP) data +type SupervisionData struct { + Active int64 `json:"active"` + Finished int64 `json:"finished"` + Total int64 `json:"total"` } // ProvinceCaseStatistics contains calculated statistics and metrics for province data @@ -59,26 +67,36 @@ func (pc *ProvinceCase) TransformToResponse(date time.Time) ProvinceCaseResponse Day: pc.Day, Date: date, Daily: ProvinceDailyCases{ - Positive: pc.Positive, - Recovered: pc.Recovered, - Deceased: pc.Deceased, - Active: dailyActive, - PersonUnderObservation: pc.PersonUnderObservation, - FinishedPersonUnderObservation: pc.FinishedPersonUnderObservation, - PersonUnderSupervision: pc.PersonUnderSupervision, - FinishedPersonUnderSupervision: pc.FinishedPersonUnderSupervision, + Positive: pc.Positive, + Recovered: pc.Recovered, + Deceased: pc.Deceased, + Active: dailyActive, + ODP: ObservationData{ + Active: pc.PersonUnderObservation - pc.FinishedPersonUnderObservation, + Finished: pc.FinishedPersonUnderObservation, + Total: pc.PersonUnderObservation, + }, + PDP: SupervisionData{ + Active: pc.PersonUnderSupervision - pc.FinishedPersonUnderSupervision, + Finished: pc.FinishedPersonUnderSupervision, + Total: pc.PersonUnderSupervision, + }, }, Cumulative: ProvinceCumulativeCases{ - Positive: pc.CumulativePositive, - Recovered: pc.CumulativeRecovered, - Deceased: pc.CumulativeDeceased, - Active: cumulativeActive, - PersonUnderObservation: pc.CumulativePersonUnderObservation, - ActivePersonUnderObservation: activePersonUnderObservation, - FinishedPersonUnderObservation: pc.CumulativeFinishedPersonUnderObservation, - PersonUnderSupervision: pc.CumulativePersonUnderSupervision, - ActivePersonUnderSupervision: activePersonUnderSupervision, - FinishedPersonUnderSupervision: pc.CumulativeFinishedPersonUnderSupervision, + Positive: pc.CumulativePositive, + Recovered: pc.CumulativeRecovered, + Deceased: pc.CumulativeDeceased, + Active: cumulativeActive, + ODP: ObservationData{ + Active: activePersonUnderObservation, + Finished: pc.CumulativeFinishedPersonUnderObservation, + Total: pc.CumulativePersonUnderObservation, + }, + PDP: SupervisionData{ + Active: activePersonUnderSupervision, + Finished: pc.CumulativeFinishedPersonUnderSupervision, + Total: pc.CumulativePersonUnderSupervision, + }, }, Statistics: ProvinceCaseStatistics{ Percentages: calculatePercentages(pc.CumulativePositive, pc.CumulativeRecovered, pc.CumulativeDeceased, cumulativeActive), From ac60b0e1f1a18f4eb3cf364cb8fcf1a456325ae9 Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Sun, 7 Sep 2025 20:36:31 +0700 Subject: [PATCH 02/13] feat: add endpoint to get provinces with latest case data - Add ProvinceWithLatestCase model - Add GetProvincesWithLatestCase service method - Update GetProvinces endpoint to support include_latest_case query param - Fetch and include latest case data for each province when requested --- internal/handler/covid_handler.go | 13 +++++++++++ internal/models/province_with_case.go | 7 ++++++ internal/service/covid_service.go | 31 +++++++++++++++++++++++++++ 3 files changed, 51 insertions(+) create mode 100644 internal/models/province_with_case.go diff --git a/internal/handler/covid_handler.go b/internal/handler/covid_handler.go index db2e6be..0ce9279 100644 --- a/internal/handler/covid_handler.go +++ b/internal/handler/covid_handler.go @@ -67,6 +67,19 @@ func (h *CovidHandler) GetLatestNationalCase(w http.ResponseWriter, r *http.Requ } func (h *CovidHandler) GetProvinces(w http.ResponseWriter, r *http.Request) { + // Check if include_latest_case query parameter is set + includeLatestCase := r.URL.Query().Get("include_latest_case") == "true" + + if includeLatestCase { + provincesWithCases, err := h.covidService.GetProvincesWithLatestCase() + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + writeSuccessResponse(w, provincesWithCases) + return + } + provinces, err := h.covidService.GetProvinces() if err != nil { writeErrorResponse(w, http.StatusInternalServerError, err.Error()) diff --git a/internal/models/province_with_case.go b/internal/models/province_with_case.go new file mode 100644 index 0000000..0375e91 --- /dev/null +++ b/internal/models/province_with_case.go @@ -0,0 +1,7 @@ +package models + +// ProvinceWithLatestCase represents a province with its latest COVID-19 case data +type ProvinceWithLatestCase struct { + Province + LatestCase *ProvinceCaseResponse `json:"latest_case,omitempty"` +} \ No newline at end of file diff --git a/internal/service/covid_service.go b/internal/service/covid_service.go index 4650e8f..576cd1c 100644 --- a/internal/service/covid_service.go +++ b/internal/service/covid_service.go @@ -13,6 +13,7 @@ type CovidService interface { GetNationalCasesByDateRange(startDate, endDate string) ([]models.NationalCase, error) GetLatestNationalCase() (*models.NationalCase, error) GetProvinces() ([]models.Province, error) + GetProvincesWithLatestCase() ([]models.ProvinceWithLatestCase, error) GetProvinceCases(provinceID string) ([]models.ProvinceCaseWithDate, error) GetProvinceCasesByDateRange(provinceID, startDate, endDate string) ([]models.ProvinceCaseWithDate, error) GetAllProvinceCases() ([]models.ProvinceCaseWithDate, error) @@ -79,6 +80,36 @@ func (s *covidService) GetProvinces() ([]models.Province, error) { return provinces, nil } +func (s *covidService) GetProvincesWithLatestCase() ([]models.ProvinceWithLatestCase, error) { + provinces, err := s.provinceRepo.GetAll() + if err != nil { + return nil, fmt.Errorf("failed to get provinces: %w", err) + } + + result := make([]models.ProvinceWithLatestCase, len(provinces)) + + for i, province := range provinces { + result[i] = models.ProvinceWithLatestCase{ + Province: province, + } + + // Get latest case for this province + latestCase, err := s.provinceCaseRepo.GetLatestByProvinceID(province.ID) + if err != nil { + // If error or no data, continue without latest case + continue + } + + if latestCase != nil { + // Transform to response format + caseResponse := latestCase.TransformToResponse() + result[i].LatestCase = &caseResponse + } + } + + return result, nil +} + func (s *covidService) GetProvinceCases(provinceID string) ([]models.ProvinceCaseWithDate, error) { cases, err := s.provinceCaseRepo.GetByProvinceID(provinceID) if err != nil { From b74a07b1ef5d615937b5f9b09728c17a121ea715 Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Sun, 7 Sep 2025 20:38:55 +0700 Subject: [PATCH 03/13] test: update tests for new ODP/PDP grouped structure - Update test expectations to use ObservationData and SupervisionData structs - Fix all test cases to match new nested ODP/PDP structure - Ensure all tests pass with grouped ODP/PDP fields --- .../models/province_case_response_test.go | 184 +++++++++++------- 1 file changed, 112 insertions(+), 72 deletions(-) diff --git a/internal/models/province_case_response_test.go b/internal/models/province_case_response_test.go index 3f362b1..9e92c8a 100644 --- a/internal/models/province_case_response_test.go +++ b/internal/models/province_case_response_test.go @@ -52,26 +52,36 @@ func TestProvinceCase_TransformToResponse(t *testing.T) { Day: 100, Date: testDate, Daily: ProvinceDailyCases{ - Positive: 150, - Recovered: 120, - Deceased: 10, - Active: 20, // 150 - 120 - 10 - PersonUnderObservation: 25, - FinishedPersonUnderObservation: 20, - PersonUnderSupervision: 30, - FinishedPersonUnderSupervision: 25, + Positive: 150, + Recovered: 120, + Deceased: 10, + Active: 20, // 150 - 120 - 10 + ODP: ObservationData{ + Active: 5, // 25 - 20 + Finished: 20, + Total: 25, + }, + PDP: SupervisionData{ + Active: 5, // 30 - 25 + Finished: 25, + Total: 30, + }, }, Cumulative: ProvinceCumulativeCases{ - Positive: 5000, - Recovered: 4500, - Deceased: 300, - Active: 200, // 5000 - 4500 - 300 - PersonUnderObservation: 800, - ActivePersonUnderObservation: 50, // 800 - 750 - FinishedPersonUnderObservation: 750, - PersonUnderSupervision: 600, - ActivePersonUnderSupervision: 20, // 600 - 580 - FinishedPersonUnderSupervision: 580, + Positive: 5000, + Recovered: 4500, + Deceased: 300, + Active: 200, // 5000 - 4500 - 300 + ODP: ObservationData{ + Active: 50, // 800 - 750 + Finished: 750, + Total: 800, + }, + PDP: SupervisionData{ + Active: 20, // 600 - 580 + Finished: 580, + Total: 600, + }, }, Statistics: ProvinceCaseStatistics{ Percentages: CasePercentages{ @@ -124,26 +134,36 @@ func TestProvinceCase_TransformToResponse(t *testing.T) { Day: 50, Date: testDate, Daily: ProvinceDailyCases{ - Positive: 100, - Recovered: 80, - Deceased: 5, - Active: 15, // 100 - 80 - 5 - PersonUnderObservation: 15, - FinishedPersonUnderObservation: 10, - PersonUnderSupervision: 20, - FinishedPersonUnderSupervision: 15, + Positive: 100, + Recovered: 80, + Deceased: 5, + Active: 15, // 100 - 80 - 5 + ODP: ObservationData{ + Active: 5, // 15 - 10 + Finished: 10, + Total: 15, + }, + PDP: SupervisionData{ + Active: 5, // 20 - 15 + Finished: 15, + Total: 20, + }, }, Cumulative: ProvinceCumulativeCases{ - Positive: 2000, - Recovered: 1800, - Deceased: 100, - Active: 100, // 2000 - 1800 - 100 - PersonUnderObservation: 400, - ActivePersonUnderObservation: 50, // 400 - 350 - FinishedPersonUnderObservation: 350, - PersonUnderSupervision: 300, - ActivePersonUnderSupervision: 10, // 300 - 290 - FinishedPersonUnderSupervision: 290, + Positive: 2000, + Recovered: 1800, + Deceased: 100, + Active: 100, // 2000 - 1800 - 100 + ODP: ObservationData{ + Active: 50, // 400 - 350 + Finished: 350, + Total: 400, + }, + PDP: SupervisionData{ + Active: 10, // 300 - 290 + Finished: 290, + Total: 300, + }, }, Statistics: ProvinceCaseStatistics{ Percentages: CasePercentages{ @@ -196,26 +216,36 @@ func TestProvinceCase_TransformToResponse(t *testing.T) { Day: 1, Date: testDate, Daily: ProvinceDailyCases{ - Positive: 0, - Recovered: 0, - Deceased: 0, - Active: 0, - PersonUnderObservation: 0, - FinishedPersonUnderObservation: 0, - PersonUnderSupervision: 0, - FinishedPersonUnderSupervision: 0, + Positive: 0, + Recovered: 0, + Deceased: 0, + Active: 0, + ODP: ObservationData{ + Active: 0, + Finished: 0, + Total: 0, + }, + PDP: SupervisionData{ + Active: 0, + Finished: 0, + Total: 0, + }, }, Cumulative: ProvinceCumulativeCases{ - Positive: 0, - Recovered: 0, - Deceased: 0, - Active: 0, - PersonUnderObservation: 0, - ActivePersonUnderObservation: 0, - FinishedPersonUnderObservation: 0, - PersonUnderSupervision: 0, - ActivePersonUnderSupervision: 0, - FinishedPersonUnderSupervision: 0, + Positive: 0, + Recovered: 0, + Deceased: 0, + Active: 0, + ODP: ObservationData{ + Active: 0, + Finished: 0, + Total: 0, + }, + PDP: SupervisionData{ + Active: 0, + Finished: 0, + Total: 0, + }, }, Statistics: ProvinceCaseStatistics{ Percentages: CasePercentages{ @@ -287,26 +317,36 @@ func TestProvinceCaseWithDate_TransformToResponse(t *testing.T) { Day: 200, Date: testDate, Daily: ProvinceDailyCases{ - Positive: 50, - Recovered: 40, - Deceased: 2, - Active: 8, // 50 - 40 - 2 - PersonUnderObservation: 10, - FinishedPersonUnderObservation: 8, - PersonUnderSupervision: 12, - FinishedPersonUnderSupervision: 10, + Positive: 50, + Recovered: 40, + Deceased: 2, + Active: 8, // 50 - 40 - 2 + ODP: ObservationData{ + Active: 2, // 10 - 8 + Finished: 8, + Total: 10, + }, + PDP: SupervisionData{ + Active: 2, // 12 - 10 + Finished: 10, + Total: 12, + }, }, Cumulative: ProvinceCumulativeCases{ - Positive: 3000, - Recovered: 2700, - Deceased: 200, - Active: 100, // 3000 - 2700 - 200 - PersonUnderObservation: 500, - ActivePersonUnderObservation: 50, // 500 - 450 - FinishedPersonUnderObservation: 450, - PersonUnderSupervision: 350, - ActivePersonUnderSupervision: 30, // 350 - 320 - FinishedPersonUnderSupervision: 320, + Positive: 3000, + Recovered: 2700, + Deceased: 200, + Active: 100, // 3000 - 2700 - 200 + ODP: ObservationData{ + Active: 50, // 500 - 450 + Finished: 450, + Total: 500, + }, + PDP: SupervisionData{ + Active: 30, // 350 - 320 + Finished: 320, + Total: 350, + }, }, Statistics: ProvinceCaseStatistics{ Percentages: CasePercentages{ From b6013a04c602b2c0945042dae448b858b3afa2c4 Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Sun, 7 Sep 2025 20:40:05 +0700 Subject: [PATCH 04/13] fix: update mock service and tests for new endpoint - Add GetProvincesWithLatestCase to mock service - Update health check test to expect version 2.0.2 - Ensure all handler tests pass --- internal/handler/covid_handler_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/handler/covid_handler_test.go b/internal/handler/covid_handler_test.go index 421f175..eff9478 100644 --- a/internal/handler/covid_handler_test.go +++ b/internal/handler/covid_handler_test.go @@ -42,6 +42,11 @@ func (m *MockCovidService) GetProvinces() ([]models.Province, error) { return args.Get(0).([]models.Province), args.Error(1) } +func (m *MockCovidService) GetProvincesWithLatestCase() ([]models.ProvinceWithLatestCase, error) { + args := m.Called() + return args.Get(0).([]models.ProvinceWithLatestCase), args.Error(1) +} + func (m *MockCovidService) GetProvinceCases(provinceID string) ([]models.ProvinceCaseWithDate, error) { args := m.Called(provinceID) return args.Get(0).([]models.ProvinceCaseWithDate), args.Error(1) @@ -286,7 +291,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.0.1", data["version"]) + assert.Equal(t, "2.0.2", data["version"]) assert.Contains(t, data, "database") dbData, ok := data["database"].(map[string]interface{}) From 88708757c65018d1aca37e1a71570e3035f58c27 Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Sun, 7 Sep 2025 23:01:31 +0700 Subject: [PATCH 05/13] fix: correct ODP/PDP structure for daily vs cumulative data - Create separate DailyObservationData and DailySupervisionData structs - Daily data only includes active and finished (no total) - Cumulative data includes active, finished, and total - Update all test cases to match corrected structure - All tests passing --- internal/models/province_case_response.go | 34 ++++++++++++------- .../models/province_case_response_test.go | 24 +++++-------- 2 files changed, 30 insertions(+), 28 deletions(-) diff --git a/internal/models/province_case_response.go b/internal/models/province_case_response.go index 93f2b24..f24fe5f 100644 --- a/internal/models/province_case_response.go +++ b/internal/models/province_case_response.go @@ -14,12 +14,12 @@ type ProvinceCaseResponse struct { // ProvinceDailyCases represents new cases for a single day in a province type ProvinceDailyCases struct { - Positive int64 `json:"positive"` - Recovered int64 `json:"recovered"` - Deceased int64 `json:"deceased"` - Active int64 `json:"active"` - ODP ObservationData `json:"odp"` - PDP SupervisionData `json:"pdp"` + Positive int64 `json:"positive"` + Recovered int64 `json:"recovered"` + Deceased int64 `json:"deceased"` + Active int64 `json:"active"` + ODP DailyObservationData `json:"odp"` + PDP DailySupervisionData `json:"pdp"` } // ProvinceCumulativeCases represents total cases accumulated over time in a province @@ -32,14 +32,26 @@ type ProvinceCumulativeCases struct { PDP SupervisionData `json:"pdp"` } -// ObservationData represents Person Under Observation (ODP) data +// DailyObservationData represents daily Person Under Observation (ODP) data +type DailyObservationData struct { + Active int64 `json:"active"` + Finished int64 `json:"finished"` +} + +// DailySupervisionData represents daily Patient Under Supervision (PDP) data +type DailySupervisionData struct { + Active int64 `json:"active"` + Finished int64 `json:"finished"` +} + +// ObservationData represents cumulative Person Under Observation (ODP) data type ObservationData struct { Active int64 `json:"active"` Finished int64 `json:"finished"` Total int64 `json:"total"` } -// SupervisionData represents Patient Under Supervision (PDP) data +// SupervisionData represents cumulative Patient Under Supervision (PDP) data type SupervisionData struct { Active int64 `json:"active"` Finished int64 `json:"finished"` @@ -71,15 +83,13 @@ func (pc *ProvinceCase) TransformToResponse(date time.Time) ProvinceCaseResponse Recovered: pc.Recovered, Deceased: pc.Deceased, Active: dailyActive, - ODP: ObservationData{ + ODP: DailyObservationData{ Active: pc.PersonUnderObservation - pc.FinishedPersonUnderObservation, Finished: pc.FinishedPersonUnderObservation, - Total: pc.PersonUnderObservation, }, - PDP: SupervisionData{ + PDP: DailySupervisionData{ Active: pc.PersonUnderSupervision - pc.FinishedPersonUnderSupervision, Finished: pc.FinishedPersonUnderSupervision, - Total: pc.PersonUnderSupervision, }, }, Cumulative: ProvinceCumulativeCases{ diff --git a/internal/models/province_case_response_test.go b/internal/models/province_case_response_test.go index 9e92c8a..84b71ad 100644 --- a/internal/models/province_case_response_test.go +++ b/internal/models/province_case_response_test.go @@ -56,15 +56,13 @@ func TestProvinceCase_TransformToResponse(t *testing.T) { Recovered: 120, Deceased: 10, Active: 20, // 150 - 120 - 10 - ODP: ObservationData{ + ODP: DailyObservationData{ Active: 5, // 25 - 20 Finished: 20, - Total: 25, }, - PDP: SupervisionData{ + PDP: DailySupervisionData{ Active: 5, // 30 - 25 Finished: 25, - Total: 30, }, }, Cumulative: ProvinceCumulativeCases{ @@ -138,15 +136,13 @@ func TestProvinceCase_TransformToResponse(t *testing.T) { Recovered: 80, Deceased: 5, Active: 15, // 100 - 80 - 5 - ODP: ObservationData{ + ODP: DailyObservationData{ Active: 5, // 15 - 10 Finished: 10, - Total: 15, }, - PDP: SupervisionData{ + PDP: DailySupervisionData{ Active: 5, // 20 - 15 Finished: 15, - Total: 20, }, }, Cumulative: ProvinceCumulativeCases{ @@ -220,15 +216,13 @@ func TestProvinceCase_TransformToResponse(t *testing.T) { Recovered: 0, Deceased: 0, Active: 0, - ODP: ObservationData{ + ODP: DailyObservationData{ Active: 0, Finished: 0, - Total: 0, }, - PDP: SupervisionData{ + PDP: DailySupervisionData{ Active: 0, Finished: 0, - Total: 0, }, }, Cumulative: ProvinceCumulativeCases{ @@ -321,15 +315,13 @@ func TestProvinceCaseWithDate_TransformToResponse(t *testing.T) { Recovered: 40, Deceased: 2, Active: 8, // 50 - 40 - 2 - ODP: ObservationData{ + ODP: DailyObservationData{ Active: 2, // 10 - 8 Finished: 8, - Total: 10, }, - PDP: SupervisionData{ + PDP: DailySupervisionData{ Active: 2, // 12 - 10 Finished: 10, - Total: 12, }, }, Cumulative: ProvinceCumulativeCases{ From 4444cfd235e621146c3cb19f726f51d8c496cf61 Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Sun, 7 Sep 2025 23:11:28 +0700 Subject: [PATCH 06/13] feat: implement hybrid pagination system - Add pagination models with metadata support - Add query parameter parsing utilities - Implement paginated repository methods - Add paginated service layer methods - Update handlers to support hybrid pagination: - ?all=true: Returns complete dataset (for charts/analytics) - Default: Returns paginated data (limit=50, max=1000) - ?limit=N&offset=M: Custom pagination - Works with date ranges and province filtering - Maintain backward compatibility with existing endpoints --- internal/handler/covid_handler.go | 108 ++++++++++--- internal/models/pagination.go | 34 +++++ .../repository/province_case_repository.go | 142 ++++++++++++++++++ internal/service/covid_service.go | 56 +++++++ pkg/utils/query.go | 63 ++++++++ 5 files changed, 384 insertions(+), 19 deletions(-) create mode 100644 internal/models/pagination.go create mode 100644 pkg/utils/query.go diff --git a/internal/handler/covid_handler.go b/internal/handler/covid_handler.go index 0ce9279..7f30193 100644 --- a/internal/handler/covid_handler.go +++ b/internal/handler/covid_handler.go @@ -7,6 +7,7 @@ import ( "github.com/banua-coder/pico-api-go/internal/models" "github.com/banua-coder/pico-api-go/internal/service" "github.com/banua-coder/pico-api-go/pkg/database" + "github.com/banua-coder/pico-api-go/pkg/utils" "github.com/gorilla/mux" ) @@ -92,58 +93,127 @@ func (h *CovidHandler) GetProvinces(w http.ResponseWriter, r *http.Request) { func (h *CovidHandler) GetProvinceCases(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) provinceID := vars["provinceId"] + + // 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") + + // Validate pagination params + limit, offset = utils.ValidatePaginationParams(limit, offset) if provinceID == "" { - startDate := r.URL.Query().Get("start_date") - endDate := r.URL.Query().Get("end_date") + // Handle all provinces cases + if all { + // Return all data without pagination + if startDate != "" && endDate != "" { + cases, err := h.covidService.GetAllProvinceCasesByDateRange(startDate, endDate) + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + responseData := models.TransformProvinceCaseSliceToResponse(cases) + writeSuccessResponse(w, responseData) + return + } + + cases, err := h.covidService.GetAllProvinceCases() + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + responseData := models.TransformProvinceCaseSliceToResponse(cases) + writeSuccessResponse(w, responseData) + return + } + + // Return paginated data + if startDate != "" && endDate != "" { + cases, total, err := h.covidService.GetAllProvinceCasesByDateRangePaginated(startDate, endDate, limit, offset) + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + responseData := models.TransformProvinceCaseSliceToResponse(cases) + pagination := models.CalculatePaginationMeta(limit, offset, total) + paginatedResponse := models.PaginatedResponse{ + Data: responseData, + Pagination: pagination, + } + writeSuccessResponse(w, paginatedResponse) + return + } + + cases, total, err := h.covidService.GetAllProvinceCasesPaginated(limit, offset) + if err != nil { + writeErrorResponse(w, http.StatusInternalServerError, err.Error()) + return + } + responseData := models.TransformProvinceCaseSliceToResponse(cases) + pagination := models.CalculatePaginationMeta(limit, offset, total) + paginatedResponse := models.PaginatedResponse{ + Data: responseData, + Pagination: pagination, + } + writeSuccessResponse(w, paginatedResponse) + return + } + // Handle specific province cases + if all { + // Return all data without pagination if startDate != "" && endDate != "" { - cases, err := h.covidService.GetAllProvinceCasesByDateRange(startDate, endDate) + cases, err := h.covidService.GetProvinceCasesByDateRange(provinceID, startDate, endDate) if err != nil { writeErrorResponse(w, http.StatusInternalServerError, err.Error()) return } - // Transform to new response structure responseData := models.TransformProvinceCaseSliceToResponse(cases) writeSuccessResponse(w, responseData) return } - - cases, err := h.covidService.GetAllProvinceCases() + + cases, err := h.covidService.GetProvinceCases(provinceID) if err != nil { writeErrorResponse(w, http.StatusInternalServerError, err.Error()) return } - // Transform to new response structure responseData := models.TransformProvinceCaseSliceToResponse(cases) writeSuccessResponse(w, responseData) return } - - startDate := r.URL.Query().Get("start_date") - endDate := r.URL.Query().Get("end_date") - + + // Return paginated data if startDate != "" && endDate != "" { - cases, err := h.covidService.GetProvinceCasesByDateRange(provinceID, startDate, endDate) + cases, total, err := h.covidService.GetProvinceCasesByDateRangePaginated(provinceID, startDate, endDate, limit, offset) if err != nil { writeErrorResponse(w, http.StatusInternalServerError, err.Error()) return } - // Transform to new response structure responseData := models.TransformProvinceCaseSliceToResponse(cases) - writeSuccessResponse(w, responseData) + pagination := models.CalculatePaginationMeta(limit, offset, total) + paginatedResponse := models.PaginatedResponse{ + Data: responseData, + Pagination: pagination, + } + writeSuccessResponse(w, paginatedResponse) return } - - cases, err := h.covidService.GetProvinceCases(provinceID) + + cases, total, err := h.covidService.GetProvinceCasesPaginated(provinceID, limit, offset) if err != nil { writeErrorResponse(w, http.StatusInternalServerError, err.Error()) return } - - // Transform to new response structure responseData := models.TransformProvinceCaseSliceToResponse(cases) - writeSuccessResponse(w, responseData) + pagination := models.CalculatePaginationMeta(limit, offset, total) + paginatedResponse := models.PaginatedResponse{ + Data: responseData, + Pagination: pagination, + } + writeSuccessResponse(w, paginatedResponse) } func (h *CovidHandler) HealthCheck(w http.ResponseWriter, r *http.Request) { diff --git a/internal/models/pagination.go b/internal/models/pagination.go new file mode 100644 index 0000000..19b3765 --- /dev/null +++ b/internal/models/pagination.go @@ -0,0 +1,34 @@ +package models + +// PaginationMeta contains metadata for paginated responses +type PaginationMeta struct { + Limit int `json:"limit"` + Offset int `json:"offset"` + Total int `json:"total"` + TotalPages int `json:"total_pages"` + Page int `json:"page"` + HasNext bool `json:"has_next"` + HasPrev bool `json:"has_prev"` +} + +// PaginatedResponse wraps data with pagination metadata +type PaginatedResponse struct { + Data interface{} `json:"data"` + Pagination PaginationMeta `json:"pagination"` +} + +// CalculatePaginationMeta calculates pagination metadata +func CalculatePaginationMeta(limit, offset, total int) PaginationMeta { + totalPages := (total + limit - 1) / limit // Ceiling division + page := (offset / limit) + 1 + + return PaginationMeta{ + Limit: limit, + Offset: offset, + Total: total, + TotalPages: totalPages, + Page: page, + HasNext: offset+limit < total, + HasPrev: offset > 0, + } +} \ No newline at end of file diff --git a/internal/repository/province_case_repository.go b/internal/repository/province_case_repository.go index 06a8687..74c24b6 100644 --- a/internal/repository/province_case_repository.go +++ b/internal/repository/province_case_repository.go @@ -11,9 +11,13 @@ import ( type ProvinceCaseRepository interface { GetAll() ([]models.ProvinceCaseWithDate, error) + GetAllPaginated(limit, offset int) ([]models.ProvinceCaseWithDate, int, error) GetByProvinceID(provinceID string) ([]models.ProvinceCaseWithDate, error) + GetByProvinceIDPaginated(provinceID string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) GetByProvinceIDAndDateRange(provinceID string, startDate, endDate time.Time) ([]models.ProvinceCaseWithDate, error) + GetByProvinceIDAndDateRangePaginated(provinceID string, startDate, endDate time.Time, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) GetByDateRange(startDate, endDate time.Time) ([]models.ProvinceCaseWithDate, error) + GetByDateRangePaginated(startDate, endDate time.Time, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) GetLatestByProvinceID(provinceID string) (*models.ProvinceCaseWithDate, error) } @@ -41,6 +45,39 @@ func (r *provinceCaseRepository) GetAll() ([]models.ProvinceCaseWithDate, error) return r.queryProvinceCases(query) } +func (r *provinceCaseRepository) GetAllPaginated(limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + // First get total count + countQuery := `SELECT COUNT(*) FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id` + + var total int + err := r.db.QueryRow(countQuery).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("failed to count province cases: %w", err) + } + + // Get paginated data + query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, + pc.person_under_observation, pc.finished_person_under_observation, + pc.person_under_supervision, pc.finished_person_under_supervision, + pc.cumulative_positive, pc.cumulative_recovered, pc.cumulative_deceased, + pc.cumulative_person_under_observation, pc.cumulative_finished_person_under_observation, + pc.cumulative_person_under_supervision, pc.cumulative_finished_person_under_supervision, + pc.rt, pc.rt_upper, pc.rt_lower, nc.date, p.name + FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + LEFT JOIN provinces p ON pc.province_id = p.id + ORDER BY nc.date DESC, p.name + LIMIT ? OFFSET ?` + + cases, err := r.queryProvinceCases(query, limit, offset) + if err != nil { + return nil, 0, err + } + + return cases, total, nil +} + func (r *provinceCaseRepository) GetByProvinceID(provinceID string) ([]models.ProvinceCaseWithDate, error) { query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, pc.person_under_observation, pc.finished_person_under_observation, @@ -58,6 +95,41 @@ func (r *provinceCaseRepository) GetByProvinceID(provinceID string) ([]models.Pr return r.queryProvinceCases(query, provinceID) } +func (r *provinceCaseRepository) GetByProvinceIDPaginated(provinceID string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + // First get total count + countQuery := `SELECT COUNT(*) FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + WHERE pc.province_id = ?` + + var total int + err := r.db.QueryRow(countQuery, provinceID).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("failed to count province cases for province %s: %w", provinceID, err) + } + + // Get paginated data + query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, + pc.person_under_observation, pc.finished_person_under_observation, + pc.person_under_supervision, pc.finished_person_under_supervision, + pc.cumulative_positive, pc.cumulative_recovered, pc.cumulative_deceased, + pc.cumulative_person_under_observation, pc.cumulative_finished_person_under_observation, + pc.cumulative_person_under_supervision, pc.cumulative_finished_person_under_supervision, + pc.rt, pc.rt_upper, pc.rt_lower, nc.date, p.name + FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + LEFT JOIN provinces p ON pc.province_id = p.id + WHERE pc.province_id = ? + ORDER BY nc.date DESC + LIMIT ? OFFSET ?` + + cases, err := r.queryProvinceCases(query, provinceID, limit, offset) + if err != nil { + return nil, 0, err + } + + return cases, total, nil +} + func (r *provinceCaseRepository) GetByProvinceIDAndDateRange(provinceID string, startDate, endDate time.Time) ([]models.ProvinceCaseWithDate, error) { query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, pc.person_under_observation, pc.finished_person_under_observation, @@ -75,6 +147,41 @@ func (r *provinceCaseRepository) GetByProvinceIDAndDateRange(provinceID string, return r.queryProvinceCases(query, provinceID, startDate, endDate) } +func (r *provinceCaseRepository) GetByProvinceIDAndDateRangePaginated(provinceID string, startDate, endDate time.Time, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + // First get total count + countQuery := `SELECT COUNT(*) FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + WHERE pc.province_id = ? AND nc.date BETWEEN ? AND ?` + + var total int + err := r.db.QueryRow(countQuery, provinceID, startDate, endDate).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("failed to count province cases for province %s in date range: %w", provinceID, err) + } + + // Get paginated data + query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, + pc.person_under_observation, pc.finished_person_under_observation, + pc.person_under_supervision, pc.finished_person_under_supervision, + pc.cumulative_positive, pc.cumulative_recovered, pc.cumulative_deceased, + pc.cumulative_person_under_observation, pc.cumulative_finished_person_under_observation, + pc.cumulative_person_under_supervision, pc.cumulative_finished_person_under_supervision, + pc.rt, pc.rt_upper, pc.rt_lower, nc.date, p.name + FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + LEFT JOIN provinces p ON pc.province_id = p.id + WHERE pc.province_id = ? AND nc.date BETWEEN ? AND ? + ORDER BY nc.date DESC + LIMIT ? OFFSET ?` + + cases, err := r.queryProvinceCases(query, provinceID, startDate, endDate, limit, offset) + if err != nil { + return nil, 0, err + } + + return cases, total, nil +} + func (r *provinceCaseRepository) GetByDateRange(startDate, endDate time.Time) ([]models.ProvinceCaseWithDate, error) { query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, pc.person_under_observation, pc.finished_person_under_observation, @@ -92,6 +199,41 @@ func (r *provinceCaseRepository) GetByDateRange(startDate, endDate time.Time) ([ return r.queryProvinceCases(query, startDate, endDate) } +func (r *provinceCaseRepository) GetByDateRangePaginated(startDate, endDate time.Time, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + // First get total count + countQuery := `SELECT COUNT(*) FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + WHERE nc.date BETWEEN ? AND ?` + + var total int + err := r.db.QueryRow(countQuery, startDate, endDate).Scan(&total) + if err != nil { + return nil, 0, fmt.Errorf("failed to count province cases in date range: %w", err) + } + + // Get paginated data + query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, + pc.person_under_observation, pc.finished_person_under_observation, + pc.person_under_supervision, pc.finished_person_under_supervision, + pc.cumulative_positive, pc.cumulative_recovered, pc.cumulative_deceased, + pc.cumulative_person_under_observation, pc.cumulative_finished_person_under_observation, + pc.cumulative_person_under_supervision, pc.cumulative_finished_person_under_supervision, + pc.rt, pc.rt_upper, pc.rt_lower, nc.date, p.name + FROM province_cases pc + JOIN national_cases nc ON pc.day = nc.id + LEFT JOIN provinces p ON pc.province_id = p.id + WHERE nc.date BETWEEN ? AND ? + ORDER BY nc.date DESC, p.name + LIMIT ? OFFSET ?` + + cases, err := r.queryProvinceCases(query, startDate, endDate, limit, offset) + if err != nil { + return nil, 0, err + } + + return cases, total, nil +} + func (r *provinceCaseRepository) GetLatestByProvinceID(provinceID string) (*models.ProvinceCaseWithDate, error) { query := `SELECT pc.id, pc.day, pc.province_id, pc.positive, pc.recovered, pc.deceased, pc.person_under_observation, pc.finished_person_under_observation, diff --git a/internal/service/covid_service.go b/internal/service/covid_service.go index 576cd1c..a76b1c4 100644 --- a/internal/service/covid_service.go +++ b/internal/service/covid_service.go @@ -15,9 +15,13 @@ type CovidService interface { GetProvinces() ([]models.Province, error) GetProvincesWithLatestCase() ([]models.ProvinceWithLatestCase, error) GetProvinceCases(provinceID string) ([]models.ProvinceCaseWithDate, error) + GetProvinceCasesPaginated(provinceID string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) GetProvinceCasesByDateRange(provinceID, startDate, endDate string) ([]models.ProvinceCaseWithDate, error) + GetProvinceCasesByDateRangePaginated(provinceID, startDate, endDate string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) GetAllProvinceCases() ([]models.ProvinceCaseWithDate, error) + GetAllProvinceCasesPaginated(limit, offset int) ([]models.ProvinceCaseWithDate, int, error) GetAllProvinceCasesByDateRange(startDate, endDate string) ([]models.ProvinceCaseWithDate, error) + GetAllProvinceCasesByDateRangePaginated(startDate, endDate string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) } type covidService struct { @@ -160,4 +164,56 @@ func (s *covidService) GetAllProvinceCasesByDateRange(startDate, endDate string) return nil, fmt.Errorf("failed to get all province cases by date range: %w", err) } return cases, nil +} + +func (s *covidService) GetProvinceCasesPaginated(provinceID string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + cases, total, err := s.provinceCaseRepo.GetByProvinceIDPaginated(provinceID, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to get province cases paginated: %w", err) + } + return cases, total, nil +} + +func (s *covidService) GetProvinceCasesByDateRangePaginated(provinceID, startDate, endDate string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + start, err := time.Parse("2006-01-02", startDate) + if err != nil { + return nil, 0, fmt.Errorf("invalid start date format: %w", err) + } + + end, err := time.Parse("2006-01-02", endDate) + if err != nil { + return nil, 0, fmt.Errorf("invalid end date format: %w", err) + } + + cases, total, err := s.provinceCaseRepo.GetByProvinceIDAndDateRangePaginated(provinceID, start, end, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to get province cases by date range paginated: %w", err) + } + return cases, total, nil +} + +func (s *covidService) GetAllProvinceCasesPaginated(limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + cases, total, err := s.provinceCaseRepo.GetAllPaginated(limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to get all province cases paginated: %w", err) + } + return cases, total, nil +} + +func (s *covidService) GetAllProvinceCasesByDateRangePaginated(startDate, endDate string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + start, err := time.Parse("2006-01-02", startDate) + if err != nil { + return nil, 0, fmt.Errorf("invalid start date format: %w", err) + } + + end, err := time.Parse("2006-01-02", endDate) + if err != nil { + return nil, 0, fmt.Errorf("invalid end date format: %w", err) + } + + cases, total, err := s.provinceCaseRepo.GetByDateRangePaginated(start, end, limit, offset) + if err != nil { + return nil, 0, fmt.Errorf("failed to get all province cases by date range paginated: %w", err) + } + return cases, total, nil } \ No newline at end of file diff --git a/pkg/utils/query.go b/pkg/utils/query.go new file mode 100644 index 0000000..06a2a01 --- /dev/null +++ b/pkg/utils/query.go @@ -0,0 +1,63 @@ +package utils + +import ( + "net/http" + "strconv" + "strings" +) + +// ParseIntQueryParam parses an integer query parameter with a default value +func ParseIntQueryParam(r *http.Request, key string, defaultValue int) int { + valueStr := r.URL.Query().Get(key) + if valueStr == "" { + return defaultValue + } + + value, err := strconv.Atoi(valueStr) + if err != nil { + return defaultValue + } + + return value +} + +// ParseBoolQueryParam parses a boolean query parameter +func ParseBoolQueryParam(r *http.Request, key string) bool { + return r.URL.Query().Get(key) == "true" +} + +// ParseStringArrayQueryParam parses a comma-separated string parameter into array +func ParseStringArrayQueryParam(r *http.Request, key string) []string { + valueStr := r.URL.Query().Get(key) + if valueStr == "" { + return nil + } + + values := strings.Split(valueStr, ",") + var result []string + for _, v := range values { + v = strings.TrimSpace(v) + if v != "" { + result = append(result, v) + } + } + + return result +} + +// ValidatePaginationParams validates and adjusts pagination parameters +func ValidatePaginationParams(limit, offset int) (int, int) { + // Validate limit + if limit <= 0 { + limit = 50 // Default limit + } else if limit > 1000 { + limit = 1000 // Max limit + } + + // Validate offset + if offset < 0 { + offset = 0 + } + + return limit, offset +} \ No newline at end of file From 534e7cc8ade32f7172fe61cb1615b6247ebfd556 Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Sun, 7 Sep 2025 23:13:51 +0700 Subject: [PATCH 07/13] docs: add comprehensive API documentation and update README - Add detailed API_DOCUMENTATION.md with endpoint specs - Update README with new pagination features and usage examples - Document enhanced ODP/PDP data structure - Add practical usage examples for different use cases - Include response format specifications and best practices --- API_DOCUMENTATION.md | 359 +++++++++++++++++++++++++++++++++++++++++++ README.md | 130 +++++++++++++++- 2 files changed, 483 insertions(+), 6 deletions(-) create mode 100644 API_DOCUMENTATION.md diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md new file mode 100644 index 0000000..36eaa1d --- /dev/null +++ b/API_DOCUMENTATION.md @@ -0,0 +1,359 @@ +# COVID-19 Indonesia API Documentation + +Version: 2.0.2 +Base URL: `https://pico-api.banuacoder.com/api/v1` + +## Overview + +This API provides COVID-19 data for Indonesia, including national statistics and province-level data. The API supports both paginated responses (for efficient data loading) and complete datasets (for analytics and charts). + +## Response Format + +### Success Response +```json +{ + "status": "success", + "data": { ... } +} +``` + +### Paginated Response +```json +{ + "status": "success", + "data": { + "data": [...], + "pagination": { + "limit": 50, + "offset": 0, + "total": 1000, + "total_pages": 20, + "page": 1, + "has_next": true, + "has_prev": false + } + } +} +``` + +### Error Response +```json +{ + "status": "error", + "message": "Error description" +} +``` + +## Pagination Parameters + +All endpoints support hybrid pagination: + +| Parameter | Type | Default | Max | Description | +|-----------|------|---------|-----|-------------| +| `limit` | int | 50 | 1000 | Number of records per page | +| `offset` | int | 0 | - | Number of records to skip | +| `all` | boolean | false | - | Return all data (bypasses pagination) | + +## Enhanced Province Data Structure + +Province case data now includes grouped ODP/PDP fields: + +```json +{ + "day": 100, + "date": "2024-01-15T00:00:00Z", + "daily": { + "positive": 150, + "recovered": 120, + "deceased": 10, + "active": 20, + "odp": { + "active": 5, + "finished": 20 + }, + "pdp": { + "active": 8, + "finished": 25 + } + }, + "cumulative": { + "positive": 5000, + "recovered": 4500, + "deceased": 300, + "active": 200, + "odp": { + "active": 50, + "finished": 750, + "total": 800 + }, + "pdp": { + "active": 20, + "finished": 580, + "total": 600 + } + }, + "statistics": { + "percentages": { + "active": 4.0, + "recovered": 90.0, + "deceased": 6.0 + }, + "reproduction_rate": { + "value": 1.2, + "upper_bound": 1.5, + "lower_bound": 0.9 + } + } +} +``` + +## Endpoints + +### 1. Health Check + +**GET** `/health` + +Check API health and database connectivity. + +**Response:** +```json +{ + "status": "success", + "data": { + "status": "healthy", + "service": "COVID-19 API", + "version": "2.0.2", + "timestamp": "2024-01-15T10:30:00Z", + "database": { + "status": "healthy", + "connections": { + "open": 2, + "idle": 1, + "in_use": 1, + "max_open": 5, + "wait_count": 0 + } + } + } +} +``` + +### 2. National Cases + +**GET** `/national` + +Get national COVID-19 cases data. + +**Query Parameters:** +- `start_date` (string, optional): Start date (YYYY-MM-DD) +- `end_date` (string, optional): End date (YYYY-MM-DD) + +**Examples:** +```bash +# Get all national data +GET /national + +# Get data for specific date range +GET /national?start_date=2024-01-01&end_date=2024-01-31 +``` + +### 3. Latest National Case + +**GET** `/national/latest` + +Get the most recent national case data. + +### 4. Provinces + +**GET** `/provinces` + +Get list of all provinces. + +**Query Parameters:** +- `include_latest_case` (boolean, optional): Include latest case data for each province + +**Examples:** +```bash +# Basic province list +GET /provinces + +# Provinces with latest case data +GET /provinces?include_latest_case=true +``` + +### 5. Province Cases + +**GET** `/provinces/cases` +**GET** `/provinces/{provinceId}/cases` + +Get COVID-19 cases for all provinces or a specific province. + +**Path Parameters:** +- `provinceId` (string, optional): Province ID (e.g., "31" for Jakarta) + +**Query Parameters:** +- `limit` (int, optional): Records per page (default: 50, max: 1000) +- `offset` (int, optional): Records to skip (default: 0) +- `all` (boolean, optional): Return all data without pagination +- `start_date` (string, optional): Start date (YYYY-MM-DD) +- `end_date` (string, optional): End date (YYYY-MM-DD) + +**Examples:** + +```bash +# Paginated province cases (default: 50 records) +GET /provinces/cases + +# Custom pagination +GET /provinces/cases?limit=100&offset=200 + +# All data (for charts/analytics) +GET /provinces/cases?all=true + +# Specific province with pagination +GET /provinces/31/cases?limit=30 + +# Specific province, all data +GET /provinces/31/cases?all=true + +# Date range with pagination +GET /provinces/cases?start_date=2024-01-01&end_date=2024-01-31&limit=100 + +# Date range, all data (for time series charts) +GET /provinces/cases?start_date=2024-01-01&end_date=2024-01-31&all=true +``` + +**Response Structure:** + +*Paginated Response:* +```json +{ + "status": "success", + "data": { + "data": [ + { "day": 1, "date": "2024-01-15", ... }, + { "day": 2, "date": "2024-01-14", ... } + ], + "pagination": { + "limit": 50, + "offset": 0, + "total": 1000, + "total_pages": 20, + "page": 1, + "has_next": true, + "has_prev": false + } + } +} +``` + +*All Data Response:* +```json +{ + "status": "success", + "data": [ + { "day": 1, "date": "2024-01-15", ... }, + { "day": 2, "date": "2024-01-14", ... } + ] +} +``` + +## Usage Patterns + +### 1. Efficient Data Loading (Default) +```javascript +// Load first page with 50 records +const response = await fetch('/api/v1/provinces/cases'); +const { data, pagination } = response.data; + +// Load next page +if (pagination.has_next) { + const nextPage = await fetch(`/api/v1/provinces/cases?offset=${pagination.offset + pagination.limit}`); +} +``` + +### 2. Charts & Analytics +```javascript +// Get complete dataset for time series chart +const response = await fetch('/api/v1/provinces/cases?all=true&start_date=2024-01-01&end_date=2024-12-31'); +const allData = response.data; + +// Perfect for Chart.js, D3.js, etc. +const chartData = allData.map(item => ({ + x: item.date, + y: item.cumulative.positive +})); +``` + +### 3. Province-Specific Analysis +```javascript +// Get all Jakarta data for detailed analysis +const response = await fetch('/api/v1/provinces/31/cases?all=true'); +const jakartaData = response.data; +``` + +## Error Handling + +### Common HTTP Status Codes +- `200` - Success +- `400` - Bad Request (invalid parameters) +- `404` - Not Found +- `500` - Internal Server Error +- `503` - Service Unavailable (database issues) + +### Error Response Examples +```json +{ + "status": "error", + "message": "Invalid date format. Use YYYY-MM-DD" +} +``` + +```json +{ + "status": "error", + "message": "Province not found" +} +``` + +## Rate Limiting + +- No rate limiting currently implemented +- Consider implementing if needed for production use + +## CORS + +CORS is enabled for all origins to support web applications. + +## Data Sources + +- Data is sourced from official Indonesian health authorities +- Updates are typically daily +- Historical data available from the beginning of the pandemic + +## Best Practices + +1. **Use pagination by default** for better performance +2. **Use `all=true` only for analytics/charts** to avoid large payloads +3. **Implement client-side caching** for frequently accessed data +4. **Use date ranges** to limit data scope when possible +5. **Handle errors gracefully** and implement retry logic +6. **Monitor response times** and adjust pagination limits as needed + +## Changelog + +### Version 2.0.2 +- ✅ Enhanced ODP/PDP data grouping structure +- ✅ Implemented hybrid pagination system +- ✅ Added provinces with latest case data endpoint +- ✅ Improved API response structure +- ✅ Added comprehensive pagination metadata + +### Version 2.0.1 +- Fixed database column typos +- Fixed RT display issues + +### Version 2.0.0 +- Major API restructure +- Added province-level data +- Enhanced statistics calculations \ No newline at end of file diff --git a/README.md b/README.md index 7fa5828..a075bc1 100644 --- a/README.md +++ b/README.md @@ -5,19 +5,22 @@ A Go backend service that provides REST API endpoints for COVID-19 data in Indon ## Features - 🦠 National COVID-19 cases data with daily and cumulative statistics -- 🗺️ Province-level COVID-19 data including ODP/PDP tracking +- 🗺️ Province-level COVID-19 data with enhanced ODP/PDP grouping - 📊 R-rate (reproductive rate) data when available - 🔍 Date range filtering for all endpoints +- 📄 **Hybrid pagination system** - efficient for apps, complete for charts +- 🎯 **Smart query parameters** - flexible data retrieval options - 🚀 Fast and efficient MySQL database integration - 🔧 Clean architecture with repository and service layers - 🛡️ CORS support for web frontend integration - 📝 Structured logging and error handling - 💾 Environment-based configuration +- 🚀 **Automatic deployment** with GitHub Actions ## API Endpoints ### Health Check -- `GET /api/v1/health` - Service health status +- `GET /api/v1/health` - Service health status and database connectivity ### National Data - `GET /api/v1/national` - Get all national cases @@ -26,10 +29,125 @@ A Go backend service that provides REST API endpoints for COVID-19 data in Indon ### Province Data - `GET /api/v1/provinces` - Get all provinces -- `GET /api/v1/provinces/cases` - Get all province cases -- `GET /api/v1/provinces/cases?start_date=2020-03-01&end_date=2020-12-31` - Get province cases by date range -- `GET /api/v1/provinces/{provinceId}/cases` - Get cases for specific province -- `GET /api/v1/provinces/{provinceId}/cases?start_date=2020-03-01&end_date=2020-12-31` - Get province cases by date range +- `GET /api/v1/provinces?include_latest_case=true` - Get provinces with latest case data +- `GET /api/v1/provinces/cases` - Get all province cases (paginated by default) +- `GET /api/v1/provinces/cases?all=true` - Get all province cases (complete dataset) +- `GET /api/v1/provinces/cases?limit=100&offset=50` - Get province cases with custom pagination +- `GET /api/v1/provinces/{provinceId}/cases` - Get cases for specific province (paginated) +- `GET /api/v1/provinces/{provinceId}/cases?all=true` - Get all cases for specific province + +### 🆕 Enhanced Query Parameters + +**Pagination (All province endpoints):** +- `limit` (int): Records per page (default: 50, max: 1000) +- `offset` (int): Records to skip (default: 0) +- `all` (boolean): Return complete dataset without pagination + +**Date Filtering:** +- `start_date` (YYYY-MM-DD): Filter from date +- `end_date` (YYYY-MM-DD): Filter to date + +**Province Enhancement:** +- `include_latest_case` (boolean): Include latest case data for each province + +### 📄 Response Types + +**Paginated Response:** +```json +{ + "status": "success", + "data": { + "data": [...], + "pagination": { + "limit": 50, + "offset": 0, + "total": 1000, + "page": 1, + "has_next": true, + "has_prev": false + } + } +} +``` + +**Complete Data Response:** +```json +{ + "status": "success", + "data": [...] +} +``` + +## 🆕 Enhanced Data Structure + +### Grouped ODP/PDP Data + +Province case data now includes structured ODP (Person Under Observation) and PDP (Patient Under Supervision) data: + +```json +{ + "daily": { + "positive": 150, + "odp": { + "active": 5, + "finished": 20 + }, + "pdp": { + "active": 8, + "finished": 25 + } + }, + "cumulative": { + "positive": 5000, + "odp": { + "active": 50, + "finished": 750, + "total": 800 + }, + "pdp": { + "active": 20, + "finished": 580, + "total": 600 + } + } +} +``` + +## Usage Examples + +### For Web Applications (Efficient Loading) +```javascript +// Load first page (default: 50 records) +const response = await fetch('/api/v1/provinces/cases'); +const { data, pagination } = response.data; + +// Load next page +if (pagination.has_next) { + const nextPage = await fetch(`/api/v1/provinces/cases?offset=${pagination.offset + pagination.limit}`); +} +``` + +### For Charts & Analytics (Complete Dataset) +```javascript +// Get complete dataset for time series charts +const response = await fetch('/api/v1/provinces/cases?all=true&start_date=2024-01-01'); +const allData = response.data; + +// Perfect for Chart.js, D3.js, etc. +const chartData = allData.map(item => ({ + x: item.date, + y: item.cumulative.positive +})); +``` + +### For Province-Specific Analysis +```javascript +// Get all Jakarta data +const response = await fetch('/api/v1/provinces/31/cases?all=true'); + +// Get provinces with their latest statistics +const provincesResponse = await fetch('/api/v1/provinces?include_latest_case=true'); +``` ## Setup and Installation From a0d55111616563ed403ef64f862a229258527706 Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Sun, 7 Sep 2025 23:27:07 +0700 Subject: [PATCH 08/13] feat: make provinces endpoint include latest case data by default - Change default behavior to include COVID-19 case data for better UX - Add exclude_latest_case parameter for basic province list only - Update all tests to reflect new default behavior - Update API documentation and README with new usage patterns - Maintain backward compatibility with existing parameter This change makes the COVID-19 API more intuitive by showing current pandemic status by default, which is what users typically expect. --- API_DOCUMENTATION.md | 10 +- README.md | 10 +- internal/handler/covid_handler.go | 16 +- internal/handler/covid_handler_test.go | 271 ++++++++++++++++++++++++- internal/models/pagination_test.go | 205 +++++++++++++++++++ internal/service/covid_service_test.go | 21 ++ pkg/utils/query_test.go | 222 ++++++++++++++++++++ test/integration/api_test.go | 42 +++- 8 files changed, 769 insertions(+), 28 deletions(-) create mode 100644 internal/models/pagination_test.go create mode 100644 pkg/utils/query_test.go diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index 36eaa1d..9341a70 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -167,18 +167,18 @@ Get the most recent national case data. **GET** `/provinces` -Get list of all provinces. +Get list of all provinces with their latest COVID-19 case data (default behavior). **Query Parameters:** -- `include_latest_case` (boolean, optional): Include latest case data for each province +- `exclude_latest_case` (boolean, optional): Return basic province list without case data **Examples:** ```bash -# Basic province list +# Provinces with latest case data (default) GET /provinces -# Provinces with latest case data -GET /provinces?include_latest_case=true +# Basic province list without case data +GET /provinces?exclude_latest_case=true ``` ### 5. Province Cases diff --git a/README.md b/README.md index a075bc1..b41c844 100644 --- a/README.md +++ b/README.md @@ -28,8 +28,8 @@ A Go backend service that provides REST API endpoints for COVID-19 data in Indon - `GET /api/v1/national/latest` - Get latest national case data ### Province Data -- `GET /api/v1/provinces` - Get all provinces -- `GET /api/v1/provinces?include_latest_case=true` - Get provinces with latest case data +- `GET /api/v1/provinces` - Get all provinces with latest case data (default) +- `GET /api/v1/provinces?exclude_latest_case=true` - Get basic province list without case data - `GET /api/v1/provinces/cases` - Get all province cases (paginated by default) - `GET /api/v1/provinces/cases?all=true` - Get all province cases (complete dataset) - `GET /api/v1/provinces/cases?limit=100&offset=50` - Get province cases with custom pagination @@ -48,7 +48,7 @@ A Go backend service that provides REST API endpoints for COVID-19 data in Indon - `end_date` (YYYY-MM-DD): Filter to date **Province Enhancement:** -- `include_latest_case` (boolean): Include latest case data for each province +- `exclude_latest_case` (boolean): Return basic province list without case data (default includes latest case data) ### 📄 Response Types @@ -145,8 +145,8 @@ const chartData = allData.map(item => ({ // Get all Jakarta data const response = await fetch('/api/v1/provinces/31/cases?all=true'); -// Get provinces with their latest statistics -const provincesResponse = await fetch('/api/v1/provinces?include_latest_case=true'); +// Get provinces with their latest statistics (default behavior) +const provincesResponse = await fetch('/api/v1/provinces'); ``` ## Setup and Installation diff --git a/internal/handler/covid_handler.go b/internal/handler/covid_handler.go index 7f30193..311626d 100644 --- a/internal/handler/covid_handler.go +++ b/internal/handler/covid_handler.go @@ -68,26 +68,26 @@ func (h *CovidHandler) GetLatestNationalCase(w http.ResponseWriter, r *http.Requ } func (h *CovidHandler) GetProvinces(w http.ResponseWriter, r *http.Request) { - // Check if include_latest_case query parameter is set - includeLatestCase := r.URL.Query().Get("include_latest_case") == "true" + // Check if exclude_latest_case query parameter is set to get basic province list only + excludeLatestCase := r.URL.Query().Get("exclude_latest_case") == "true" - if includeLatestCase { - provincesWithCases, err := h.covidService.GetProvincesWithLatestCase() + if excludeLatestCase { + provinces, err := h.covidService.GetProvinces() if err != nil { writeErrorResponse(w, http.StatusInternalServerError, err.Error()) return } - writeSuccessResponse(w, provincesWithCases) + writeSuccessResponse(w, provinces) return } - provinces, err := h.covidService.GetProvinces() + // Default behavior: include latest case data for COVID-19 context + provincesWithCases, err := h.covidService.GetProvincesWithLatestCase() if err != nil { writeErrorResponse(w, http.StatusInternalServerError, err.Error()) return } - - writeSuccessResponse(w, provinces) + writeSuccessResponse(w, provincesWithCases) } func (h *CovidHandler) GetProvinceCases(w http.ResponseWriter, r *http.Request) { diff --git a/internal/handler/covid_handler_test.go b/internal/handler/covid_handler_test.go index eff9478..ab10935 100644 --- a/internal/handler/covid_handler_test.go +++ b/internal/handler/covid_handler_test.go @@ -67,6 +67,27 @@ func (m *MockCovidService) GetAllProvinceCasesByDateRange(startDate, endDate str return args.Get(0).([]models.ProvinceCaseWithDate), args.Error(1) } +// Paginated methods +func (m *MockCovidService) GetProvinceCasesPaginated(provinceID string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(provinceID, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + +func (m *MockCovidService) GetProvinceCasesByDateRangePaginated(provinceID, startDate, endDate string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(provinceID, startDate, endDate, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + +func (m *MockCovidService) GetAllProvinceCasesPaginated(limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + +func (m *MockCovidService) GetAllProvinceCasesByDateRangePaginated(startDate, endDate string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(startDate, endDate, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + func TestCovidHandler_GetNationalCases(t *testing.T) { mockService := new(MockCovidService) handler := NewCovidHandler(mockService, nil) @@ -193,12 +214,28 @@ func TestCovidHandler_GetProvinces(t *testing.T) { mockService := new(MockCovidService) handler := NewCovidHandler(mockService, nil) - expectedProvinces := []models.Province{ - {ID: "11", Name: "Aceh"}, - {ID: "31", Name: "DKI Jakarta"}, + expectedProvinces := []models.ProvinceWithLatestCase{ + { + Province: models.Province{ID: "11", Name: "Aceh"}, + LatestCase: &models.ProvinceCaseResponse{ + Day: 100, + Daily: models.ProvinceDailyCases{ + Positive: 10, + }, + }, + }, + { + Province: models.Province{ID: "31", Name: "DKI Jakarta"}, + LatestCase: &models.ProvinceCaseResponse{ + Day: 101, + Daily: models.ProvinceDailyCases{ + Positive: 25, + }, + }, + }, } - mockService.On("GetProvinces").Return(expectedProvinces, nil) + mockService.On("GetProvincesWithLatestCase").Return(expectedProvinces, nil) req, err := http.NewRequest("GET", "/api/v1/provinces", nil) assert.NoError(t, err) @@ -216,15 +253,16 @@ func TestCovidHandler_GetProvinces(t *testing.T) { mockService.AssertExpectations(t) } -func TestCovidHandler_GetProvinceCases_AllProvinces(t *testing.T) { +func TestCovidHandler_GetProvinceCases_AllProvinces_Paginated(t *testing.T) { mockService := new(MockCovidService) handler := NewCovidHandler(mockService, nil) expectedCases := []models.ProvinceCaseWithDate{ {ProvinceCase: models.ProvinceCase{ID: 1, ProvinceID: "11", Positive: 50}}, } + expectedTotal := 100 - mockService.On("GetAllProvinceCases").Return(expectedCases, nil) + mockService.On("GetAllProvinceCasesPaginated", 50, 0).Return(expectedCases, expectedTotal, nil) req, err := http.NewRequest("GET", "/api/v1/provinces/cases", nil) assert.NoError(t, err) @@ -239,18 +277,34 @@ func TestCovidHandler_GetProvinceCases_AllProvinces(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "success", response.Status) + // Verify paginated response structure + paginatedData, ok := response.Data.(map[string]interface{}) + assert.True(t, ok) + assert.Contains(t, paginatedData, "data") + assert.Contains(t, paginatedData, "pagination") + + pagination, ok := paginatedData["pagination"].(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, float64(50), pagination["limit"]) + assert.Equal(t, float64(0), pagination["offset"]) + assert.Equal(t, float64(100), pagination["total"]) + assert.Equal(t, float64(1), pagination["page"]) + assert.Equal(t, true, pagination["has_next"]) + assert.Equal(t, false, pagination["has_prev"]) + mockService.AssertExpectations(t) } -func TestCovidHandler_GetProvinceCases_SpecificProvince(t *testing.T) { +func TestCovidHandler_GetProvinceCases_SpecificProvince_Paginated(t *testing.T) { mockService := new(MockCovidService) handler := NewCovidHandler(mockService, nil) expectedCases := []models.ProvinceCaseWithDate{ {ProvinceCase: models.ProvinceCase{ID: 1, ProvinceID: "11", Positive: 50}}, } + expectedTotal := 50 - mockService.On("GetProvinceCases", "11").Return(expectedCases, nil) + mockService.On("GetProvinceCasesPaginated", "11", 50, 0).Return(expectedCases, expectedTotal, nil) req, err := http.NewRequest("GET", "/api/v1/provinces/11/cases", nil) assert.NoError(t, err) @@ -267,6 +321,207 @@ func TestCovidHandler_GetProvinceCases_SpecificProvince(t *testing.T) { assert.NoError(t, err) assert.Equal(t, "success", response.Status) + // Verify paginated response structure + paginatedData, ok := response.Data.(map[string]interface{}) + assert.True(t, ok) + assert.Contains(t, paginatedData, "data") + assert.Contains(t, paginatedData, "pagination") + + mockService.AssertExpectations(t) +} + +func TestCovidHandler_GetProvinceCases_AllData(t *testing.T) { + mockService := new(MockCovidService) + handler := NewCovidHandler(mockService, nil) + + expectedCases := []models.ProvinceCaseWithDate{ + {ProvinceCase: models.ProvinceCase{ID: 1, ProvinceID: "11", Positive: 50}}, + {ProvinceCase: models.ProvinceCase{ID: 2, ProvinceID: "31", Positive: 100}}, + } + + mockService.On("GetAllProvinceCases").Return(expectedCases, nil) + + req, err := http.NewRequest("GET", "/api/v1/provinces/cases?all=true", nil) + assert.NoError(t, err) + + rr := httptest.NewRecorder() + handler.GetProvinceCases(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var response Response + err = json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "success", response.Status) + + // Verify non-paginated response structure (direct array) + responseArray, ok := response.Data.([]interface{}) + assert.True(t, ok) + assert.Len(t, responseArray, 2) + + mockService.AssertExpectations(t) +} + +func TestCovidHandler_GetProvinceCases_CustomPagination(t *testing.T) { + mockService := new(MockCovidService) + handler := NewCovidHandler(mockService, nil) + + expectedCases := []models.ProvinceCaseWithDate{ + {ProvinceCase: models.ProvinceCase{ID: 3, ProvinceID: "12", Positive: 25}}, + } + expectedTotal := 200 + + mockService.On("GetAllProvinceCasesPaginated", 100, 50).Return(expectedCases, expectedTotal, nil) + + req, err := http.NewRequest("GET", "/api/v1/provinces/cases?limit=100&offset=50", nil) + assert.NoError(t, err) + + rr := httptest.NewRecorder() + handler.GetProvinceCases(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var response Response + err = json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "success", response.Status) + + // Verify custom pagination metadata + paginatedData, ok := response.Data.(map[string]interface{}) + assert.True(t, ok) + + pagination, ok := paginatedData["pagination"].(map[string]interface{}) + assert.True(t, ok) + assert.Equal(t, float64(100), pagination["limit"]) + assert.Equal(t, float64(50), pagination["offset"]) + assert.Equal(t, float64(200), pagination["total"]) + assert.Equal(t, float64(1), pagination["page"]) + assert.Equal(t, true, pagination["has_next"]) + assert.Equal(t, true, pagination["has_prev"]) + + mockService.AssertExpectations(t) +} + +func TestCovidHandler_GetProvinceCases_DateRange_Paginated(t *testing.T) { + mockService := new(MockCovidService) + handler := NewCovidHandler(mockService, nil) + + expectedCases := []models.ProvinceCaseWithDate{ + {ProvinceCase: models.ProvinceCase{ID: 1, ProvinceID: "11", Positive: 50}}, + } + expectedTotal := 30 + + mockService.On("GetAllProvinceCasesByDateRangePaginated", "2024-01-01", "2024-01-31", 50, 0).Return(expectedCases, expectedTotal, nil) + + req, err := http.NewRequest("GET", "/api/v1/provinces/cases?start_date=2024-01-01&end_date=2024-01-31", nil) + assert.NoError(t, err) + + rr := httptest.NewRecorder() + handler.GetProvinceCases(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var response Response + err = json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "success", response.Status) + + // Verify paginated response + paginatedData, ok := response.Data.(map[string]interface{}) + assert.True(t, ok) + assert.Contains(t, paginatedData, "pagination") + + mockService.AssertExpectations(t) +} + +func TestCovidHandler_GetProvinceCases_DateRange_AllData(t *testing.T) { + mockService := new(MockCovidService) + handler := NewCovidHandler(mockService, nil) + + expectedCases := []models.ProvinceCaseWithDate{ + {ProvinceCase: models.ProvinceCase{ID: 1, ProvinceID: "11", Positive: 50}}, + } + + mockService.On("GetAllProvinceCasesByDateRange", "2024-01-01", "2024-01-31").Return(expectedCases, nil) + + req, err := http.NewRequest("GET", "/api/v1/provinces/cases?start_date=2024-01-01&end_date=2024-01-31&all=true", nil) + assert.NoError(t, err) + + rr := httptest.NewRecorder() + handler.GetProvinceCases(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var response Response + err = json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "success", response.Status) + + // Verify non-paginated response structure + responseArray, ok := response.Data.([]interface{}) + assert.True(t, ok) + assert.Len(t, responseArray, 1) + + mockService.AssertExpectations(t) +} + +func TestCovidHandler_GetProvinceCases_SpecificProvince_AllData(t *testing.T) { + mockService := new(MockCovidService) + handler := NewCovidHandler(mockService, nil) + + expectedCases := []models.ProvinceCaseWithDate{ + {ProvinceCase: models.ProvinceCase{ID: 1, ProvinceID: "31", Positive: 200}}, + } + + mockService.On("GetProvinceCases", "31").Return(expectedCases, nil) + + req, err := http.NewRequest("GET", "/api/v1/provinces/31/cases?all=true", nil) + assert.NoError(t, err) + + rr := httptest.NewRecorder() + router := mux.NewRouter() + router.HandleFunc("/api/v1/provinces/{provinceId}/cases", handler.GetProvinceCases) + router.ServeHTTP(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var response Response + err = json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "success", response.Status) + + // Verify non-paginated response structure + responseArray, ok := response.Data.([]interface{}) + assert.True(t, ok) + assert.Len(t, responseArray, 1) + + mockService.AssertExpectations(t) +} + +func TestCovidHandler_GetProvinces_ExcludeLatestCase(t *testing.T) { + mockService := new(MockCovidService) + handler := NewCovidHandler(mockService, nil) + + expectedProvinces := []models.Province{ + {ID: "11", Name: "Aceh"}, + {ID: "31", Name: "DKI Jakarta"}, + } + + mockService.On("GetProvinces").Return(expectedProvinces, nil) + + req, err := http.NewRequest("GET", "/api/v1/provinces?exclude_latest_case=true", nil) + assert.NoError(t, err) + + rr := httptest.NewRecorder() + handler.GetProvinces(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var response Response + err = json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "success", response.Status) + mockService.AssertExpectations(t) } diff --git a/internal/models/pagination_test.go b/internal/models/pagination_test.go new file mode 100644 index 0000000..4adce6e --- /dev/null +++ b/internal/models/pagination_test.go @@ -0,0 +1,205 @@ +package models + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestCalculatePaginationMeta(t *testing.T) { + tests := []struct { + name string + limit int + offset int + total int + expectedMeta PaginationMeta + }{ + { + name: "First page with results", + limit: 50, + offset: 0, + total: 200, + expectedMeta: PaginationMeta{ + Limit: 50, + Offset: 0, + Total: 200, + TotalPages: 4, + Page: 1, + HasNext: true, + HasPrev: false, + }, + }, + { + name: "Middle page", + limit: 50, + offset: 50, + total: 200, + expectedMeta: PaginationMeta{ + Limit: 50, + Offset: 50, + Total: 200, + TotalPages: 4, + Page: 2, + HasNext: true, + HasPrev: true, + }, + }, + { + name: "Last page", + limit: 50, + offset: 150, + total: 200, + expectedMeta: PaginationMeta{ + Limit: 50, + Offset: 150, + Total: 200, + TotalPages: 4, + Page: 4, + HasNext: false, + HasPrev: true, + }, + }, + { + name: "Single page with all data", + limit: 100, + offset: 0, + total: 50, + expectedMeta: PaginationMeta{ + Limit: 100, + Offset: 0, + Total: 50, + TotalPages: 1, + Page: 1, + HasNext: false, + HasPrev: false, + }, + }, + { + name: "Exact fit last page", + limit: 25, + offset: 75, + total: 100, + expectedMeta: PaginationMeta{ + Limit: 25, + Offset: 75, + Total: 100, + TotalPages: 4, + Page: 4, + HasNext: false, + HasPrev: true, + }, + }, + { + name: "Empty result set", + limit: 50, + offset: 0, + total: 0, + expectedMeta: PaginationMeta{ + Limit: 50, + Offset: 0, + Total: 0, + TotalPages: 0, + Page: 1, + HasNext: false, + HasPrev: false, + }, + }, + { + name: "Large offset beyond total", + limit: 50, + offset: 500, + total: 100, + expectedMeta: PaginationMeta{ + Limit: 50, + Offset: 500, + Total: 100, + TotalPages: 2, + Page: 11, + HasNext: false, + HasPrev: true, + }, + }, + { + name: "Partial last page", + limit: 30, + offset: 90, + total: 100, + expectedMeta: PaginationMeta{ + Limit: 30, + Offset: 90, + Total: 100, + TotalPages: 4, + Page: 4, + HasNext: false, + HasPrev: true, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + meta := CalculatePaginationMeta(tt.limit, tt.offset, tt.total) + assert.Equal(t, tt.expectedMeta, meta) + }) + } +} + +func TestPaginationMetaCalculations(t *testing.T) { + t.Run("Total pages calculation for different scenarios", func(t *testing.T) { + // Test ceiling division for total pages + assert.Equal(t, 4, CalculatePaginationMeta(33, 0, 100).TotalPages) // 100/33 = 3.03 -> 4 pages + assert.Equal(t, 2, CalculatePaginationMeta(50, 0, 100).TotalPages) // 100/50 = 2 -> 2 pages + assert.Equal(t, 3, CalculatePaginationMeta(33, 0, 99).TotalPages) // 99/33 = 3 -> 3 pages + }) + + t.Run("Page number calculation", func(t *testing.T) { + assert.Equal(t, 1, CalculatePaginationMeta(50, 0, 200).Page) // offset 0 = page 1 + assert.Equal(t, 2, CalculatePaginationMeta(50, 50, 200).Page) // offset 50 = page 2 + assert.Equal(t, 3, CalculatePaginationMeta(50, 100, 200).Page) // offset 100 = page 3 + }) + + t.Run("Has next and previous flags", func(t *testing.T) { + // First page + meta := CalculatePaginationMeta(50, 0, 200) + assert.False(t, meta.HasPrev) + assert.True(t, meta.HasNext) + + // Middle page + meta = CalculatePaginationMeta(50, 50, 200) + assert.True(t, meta.HasPrev) + assert.True(t, meta.HasNext) + + // Last page + meta = CalculatePaginationMeta(50, 150, 200) + assert.True(t, meta.HasPrev) + assert.False(t, meta.HasNext) + + // Single page + meta = CalculatePaginationMeta(100, 0, 50) + assert.False(t, meta.HasPrev) + assert.False(t, meta.HasNext) + }) +} + +func TestPaginatedResponse(t *testing.T) { + t.Run("PaginatedResponse structure", func(t *testing.T) { + testData := []string{"item1", "item2", "item3"} + pagination := PaginationMeta{ + Limit: 10, + Offset: 0, + Total: 3, + TotalPages: 1, + Page: 1, + HasNext: false, + HasPrev: false, + } + + response := PaginatedResponse{ + Data: testData, + Pagination: pagination, + } + + assert.Equal(t, testData, response.Data) + assert.Equal(t, pagination, response.Pagination) + }) +} \ No newline at end of file diff --git a/internal/service/covid_service_test.go b/internal/service/covid_service_test.go index 14adb38..d3030e8 100644 --- a/internal/service/covid_service_test.go +++ b/internal/service/covid_service_test.go @@ -89,6 +89,27 @@ func (m *MockProvinceCaseRepository) GetLatestByProvinceID(provinceID string) (* return result.(*models.ProvinceCaseWithDate), args.Error(1) } +// Paginated methods +func (m *MockProvinceCaseRepository) GetAllPaginated(limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + +func (m *MockProvinceCaseRepository) GetByProvinceIDPaginated(provinceID string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(provinceID, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + +func (m *MockProvinceCaseRepository) GetByProvinceIDAndDateRangePaginated(provinceID string, startDate, endDate time.Time, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(provinceID, startDate, endDate, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + +func (m *MockProvinceCaseRepository) GetByDateRangePaginated(startDate, endDate time.Time, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(startDate, endDate, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + func setupMockService() (*MockNationalCaseRepository, *MockProvinceRepository, *MockProvinceCaseRepository, CovidService) { mockNationalRepo := new(MockNationalCaseRepository) mockProvinceRepo := new(MockProvinceRepository) diff --git a/pkg/utils/query_test.go b/pkg/utils/query_test.go new file mode 100644 index 0000000..d05042d --- /dev/null +++ b/pkg/utils/query_test.go @@ -0,0 +1,222 @@ +package utils + +import ( + "net/http" + "net/url" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestParseIntQueryParam(t *testing.T) { + tests := []struct { + name string + queryValue string + defaultValue int + expected int + }{ + { + name: "Valid integer", + queryValue: "100", + defaultValue: 50, + expected: 100, + }, + { + name: "Empty value uses default", + queryValue: "", + defaultValue: 50, + expected: 50, + }, + { + name: "Invalid integer uses default", + queryValue: "not-a-number", + defaultValue: 50, + expected: 50, + }, + { + name: "Zero value", + queryValue: "0", + defaultValue: 50, + expected: 0, + }, + { + name: "Negative value", + queryValue: "-10", + defaultValue: 50, + expected: -10, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &http.Request{ + URL: &url.URL{ + RawQuery: url.Values{"test_param": []string{tt.queryValue}}.Encode(), + }, + } + + result := ParseIntQueryParam(req, "test_param", tt.defaultValue) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseBoolQueryParam(t *testing.T) { + tests := []struct { + name string + queryValue string + expected bool + }{ + { + name: "true value", + queryValue: "true", + expected: true, + }, + { + name: "false value", + queryValue: "false", + expected: false, + }, + { + name: "empty value", + queryValue: "", + expected: false, + }, + { + name: "non-boolean value", + queryValue: "yes", + expected: false, + }, + { + name: "1 is not true", + queryValue: "1", + expected: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &http.Request{ + URL: &url.URL{ + RawQuery: url.Values{"test_param": []string{tt.queryValue}}.Encode(), + }, + } + + result := ParseBoolQueryParam(req, "test_param") + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestParseStringArrayQueryParam(t *testing.T) { + tests := []struct { + name string + queryValue string + expected []string + }{ + { + name: "Single value", + queryValue: "value1", + expected: []string{"value1"}, + }, + { + name: "Multiple values", + queryValue: "value1,value2,value3", + expected: []string{"value1", "value2", "value3"}, + }, + { + name: "Values with spaces", + queryValue: "value1, value2 , value3", + expected: []string{"value1", "value2", "value3"}, + }, + { + name: "Empty string returns nil", + queryValue: "", + expected: nil, + }, + { + name: "Only commas and spaces", + queryValue: " , , ", + expected: nil, + }, + { + name: "Mixed empty and valid values", + queryValue: "value1,,value2, ,value3", + expected: []string{"value1", "value2", "value3"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := &http.Request{ + URL: &url.URL{ + RawQuery: url.Values{"test_param": []string{tt.queryValue}}.Encode(), + }, + } + + result := ParseStringArrayQueryParam(req, "test_param") + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestValidatePaginationParams(t *testing.T) { + tests := []struct { + name string + inputLimit int + inputOffset int + expectedLimit int + expectedOffset int + }{ + { + name: "Valid parameters", + inputLimit: 100, + inputOffset: 50, + expectedLimit: 100, + expectedOffset: 50, + }, + { + name: "Zero limit uses default", + inputLimit: 0, + inputOffset: 10, + expectedLimit: 50, + expectedOffset: 10, + }, + { + name: "Negative limit uses default", + inputLimit: -10, + inputOffset: 10, + expectedLimit: 50, + expectedOffset: 10, + }, + { + name: "Limit exceeds max", + inputLimit: 2000, + inputOffset: 10, + expectedLimit: 1000, + expectedOffset: 10, + }, + { + name: "Negative offset uses zero", + inputLimit: 100, + inputOffset: -10, + expectedLimit: 100, + expectedOffset: 0, + }, + { + name: "Both invalid values", + inputLimit: -5, + inputOffset: -10, + expectedLimit: 50, + expectedOffset: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + limit, offset := ValidatePaginationParams(tt.inputLimit, tt.inputOffset) + assert.Equal(t, tt.expectedLimit, limit) + assert.Equal(t, tt.expectedOffset, offset) + }) + } +} \ No newline at end of file diff --git a/test/integration/api_test.go b/test/integration/api_test.go index a9dd891..f7688ff 100644 --- a/test/integration/api_test.go +++ b/test/integration/api_test.go @@ -95,6 +95,27 @@ func (m *MockProvinceCaseRepo) GetLatestByProvinceID(provinceID string) (*models return result.(*models.ProvinceCaseWithDate), args.Error(1) } +// Paginated methods +func (m *MockProvinceCaseRepo) GetAllPaginated(limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + +func (m *MockProvinceCaseRepo) GetByProvinceIDPaginated(provinceID string, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(provinceID, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + +func (m *MockProvinceCaseRepo) GetByProvinceIDAndDateRangePaginated(provinceID string, startDate, endDate time.Time, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(provinceID, startDate, endDate, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + +func (m *MockProvinceCaseRepo) GetByDateRangePaginated(startDate, endDate time.Time, limit, offset int) ([]models.ProvinceCaseWithDate, int, error) { + args := m.Called(startDate, endDate, limit, offset) + return args.Get(0).([]models.ProvinceCaseWithDate), args.Int(1), args.Error(2) +} + func setupTestServer() (*httptest.Server, *MockNationalCaseRepo, *MockProvinceRepo, *MockProvinceCaseRepo) { mockNationalRepo := new(MockNationalCaseRepo) mockProvinceRepo := new(MockProvinceRepo) @@ -236,7 +257,7 @@ func TestAPI_GetLatestNationalCase(t *testing.T) { } func TestAPI_GetProvinces(t *testing.T) { - server, _, mockProvinceRepo, _ := setupTestServer() + server, _, mockProvinceRepo, mockProvinceCaseRepo := setupTestServer() defer server.Close() expectedProvinces := []models.Province{ @@ -244,7 +265,23 @@ func TestAPI_GetProvinces(t *testing.T) { {ID: "31", Name: "DKI Jakarta"}, } + // Mock the calls needed for GetProvincesWithLatestCase (default behavior) mockProvinceRepo.On("GetAll").Return(expectedProvinces, nil) + + // Mock the latest case data for each province + testTime := time.Date(2024, 1, 15, 0, 0, 0, 0, time.UTC) + mockProvinceCaseRepo.On("GetLatestByProvinceID", "11").Return(&models.ProvinceCaseWithDate{ + ProvinceCase: models.ProvinceCase{ + ID: 1, ProvinceID: "11", Positive: 10, Day: 100, + }, + Date: testTime, + }, nil) + mockProvinceCaseRepo.On("GetLatestByProvinceID", "31").Return(&models.ProvinceCaseWithDate{ + ProvinceCase: models.ProvinceCase{ + ID: 2, ProvinceID: "31", Positive: 25, Day: 100, + }, + Date: testTime, + }, nil) resp, err := http.Get(server.URL + "/api/v1/provinces") assert.NoError(t, err) @@ -262,6 +299,7 @@ func TestAPI_GetProvinces(t *testing.T) { assert.Equal(t, "success", response.Status) mockProvinceRepo.AssertExpectations(t) + mockProvinceCaseRepo.AssertExpectations(t) } func TestAPI_GetProvinceCases(t *testing.T) { @@ -279,7 +317,7 @@ func TestAPI_GetProvinceCases(t *testing.T) { }, } - mockProvinceCaseRepo.On("GetAll").Return(expectedCases, nil) + mockProvinceCaseRepo.On("GetAllPaginated", 50, 0).Return(expectedCases, len(expectedCases), nil) resp, err := http.Get(server.URL + "/api/v1/provinces/cases") assert.NoError(t, err) From ef37c5fb3b630caed3bb54f5ea8c34361f46dcbd Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Sun, 7 Sep 2025 23:39:13 +0700 Subject: [PATCH 09/13] feat: add comprehensive OpenAPI/Swagger documentation - Generate interactive Swagger UI accessible at /swagger/index.html - Add detailed Go annotations for all API endpoints with parameters, responses - Include comprehensive OpenAPI 3.0 specification (YAML/JSON) - Auto-document all models including enhanced ODP/PDP structure - Support for pagination metadata and hybrid response types - Add redirect from root to swagger docs for convenience - Include documentation regeneration instructions for developers Key improvements: - Complete API specification with examples - Type-safe client code generation support - Interactive testing interface - Automatic validation of API contracts - Professional API documentation presentation --- README.md | 23 + cmd/main.go | 31 ++ docs/README.md | 63 +++ docs/docs.go | 752 ++++++++++++++++++++++++++++++ docs/swagger.json | 732 +++++++++++++++++++++++++++++ docs/swagger.yaml | 463 ++++++++++++++++++ go.mod | 28 +- go.sum | 88 +++- internal/handler/covid_handler.go | 65 +++ internal/handler/routes.go | 11 + 10 files changed, 2254 insertions(+), 2 deletions(-) create mode 100644 docs/README.md create mode 100644 docs/docs.go create mode 100644 docs/swagger.json create mode 100644 docs/swagger.yaml diff --git a/README.md b/README.md index b41c844..d869970 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,7 @@ A Go backend service that provides REST API endpoints for COVID-19 data in Indon - 📊 R-rate (reproductive rate) data when available - 🔍 Date range filtering for all endpoints - 📄 **Hybrid pagination system** - efficient for apps, complete for charts +- 📚 **Interactive API Documentation** - Auto-generated OpenAPI/Swagger docs - 🎯 **Smart query parameters** - flexible data retrieval options - 🚀 Fast and efficient MySQL database integration - 🔧 Clean architecture with repository and service layers @@ -17,6 +18,16 @@ A Go backend service that provides REST API endpoints for COVID-19 data in Indon - 💾 Environment-based configuration - 🚀 **Automatic deployment** with GitHub Actions +## 📚 API Documentation + +### Interactive Swagger UI +- **Local development**: http://localhost:8080/swagger/index.html +- **Production**: https://pico-api.banuacoder.com/swagger/index.html + +### OpenAPI Specification +- YAML: [`docs/swagger.yaml`](docs/swagger.yaml) +- JSON: [`docs/swagger.json`](docs/swagger.json) + ## API Endpoints ### Health Check @@ -200,6 +211,18 @@ go build -o pico-api-go cmd/main.go ./pico-api-go ``` +### Regenerating API Documentation + +After modifying handlers or adding new endpoints, regenerate the Swagger docs: + +```bash +# Install swag tool (one-time setup) +go install github.com/swaggo/swag/cmd/swag@latest + +# Generate documentation +swag init -g cmd/main.go -o ./docs +``` + ## Database Schema The API uses three main tables: diff --git a/cmd/main.go b/cmd/main.go index 5c39988..4484bc6 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,3 +1,33 @@ +// Package main provides the entry point for the COVID-19 Indonesia API +// +// @title COVID-19 Indonesia API +// @version 2.0.2 +// @description A comprehensive REST API for COVID-19 data in Indonesia, including national cases and province-level statistics with enhanced ODP/PDP grouping and hybrid pagination. +// @termsOfService http://swagger.io/terms/ +// +// @contact.name API Support +// @contact.url https://github.com/banua-coder/pico-api-go +// @contact.email support@banuacoder.com +// +// @license.name MIT +// @license.url https://opensource.org/licenses/MIT +// +// @host pico-api.banuacoder.com +// @BasePath /api/v1 +// +// @schemes https http +// +// @tag.name health +// @tag.description Health check operations +// +// @tag.name national +// @tag.description National COVID-19 case operations +// +// @tag.name provinces +// @tag.description Province information and COVID-19 case operations +// +// @tag.name province-cases +// @tag.description Province-level COVID-19 case data with pagination support package main import ( @@ -11,6 +41,7 @@ import ( "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/database" + _ "github.com/banua-coder/pico-api-go/docs" // Import generated docs ) func main() { diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..1eb5203 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,63 @@ +# API Documentation + +This directory contains auto-generated OpenAPI/Swagger documentation for the COVID-19 Indonesia API. + +## Files + +- `swagger.yaml` - OpenAPI 3.0 specification in YAML format +- `swagger.json` - OpenAPI 3.0 specification in JSON format +- `docs.go` - Generated Go file containing the documentation + +## Usage + +### Interactive Documentation + +When the API server is running, you can access the interactive Swagger UI at: +- **Local development**: http://localhost:8080/swagger/index.html +- **Production**: https://pico-api.banuacoder.com/swagger/index.html + +### Swagger Specification Files + +You can also use the specification files directly with various tools: + +```bash +# Validate the OpenAPI spec +swagger-codegen validate -i docs/swagger.yaml + +# Generate client SDKs +swagger-codegen generate -i docs/swagger.yaml -l javascript -o clients/js +swagger-codegen generate -i docs/swagger.yaml -l python -o clients/python + +# Import into Postman, Insomnia, or other API tools +# Use the swagger.json or swagger.yaml file +``` + +## Regenerating Documentation + +To regenerate the documentation after code changes: + +```bash +swag init -g cmd/main.go -o ./docs +``` + +This will update all files in this directory based on the Go code annotations. + +## Key Features Documented + +- 🏥 **Health Check** - API status and database connectivity +- 🇮🇩 **National Data** - COVID-19 statistics for Indonesia +- 🗺️ **Province Data** - Provincial COVID-19 information with latest case data by default +- 📊 **Province Cases** - Detailed case data with hybrid pagination support +- 📄 **Pagination** - Comprehensive pagination metadata and flexible data retrieval +- 🏷️ **Enhanced Data Structure** - Grouped ODP/PDP fields with proper daily/cumulative separation + +## Response Models + +All response models include proper JSON schema definitions with: +- Type validation +- Required field specifications +- Example values +- Field descriptions +- Nested object relationships + +This makes it easy to generate client code, validate API responses, and understand the data structure. \ No newline at end of file diff --git a/docs/docs.go b/docs/docs.go new file mode 100644 index 0000000..3005ba8 --- /dev/null +++ b/docs/docs.go @@ -0,0 +1,752 @@ +// Package docs Code generated by swaggo/swag. DO NOT EDIT +package docs + +import "github.com/swaggo/swag" + +const docTemplate = `{ + "schemes": {{ marshal .Schemes }}, + "swagger": "2.0", + "info": { + "description": "{{escape .Description}}", + "title": "{{.Title}}", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "https://github.com/banua-coder/pico-api-go", + "email": "support@banuacoder.com" + }, + "license": { + "name": "MIT", + "url": "https://opensource.org/licenses/MIT" + }, + "version": "{{.Version}}" + }, + "host": "{{.Host}}", + "basePath": "{{.BasePath}}", + "paths": { + "/health": { + "get": { + "description": "Check API health status and database connectivity", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "health" + ], + "summary": "Health check", + "responses": { + "200": { + "description": "API is healthy", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": true + } + } + } + ] + } + }, + "503": { + "description": "API is degraded (database issues)", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": true + } + } + } + ] + } + } + } + } + }, + "/national": { + "get": { + "description": "Retrieve national COVID-19 cases data with optional date range filtering", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "national" + ], + "summary": "Get national COVID-19 cases", + "parameters": [ + { + "type": "string", + "description": "Start date (YYYY-MM-DD)", + "name": "start_date", + "in": "query" + }, + { + "type": "string", + "description": "End date (YYYY-MM-DD)", + "name": "end_date", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.NationalCaseResponse" + } + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handler.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.Response" + } + } + } + } + }, + "/national/latest": { + "get": { + "description": "Retrieve the most recent national COVID-19 case data", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "national" + ], + "summary": "Get latest national COVID-19 case", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.NationalCaseResponse" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handler.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.Response" + } + } + } + } + }, + "/provinces": { + "get": { + "description": "Retrieve all provinces with their latest COVID-19 case data by default. Use exclude_latest_case=true for basic province list only.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "provinces" + ], + "summary": "Get provinces with COVID-19 data", + "parameters": [ + { + "type": "boolean", + "description": "Exclude latest case data (default: false)", + "name": "exclude_latest_case", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Basic province list when exclude_latest_case=true", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Province" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.Response" + } + } + } + } + }, + "/provinces/cases": { + "get": { + "description": "Retrieve COVID-19 cases for all provinces or a specific province with hybrid pagination support", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "province-cases" + ], + "summary": "Get province COVID-19 cases", + "parameters": [ + { + "type": "integer", + "description": "Records per page (default: 50, max: 1000)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Records to skip (default: 0)", + "name": "offset", + "in": "query" + }, + { + "type": "boolean", + "description": "Return all data without pagination", + "name": "all", + "in": "query" + }, + { + "type": "string", + "description": "Start date (YYYY-MM-DD)", + "name": "start_date", + "in": "query" + }, + { + "type": "string", + "description": "End date (YYYY-MM-DD)", + "name": "end_date", + "in": "query" + } + ], + "responses": { + "200": { + "description": "All data response when all=true", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProvinceCaseResponse" + } + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handler.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.Response" + } + } + } + } + }, + "/provinces/{provinceId}/cases": { + "get": { + "description": "Retrieve COVID-19 cases for all provinces or a specific province with hybrid pagination support", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "province-cases" + ], + "summary": "Get province COVID-19 cases", + "parameters": [ + { + "type": "string", + "description": "Province ID (e.g., '31' for Jakarta)", + "name": "provinceId", + "in": "path" + }, + { + "type": "integer", + "description": "Records per page (default: 50, max: 1000)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Records to skip (default: 0)", + "name": "offset", + "in": "query" + }, + { + "type": "boolean", + "description": "Return all data without pagination", + "name": "all", + "in": "query" + }, + { + "type": "string", + "description": "Start date (YYYY-MM-DD)", + "name": "start_date", + "in": "query" + }, + { + "type": "string", + "description": "End date (YYYY-MM-DD)", + "name": "end_date", + "in": "query" + } + ], + "responses": { + "200": { + "description": "All data response when all=true", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProvinceCaseResponse" + } + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handler.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.Response" + } + } + } + } + } + }, + "definitions": { + "handler.Response": { + "type": "object", + "properties": { + "data": {}, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "models.CasePercentages": { + "type": "object", + "properties": { + "active": { + "type": "number" + }, + "deceased": { + "type": "number" + }, + "recovered": { + "type": "number" + } + } + }, + "models.CumulativeCases": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "deceased": { + "type": "integer" + }, + "positive": { + "type": "integer" + }, + "recovered": { + "type": "integer" + } + } + }, + "models.DailyCases": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "deceased": { + "type": "integer" + }, + "positive": { + "type": "integer" + }, + "recovered": { + "type": "integer" + } + } + }, + "models.DailyObservationData": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "finished": { + "type": "integer" + } + } + }, + "models.DailySupervisionData": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "finished": { + "type": "integer" + } + } + }, + "models.NationalCaseResponse": { + "type": "object", + "properties": { + "cumulative": { + "$ref": "#/definitions/models.CumulativeCases" + }, + "daily": { + "$ref": "#/definitions/models.DailyCases" + }, + "date": { + "type": "string" + }, + "day": { + "type": "integer" + }, + "statistics": { + "$ref": "#/definitions/models.NationalCaseStatistics" + } + } + }, + "models.NationalCaseStatistics": { + "type": "object", + "properties": { + "percentages": { + "$ref": "#/definitions/models.CasePercentages" + }, + "reproduction_rate": { + "$ref": "#/definitions/models.ReproductionRate" + } + } + }, + "models.ObservationData": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "finished": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "models.PaginatedResponse": { + "type": "object", + "properties": { + "data": {}, + "pagination": { + "$ref": "#/definitions/models.PaginationMeta" + } + } + }, + "models.PaginationMeta": { + "type": "object", + "properties": { + "has_next": { + "type": "boolean" + }, + "has_prev": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + } + } + }, + "models.Province": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "models.ProvinceCaseResponse": { + "type": "object", + "properties": { + "cumulative": { + "$ref": "#/definitions/models.ProvinceCumulativeCases" + }, + "daily": { + "$ref": "#/definitions/models.ProvinceDailyCases" + }, + "date": { + "type": "string" + }, + "day": { + "type": "integer" + }, + "province": { + "$ref": "#/definitions/models.Province" + }, + "statistics": { + "$ref": "#/definitions/models.ProvinceCaseStatistics" + } + } + }, + "models.ProvinceCaseStatistics": { + "type": "object", + "properties": { + "percentages": { + "$ref": "#/definitions/models.CasePercentages" + }, + "reproduction_rate": { + "$ref": "#/definitions/models.ReproductionRate" + } + } + }, + "models.ProvinceCumulativeCases": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "deceased": { + "type": "integer" + }, + "odp": { + "$ref": "#/definitions/models.ObservationData" + }, + "pdp": { + "$ref": "#/definitions/models.SupervisionData" + }, + "positive": { + "type": "integer" + }, + "recovered": { + "type": "integer" + } + } + }, + "models.ProvinceDailyCases": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "deceased": { + "type": "integer" + }, + "odp": { + "$ref": "#/definitions/models.DailyObservationData" + }, + "pdp": { + "$ref": "#/definitions/models.DailySupervisionData" + }, + "positive": { + "type": "integer" + }, + "recovered": { + "type": "integer" + } + } + }, + "models.ProvinceWithLatestCase": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "latest_case": { + "$ref": "#/definitions/models.ProvinceCaseResponse" + }, + "name": { + "type": "string" + } + } + }, + "models.ReproductionRate": { + "type": "object", + "properties": { + "lower_bound": { + "type": "number" + }, + "upper_bound": { + "type": "number" + }, + "value": { + "type": "number" + } + } + }, + "models.SupervisionData": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "finished": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + } + }, + "tags": [ + { + "description": "Health check operations", + "name": "health" + }, + { + "description": "National COVID-19 case operations", + "name": "national" + }, + { + "description": "Province information and COVID-19 case operations", + "name": "provinces" + }, + { + "description": "Province-level COVID-19 case data with pagination support", + "name": "province-cases" + } + ] +}` + +// SwaggerInfo holds exported Swagger Info so clients can modify it +var SwaggerInfo = &swag.Spec{ + Version: "2.0.2", + Host: "pico-api.banuacoder.com", + BasePath: "/api/v1", + Schemes: []string{"https", "http"}, + Title: "COVID-19 Indonesia API", + Description: "A comprehensive REST API for COVID-19 data in Indonesia, including national cases and province-level statistics with enhanced ODP/PDP grouping and hybrid pagination.", + InfoInstanceName: "swagger", + SwaggerTemplate: docTemplate, + LeftDelim: "{{", + RightDelim: "}}", +} + +func init() { + swag.Register(SwaggerInfo.InstanceName(), SwaggerInfo) +} diff --git a/docs/swagger.json b/docs/swagger.json new file mode 100644 index 0000000..7e96a1c --- /dev/null +++ b/docs/swagger.json @@ -0,0 +1,732 @@ +{ + "schemes": [ + "https", + "http" + ], + "swagger": "2.0", + "info": { + "description": "A comprehensive REST API for COVID-19 data in Indonesia, including national cases and province-level statistics with enhanced ODP/PDP grouping and hybrid pagination.", + "title": "COVID-19 Indonesia API", + "termsOfService": "http://swagger.io/terms/", + "contact": { + "name": "API Support", + "url": "https://github.com/banua-coder/pico-api-go", + "email": "support@banuacoder.com" + }, + "license": { + "name": "MIT", + "url": "https://opensource.org/licenses/MIT" + }, + "version": "2.0.2" + }, + "host": "pico-api.banuacoder.com", + "basePath": "/api/v1", + "paths": { + "/health": { + "get": { + "description": "Check API health status and database connectivity", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "health" + ], + "summary": "Health check", + "responses": { + "200": { + "description": "API is healthy", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": true + } + } + } + ] + } + }, + "503": { + "description": "API is degraded (database issues)", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": true + } + } + } + ] + } + } + } + } + }, + "/national": { + "get": { + "description": "Retrieve national COVID-19 cases data with optional date range filtering", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "national" + ], + "summary": "Get national COVID-19 cases", + "parameters": [ + { + "type": "string", + "description": "Start date (YYYY-MM-DD)", + "name": "start_date", + "in": "query" + }, + { + "type": "string", + "description": "End date (YYYY-MM-DD)", + "name": "end_date", + "in": "query" + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.NationalCaseResponse" + } + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handler.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.Response" + } + } + } + } + }, + "/national/latest": { + "get": { + "description": "Retrieve the most recent national COVID-19 case data", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "national" + ], + "summary": "Get latest national COVID-19 case", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "$ref": "#/definitions/models.NationalCaseResponse" + } + } + } + ] + } + }, + "404": { + "description": "Not Found", + "schema": { + "$ref": "#/definitions/handler.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.Response" + } + } + } + } + }, + "/provinces": { + "get": { + "description": "Retrieve all provinces with their latest COVID-19 case data by default. Use exclude_latest_case=true for basic province list only.", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "provinces" + ], + "summary": "Get provinces with COVID-19 data", + "parameters": [ + { + "type": "boolean", + "description": "Exclude latest case data (default: false)", + "name": "exclude_latest_case", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Basic province list when exclude_latest_case=true", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.Province" + } + } + } + } + ] + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.Response" + } + } + } + } + }, + "/provinces/cases": { + "get": { + "description": "Retrieve COVID-19 cases for all provinces or a specific province with hybrid pagination support", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "province-cases" + ], + "summary": "Get province COVID-19 cases", + "parameters": [ + { + "type": "integer", + "description": "Records per page (default: 50, max: 1000)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Records to skip (default: 0)", + "name": "offset", + "in": "query" + }, + { + "type": "boolean", + "description": "Return all data without pagination", + "name": "all", + "in": "query" + }, + { + "type": "string", + "description": "Start date (YYYY-MM-DD)", + "name": "start_date", + "in": "query" + }, + { + "type": "string", + "description": "End date (YYYY-MM-DD)", + "name": "end_date", + "in": "query" + } + ], + "responses": { + "200": { + "description": "All data response when all=true", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProvinceCaseResponse" + } + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handler.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.Response" + } + } + } + } + }, + "/provinces/{provinceId}/cases": { + "get": { + "description": "Retrieve COVID-19 cases for all provinces or a specific province with hybrid pagination support", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "province-cases" + ], + "summary": "Get province COVID-19 cases", + "parameters": [ + { + "type": "string", + "description": "Province ID (e.g., '31' for Jakarta)", + "name": "provinceId", + "in": "path" + }, + { + "type": "integer", + "description": "Records per page (default: 50, max: 1000)", + "name": "limit", + "in": "query" + }, + { + "type": "integer", + "description": "Records to skip (default: 0)", + "name": "offset", + "in": "query" + }, + { + "type": "boolean", + "description": "Return all data without pagination", + "name": "all", + "in": "query" + }, + { + "type": "string", + "description": "Start date (YYYY-MM-DD)", + "name": "start_date", + "in": "query" + }, + { + "type": "string", + "description": "End date (YYYY-MM-DD)", + "name": "end_date", + "in": "query" + } + ], + "responses": { + "200": { + "description": "All data response when all=true", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/definitions/models.ProvinceCaseResponse" + } + } + } + } + ] + } + }, + "400": { + "description": "Bad Request", + "schema": { + "$ref": "#/definitions/handler.Response" + } + }, + "500": { + "description": "Internal Server Error", + "schema": { + "$ref": "#/definitions/handler.Response" + } + } + } + } + } + }, + "definitions": { + "handler.Response": { + "type": "object", + "properties": { + "data": {}, + "error": { + "type": "string" + }, + "message": { + "type": "string" + }, + "status": { + "type": "string" + } + } + }, + "models.CasePercentages": { + "type": "object", + "properties": { + "active": { + "type": "number" + }, + "deceased": { + "type": "number" + }, + "recovered": { + "type": "number" + } + } + }, + "models.CumulativeCases": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "deceased": { + "type": "integer" + }, + "positive": { + "type": "integer" + }, + "recovered": { + "type": "integer" + } + } + }, + "models.DailyCases": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "deceased": { + "type": "integer" + }, + "positive": { + "type": "integer" + }, + "recovered": { + "type": "integer" + } + } + }, + "models.DailyObservationData": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "finished": { + "type": "integer" + } + } + }, + "models.DailySupervisionData": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "finished": { + "type": "integer" + } + } + }, + "models.NationalCaseResponse": { + "type": "object", + "properties": { + "cumulative": { + "$ref": "#/definitions/models.CumulativeCases" + }, + "daily": { + "$ref": "#/definitions/models.DailyCases" + }, + "date": { + "type": "string" + }, + "day": { + "type": "integer" + }, + "statistics": { + "$ref": "#/definitions/models.NationalCaseStatistics" + } + } + }, + "models.NationalCaseStatistics": { + "type": "object", + "properties": { + "percentages": { + "$ref": "#/definitions/models.CasePercentages" + }, + "reproduction_rate": { + "$ref": "#/definitions/models.ReproductionRate" + } + } + }, + "models.ObservationData": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "finished": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + }, + "models.PaginatedResponse": { + "type": "object", + "properties": { + "data": {}, + "pagination": { + "$ref": "#/definitions/models.PaginationMeta" + } + } + }, + "models.PaginationMeta": { + "type": "object", + "properties": { + "has_next": { + "type": "boolean" + }, + "has_prev": { + "type": "boolean" + }, + "limit": { + "type": "integer" + }, + "offset": { + "type": "integer" + }, + "page": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "total_pages": { + "type": "integer" + } + } + }, + "models.Province": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "name": { + "type": "string" + } + } + }, + "models.ProvinceCaseResponse": { + "type": "object", + "properties": { + "cumulative": { + "$ref": "#/definitions/models.ProvinceCumulativeCases" + }, + "daily": { + "$ref": "#/definitions/models.ProvinceDailyCases" + }, + "date": { + "type": "string" + }, + "day": { + "type": "integer" + }, + "province": { + "$ref": "#/definitions/models.Province" + }, + "statistics": { + "$ref": "#/definitions/models.ProvinceCaseStatistics" + } + } + }, + "models.ProvinceCaseStatistics": { + "type": "object", + "properties": { + "percentages": { + "$ref": "#/definitions/models.CasePercentages" + }, + "reproduction_rate": { + "$ref": "#/definitions/models.ReproductionRate" + } + } + }, + "models.ProvinceCumulativeCases": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "deceased": { + "type": "integer" + }, + "odp": { + "$ref": "#/definitions/models.ObservationData" + }, + "pdp": { + "$ref": "#/definitions/models.SupervisionData" + }, + "positive": { + "type": "integer" + }, + "recovered": { + "type": "integer" + } + } + }, + "models.ProvinceDailyCases": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "deceased": { + "type": "integer" + }, + "odp": { + "$ref": "#/definitions/models.DailyObservationData" + }, + "pdp": { + "$ref": "#/definitions/models.DailySupervisionData" + }, + "positive": { + "type": "integer" + }, + "recovered": { + "type": "integer" + } + } + }, + "models.ProvinceWithLatestCase": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "latest_case": { + "$ref": "#/definitions/models.ProvinceCaseResponse" + }, + "name": { + "type": "string" + } + } + }, + "models.ReproductionRate": { + "type": "object", + "properties": { + "lower_bound": { + "type": "number" + }, + "upper_bound": { + "type": "number" + }, + "value": { + "type": "number" + } + } + }, + "models.SupervisionData": { + "type": "object", + "properties": { + "active": { + "type": "integer" + }, + "finished": { + "type": "integer" + }, + "total": { + "type": "integer" + } + } + } + }, + "tags": [ + { + "description": "Health check operations", + "name": "health" + }, + { + "description": "National COVID-19 case operations", + "name": "national" + }, + { + "description": "Province information and COVID-19 case operations", + "name": "provinces" + }, + { + "description": "Province-level COVID-19 case data with pagination support", + "name": "province-cases" + } + ] +} \ No newline at end of file diff --git a/docs/swagger.yaml b/docs/swagger.yaml new file mode 100644 index 0000000..298dbe3 --- /dev/null +++ b/docs/swagger.yaml @@ -0,0 +1,463 @@ +basePath: /api/v1 +definitions: + handler.Response: + properties: + data: {} + error: + type: string + message: + type: string + status: + type: string + type: object + models.CasePercentages: + properties: + active: + type: number + deceased: + type: number + recovered: + type: number + type: object + models.CumulativeCases: + properties: + active: + type: integer + deceased: + type: integer + positive: + type: integer + recovered: + type: integer + type: object + models.DailyCases: + properties: + active: + type: integer + deceased: + type: integer + positive: + type: integer + recovered: + type: integer + type: object + models.DailyObservationData: + properties: + active: + type: integer + finished: + type: integer + type: object + models.DailySupervisionData: + properties: + active: + type: integer + finished: + type: integer + type: object + models.NationalCaseResponse: + properties: + cumulative: + $ref: '#/definitions/models.CumulativeCases' + daily: + $ref: '#/definitions/models.DailyCases' + date: + type: string + day: + type: integer + statistics: + $ref: '#/definitions/models.NationalCaseStatistics' + type: object + models.NationalCaseStatistics: + properties: + percentages: + $ref: '#/definitions/models.CasePercentages' + reproduction_rate: + $ref: '#/definitions/models.ReproductionRate' + type: object + models.ObservationData: + properties: + active: + type: integer + finished: + type: integer + total: + type: integer + type: object + models.PaginatedResponse: + properties: + data: {} + pagination: + $ref: '#/definitions/models.PaginationMeta' + type: object + models.PaginationMeta: + properties: + has_next: + type: boolean + has_prev: + type: boolean + limit: + type: integer + offset: + type: integer + page: + type: integer + total: + type: integer + total_pages: + type: integer + type: object + models.Province: + properties: + id: + type: string + name: + type: string + type: object + models.ProvinceCaseResponse: + properties: + cumulative: + $ref: '#/definitions/models.ProvinceCumulativeCases' + daily: + $ref: '#/definitions/models.ProvinceDailyCases' + date: + type: string + day: + type: integer + province: + $ref: '#/definitions/models.Province' + statistics: + $ref: '#/definitions/models.ProvinceCaseStatistics' + type: object + models.ProvinceCaseStatistics: + properties: + percentages: + $ref: '#/definitions/models.CasePercentages' + reproduction_rate: + $ref: '#/definitions/models.ReproductionRate' + type: object + models.ProvinceCumulativeCases: + properties: + active: + type: integer + deceased: + type: integer + odp: + $ref: '#/definitions/models.ObservationData' + pdp: + $ref: '#/definitions/models.SupervisionData' + positive: + type: integer + recovered: + type: integer + type: object + models.ProvinceDailyCases: + properties: + active: + type: integer + deceased: + type: integer + odp: + $ref: '#/definitions/models.DailyObservationData' + pdp: + $ref: '#/definitions/models.DailySupervisionData' + positive: + type: integer + recovered: + type: integer + type: object + models.ProvinceWithLatestCase: + properties: + id: + type: string + latest_case: + $ref: '#/definitions/models.ProvinceCaseResponse' + name: + type: string + type: object + models.ReproductionRate: + properties: + lower_bound: + type: number + upper_bound: + type: number + value: + type: number + type: object + models.SupervisionData: + properties: + active: + type: integer + finished: + type: integer + total: + type: integer + type: object +host: pico-api.banuacoder.com +info: + contact: + email: support@banuacoder.com + name: API Support + url: https://github.com/banua-coder/pico-api-go + description: A comprehensive REST API for COVID-19 data in Indonesia, including + national cases and province-level statistics with enhanced ODP/PDP grouping and + hybrid pagination. + license: + name: MIT + url: https://opensource.org/licenses/MIT + termsOfService: http://swagger.io/terms/ + title: COVID-19 Indonesia API + version: 2.0.2 +paths: + /health: + get: + consumes: + - application/json + description: Check API health status and database connectivity + produces: + - application/json + responses: + "200": + description: API is healthy + schema: + allOf: + - $ref: '#/definitions/handler.Response' + - properties: + data: + additionalProperties: true + type: object + type: object + "503": + description: API is degraded (database issues) + schema: + allOf: + - $ref: '#/definitions/handler.Response' + - properties: + data: + additionalProperties: true + type: object + type: object + summary: Health check + tags: + - health + /national: + get: + consumes: + - application/json + description: Retrieve national COVID-19 cases data with optional date range + filtering + parameters: + - description: Start date (YYYY-MM-DD) + in: query + name: start_date + type: string + - description: End date (YYYY-MM-DD) + in: query + name: end_date + type: string + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.Response' + - properties: + data: + items: + $ref: '#/definitions/models.NationalCaseResponse' + type: array + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/handler.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handler.Response' + summary: Get national COVID-19 cases + tags: + - national + /national/latest: + get: + consumes: + - application/json + description: Retrieve the most recent national COVID-19 case data + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.Response' + - properties: + data: + $ref: '#/definitions/models.NationalCaseResponse' + type: object + "404": + description: Not Found + schema: + $ref: '#/definitions/handler.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handler.Response' + summary: Get latest national COVID-19 case + tags: + - national + /provinces: + get: + consumes: + - application/json + description: Retrieve all provinces with their latest COVID-19 case data by + default. Use exclude_latest_case=true for basic province list only. + parameters: + - description: 'Exclude latest case data (default: false)' + in: query + name: exclude_latest_case + type: boolean + produces: + - application/json + responses: + "200": + description: Basic province list when exclude_latest_case=true + schema: + allOf: + - $ref: '#/definitions/handler.Response' + - properties: + data: + items: + $ref: '#/definitions/models.Province' + type: array + type: object + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handler.Response' + summary: Get provinces with COVID-19 data + tags: + - provinces + /provinces/{provinceId}/cases: + get: + consumes: + - application/json + description: Retrieve COVID-19 cases for all provinces or a specific province + with hybrid pagination support + parameters: + - description: Province ID (e.g., '31' for Jakarta) + in: path + name: provinceId + type: string + - description: 'Records per page (default: 50, max: 1000)' + in: query + name: limit + type: integer + - description: 'Records to skip (default: 0)' + in: query + name: offset + type: integer + - description: Return all data without pagination + in: query + name: all + type: boolean + - description: Start date (YYYY-MM-DD) + in: query + name: start_date + type: string + - description: End date (YYYY-MM-DD) + in: query + name: end_date + type: string + produces: + - application/json + responses: + "200": + description: All data response when all=true + schema: + allOf: + - $ref: '#/definitions/handler.Response' + - properties: + data: + items: + $ref: '#/definitions/models.ProvinceCaseResponse' + type: array + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/handler.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handler.Response' + summary: Get province COVID-19 cases + tags: + - province-cases + /provinces/cases: + get: + consumes: + - application/json + description: Retrieve COVID-19 cases for all provinces or a specific province + with hybrid pagination support + parameters: + - description: 'Records per page (default: 50, max: 1000)' + in: query + name: limit + type: integer + - description: 'Records to skip (default: 0)' + in: query + name: offset + type: integer + - description: Return all data without pagination + in: query + name: all + type: boolean + - description: Start date (YYYY-MM-DD) + in: query + name: start_date + type: string + - description: End date (YYYY-MM-DD) + in: query + name: end_date + type: string + produces: + - application/json + responses: + "200": + description: All data response when all=true + schema: + allOf: + - $ref: '#/definitions/handler.Response' + - properties: + data: + items: + $ref: '#/definitions/models.ProvinceCaseResponse' + type: array + type: object + "400": + description: Bad Request + schema: + $ref: '#/definitions/handler.Response' + "500": + description: Internal Server Error + schema: + $ref: '#/definitions/handler.Response' + summary: Get province COVID-19 cases + tags: + - province-cases +schemes: +- https +- http +swagger: "2.0" +tags: +- description: Health check operations + name: health +- description: National COVID-19 case operations + name: national +- description: Province information and COVID-19 case operations + name: provinces +- description: Province-level COVID-19 case data with pagination support + name: province-cases diff --git a/go.mod b/go.mod index 8755a21..4022130 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/banua-coder/pico-api-go -go 1.23 +go 1.24.0 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 @@ -8,12 +8,38 @@ require ( github.com/gorilla/mux v1.8.1 github.com/joho/godotenv v1.5.1 github.com/stretchr/testify v1.11.1 + github.com/swaggo/http-swagger v1.3.4 ) require ( filippo.io/edwards25519 v1.1.0 // indirect + github.com/KyleBanks/depth v1.2.1 // indirect github.com/davecgh/go-spew v1.1.1 // 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 + github.com/go-openapi/swag v0.24.1 // indirect + github.com/go-openapi/swag/cmdutils v0.24.0 // indirect + github.com/go-openapi/swag/conv v0.24.0 // indirect + github.com/go-openapi/swag/fileutils v0.24.0 // indirect + github.com/go-openapi/swag/jsonname v0.24.0 // indirect + github.com/go-openapi/swag/jsonutils v0.24.0 // indirect + github.com/go-openapi/swag/loading v0.24.0 // indirect + github.com/go-openapi/swag/mangling v0.24.0 // indirect + github.com/go-openapi/swag/netutils v0.24.0 // indirect + github.com/go-openapi/swag/stringutils v0.24.0 // indirect + github.com/go-openapi/swag/typeutils v0.24.0 // indirect + github.com/go-openapi/swag/yamlutils v0.24.0 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/mailru/easyjson v0.9.0 // indirect 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 + github.com/swaggo/swag v1.16.6 // 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 + golang.org/x/tools v0.36.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 2724e41..83d2836 100644 --- a/go.sum +++ b/go.sum @@ -2,22 +2,108 @@ filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU= 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/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/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= +github.com/go-openapi/jsonreference v0.21.1/go.mod h1:PWs8rO4xxTUqKGu+lEvvCxD5k2X7QYkKAepJyCmSTT8= +github.com/go-openapi/spec v0.21.0 h1:LTVzPc3p/RzRnkQqLRndbAzjY0d0BCL72A6j3CdL9ZY= +github.com/go-openapi/spec v0.21.0/go.mod h1:78u6VdPw81XU44qEWGhtr982gJ5BWg2c0I5XwVMotYk= +github.com/go-openapi/swag v0.24.1 h1:DPdYTZKo6AQCRqzwr/kGkxJzHhpKxZ9i/oX0zag+MF8= +github.com/go-openapi/swag v0.24.1/go.mod h1:sm8I3lCPlspsBBwUm1t5oZeWZS0s7m/A+Psg0ooRU0A= +github.com/go-openapi/swag/cmdutils v0.24.0 h1:KlRCffHwXFI6E5MV9n8o8zBRElpY4uK4yWyAMWETo9I= +github.com/go-openapi/swag/cmdutils v0.24.0/go.mod h1:uxib2FAeQMByyHomTlsP8h1TtPd54Msu2ZDU/H5Vuf8= +github.com/go-openapi/swag/conv v0.24.0 h1:ejB9+7yogkWly6pnruRX45D1/6J+ZxRu92YFivx54ik= +github.com/go-openapi/swag/conv v0.24.0/go.mod h1:jbn140mZd7EW2g8a8Y5bwm8/Wy1slLySQQ0ND6DPc2c= +github.com/go-openapi/swag/fileutils v0.24.0 h1:U9pCpqp4RUytnD689Ek/N1d2N/a//XCeqoH508H5oak= +github.com/go-openapi/swag/fileutils v0.24.0/go.mod h1:3SCrCSBHyP1/N+3oErQ1gP+OX1GV2QYFSnrTbzwli90= +github.com/go-openapi/swag/jsonname v0.24.0 h1:2wKS9bgRV/xB8c62Qg16w4AUiIrqqiniJFtZGi3dg5k= +github.com/go-openapi/swag/jsonname v0.24.0/go.mod h1:GXqrPzGJe611P7LG4QB9JKPtUZ7flE4DOVechNaDd7Q= +github.com/go-openapi/swag/jsonutils v0.24.0 h1:F1vE1q4pg1xtO3HTyJYRmEuJ4jmIp2iZ30bzW5XgZts= +github.com/go-openapi/swag/jsonutils v0.24.0/go.mod h1:vBowZtF5Z4DDApIoxcIVfR8v0l9oq5PpYRUuteVu6f0= +github.com/go-openapi/swag/loading v0.24.0 h1:ln/fWTwJp2Zkj5DdaX4JPiddFC5CHQpvaBKycOlceYc= +github.com/go-openapi/swag/loading v0.24.0/go.mod h1:gShCN4woKZYIxPxbfbyHgjXAhO61m88tmjy0lp/LkJk= +github.com/go-openapi/swag/mangling v0.24.0 h1:PGOQpViCOUroIeak/Uj/sjGAq9LADS3mOyjznmHy2pk= +github.com/go-openapi/swag/mangling v0.24.0/go.mod h1:Jm5Go9LHkycsz0wfoaBDkdc4CkpuSnIEf62brzyCbhc= +github.com/go-openapi/swag/netutils v0.24.0 h1:Bz02HRjYv8046Ycg/w80q3g9QCWeIqTvlyOjQPDjD8w= +github.com/go-openapi/swag/netutils v0.24.0/go.mod h1:WRgiHcYTnx+IqfMCtu0hy9oOaPR0HnPbmArSRN1SkZM= +github.com/go-openapi/swag/stringutils v0.24.0 h1:i4Z/Jawf9EvXOLUbT97O0HbPUja18VdBxeadyAqS1FM= +github.com/go-openapi/swag/stringutils v0.24.0/go.mod h1:5nUXB4xA0kw2df5PRipZDslPJgJut+NjL7D25zPZ/4w= +github.com/go-openapi/swag/typeutils v0.24.0 h1:d3szEGzGDf4L2y1gYOSSLeK6h46F+zibnEas2Jm/wIw= +github.com/go-openapi/swag/typeutils v0.24.0/go.mod h1:q8C3Kmk/vh2VhpCLaoR2MVWOGP8y7Jc8l82qCTd1DYI= +github.com/go-openapi/swag/yamlutils v0.24.0 h1:bhw4894A7Iw6ne+639hsBNRHg9iZg/ISrOVr+sJGp4c= +github.com/go-openapi/swag/yamlutils v0.24.0/go.mod h1:DpKv5aYuaGm/sULePoeiG8uwMpZSfReo1HR3Ik0yaG8= github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +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/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= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +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/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= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +github.com/swaggo/files v1.0.1 h1:J1bVJ4XHZNq0I46UU90611i9/YzdrF7x92oX1ig5IdE= +github.com/swaggo/files v1.0.1/go.mod h1:0qXmMNH6sXNf+73t65aKeB+ApmgxdnkQzVTAj2uaMUg= +github.com/swaggo/http-swagger v1.3.4 h1:q7t/XLx0n15H1Q9/tk3Y9L4n210XzJF5WtnDX64a5ww= +github.com/swaggo/http-swagger v1.3.4/go.mod h1:9dAh0unqMBAlbp1uE2Uc2mQTxNMU/ha4UbucIg1MFkQ= +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= +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= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= +golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/handler/covid_handler.go b/internal/handler/covid_handler.go index 311626d..2d8c003 100644 --- a/internal/handler/covid_handler.go +++ b/internal/handler/covid_handler.go @@ -23,6 +23,19 @@ 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 +// @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)" +// @Success 200 {object} Response{data=[]models.NationalCaseResponse} +// @Failure 400 {object} Response +// @Failure 500 {object} Response +// @Router /national [get] func (h *CovidHandler) GetNationalCases(w http.ResponseWriter, r *http.Request) { startDate := r.URL.Query().Get("start_date") endDate := r.URL.Query().Get("end_date") @@ -50,6 +63,17 @@ func (h *CovidHandler) GetNationalCases(w http.ResponseWriter, r *http.Request) writeSuccessResponse(w, responseData) } +// 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] func (h *CovidHandler) GetLatestNationalCase(w http.ResponseWriter, r *http.Request) { nationalCase, err := h.covidService.GetLatestNationalCase() if err != nil { @@ -67,6 +91,18 @@ func (h *CovidHandler) GetLatestNationalCase(w http.ResponseWriter, r *http.Requ writeSuccessResponse(w, responseData) } +// 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] 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" @@ -90,6 +126,25 @@ func (h *CovidHandler) GetProvinces(w http.ResponseWriter, r *http.Request) { writeSuccessResponse(w, provincesWithCases) } +// 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)" +// @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"] @@ -216,6 +271,16 @@ func (h *CovidHandler) GetProvinceCases(w http.ResponseWriter, r *http.Request) writeSuccessResponse(w, paginatedResponse) } +// 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] func (h *CovidHandler) HealthCheck(w http.ResponseWriter, r *http.Request) { health := map[string]interface{}{ "status": "healthy", diff --git a/internal/handler/routes.go b/internal/handler/routes.go index 13dd612..c4aff5c 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -1,9 +1,12 @@ package handler import ( + "net/http" + "github.com/banua-coder/pico-api-go/internal/service" "github.com/banua-coder/pico-api-go/pkg/database" "github.com/gorilla/mux" + httpSwagger "github.com/swaggo/http-swagger" ) func SetupRoutes(covidService service.CovidService, db *database.DB) *mux.Router { @@ -20,5 +23,13 @@ func SetupRoutes(covidService service.CovidService, db *database.DB) *mux.Router api.HandleFunc("/provinces/cases", covidHandler.GetProvinceCases).Methods("GET", "OPTIONS") api.HandleFunc("/provinces/{provinceId}/cases", covidHandler.GetProvinceCases).Methods("GET", "OPTIONS") + // Swagger documentation + router.PathPrefix("/swagger/").Handler(httpSwagger.WrapHandler).Methods("GET") + + // Redirect root to swagger docs for convenience + router.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + http.Redirect(w, r, "/swagger/index.html", http.StatusFound) + }).Methods("GET") + return router } \ No newline at end of file From 2220623df8c7201894d5eab5ad1650fc1194f89a Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Sun, 7 Sep 2025 23:42:48 +0700 Subject: [PATCH 10/13] docs: clarify Sulawesi Tengah focus in API documentation - Update API title to 'Sulawesi Tengah COVID-19 Data API' - Clarify that national and other provincial data are for context - Emphasize primary focus on Central Sulawesi COVID-19 data - Update all documentation files (README, API docs, Swagger specs) - Regenerate OpenAPI documentation with correct regional focus This better reflects the actual purpose and scope of the API, making it clear to users that while national data is available, the main focus is on Sulawesi Tengah regional COVID-19 information. --- API_DOCUMENTATION.md | 4 ++-- README.md | 7 ++++--- cmd/main.go | 10 +++++----- docs/README.md | 5 +++-- docs/docs.go | 8 ++++---- docs/swagger.json | 8 ++++---- docs/swagger.yaml | 13 +++++++------ 7 files changed, 29 insertions(+), 26 deletions(-) diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md index 9341a70..2f5f87c 100644 --- a/API_DOCUMENTATION.md +++ b/API_DOCUMENTATION.md @@ -1,11 +1,11 @@ -# COVID-19 Indonesia API Documentation +# Sulawesi Tengah COVID-19 Data API Documentation Version: 2.0.2 Base URL: `https://pico-api.banuacoder.com/api/v1` ## Overview -This API provides COVID-19 data for Indonesia, including national statistics and province-level data. The API supports both paginated responses (for efficient data loading) and complete datasets (for analytics and charts). +This API provides COVID-19 data primarily focused on Sulawesi Tengah (Central Sulawesi), with additional national and provincial data for context. The API supports both paginated responses (for efficient data loading) and complete datasets (for analytics and charts). ## Response Format diff --git a/README.md b/README.md index d869970..891af58 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,11 @@ -# Pico API Go - COVID-19 Data API +# Pico API Go - Sulawesi Tengah COVID-19 Data API -A Go backend service that provides REST API endpoints for COVID-19 data in Indonesia, including national cases and province-level statistics. +A Go backend service that provides REST API endpoints for COVID-19 data in Sulawesi Tengah (Central Sulawesi), with additional national and provincial data for context. ## Features -- 🦠 National COVID-19 cases data with daily and cumulative statistics +- 🏛️ **Sulawesi Tengah focused** COVID-19 data with comprehensive statistics +- 🦠 National COVID-19 cases data for reference and context - 🗺️ Province-level COVID-19 data with enhanced ODP/PDP grouping - 📊 R-rate (reproductive rate) data when available - 🔍 Date range filtering for all endpoints diff --git a/cmd/main.go b/cmd/main.go index 4484bc6..3e48165 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -1,8 +1,8 @@ -// Package main provides the entry point for the COVID-19 Indonesia API +// Package main provides the entry point for the Sulawesi Tengah COVID-19 Data API // -// @title COVID-19 Indonesia API +// @title Sulawesi Tengah COVID-19 Data API // @version 2.0.2 -// @description A comprehensive REST API for COVID-19 data in Indonesia, including national cases and province-level statistics with enhanced ODP/PDP grouping and hybrid pagination. +// @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 and hybrid pagination. // @termsOfService http://swagger.io/terms/ // // @contact.name API Support @@ -21,10 +21,10 @@ // @tag.description Health check operations // // @tag.name national -// @tag.description National COVID-19 case operations +// @tag.description National COVID-19 case operations (for context) // // @tag.name provinces -// @tag.description Province information and COVID-19 case operations +// @tag.description Province information and COVID-19 case operations (focus on Sulawesi Tengah) // // @tag.name province-cases // @tag.description Province-level COVID-19 case data with pagination support diff --git a/docs/README.md b/docs/README.md index 1eb5203..7e9cf99 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,6 +1,6 @@ # API Documentation -This directory contains auto-generated OpenAPI/Swagger documentation for the COVID-19 Indonesia API. +This directory contains auto-generated OpenAPI/Swagger documentation for the Sulawesi Tengah COVID-19 Data API. ## Files @@ -45,7 +45,8 @@ This will update all files in this directory based on the Go code annotations. ## Key Features Documented - 🏥 **Health Check** - API status and database connectivity -- 🇮🇩 **National Data** - COVID-19 statistics for Indonesia +- 🏛️ **Sulawesi Tengah Focus** - Primary COVID-19 data for Central Sulawesi +- 🇮🇩 **National Data** - COVID-19 statistics for Indonesia (reference context) - 🗺️ **Province Data** - Provincial COVID-19 information with latest case data by default - 📊 **Province Cases** - Detailed case data with hybrid pagination support - 📄 **Pagination** - Comprehensive pagination metadata and flexible data retrieval diff --git a/docs/docs.go b/docs/docs.go index 3005ba8..b750ff9 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -719,11 +719,11 @@ const docTemplate = `{ "name": "health" }, { - "description": "National COVID-19 case operations", + "description": "National COVID-19 case operations (for context)", "name": "national" }, { - "description": "Province information and COVID-19 case operations", + "description": "Province information and COVID-19 case operations (focus on Sulawesi Tengah)", "name": "provinces" }, { @@ -739,8 +739,8 @@ var SwaggerInfo = &swag.Spec{ Host: "pico-api.banuacoder.com", BasePath: "/api/v1", Schemes: []string{"https", "http"}, - Title: "COVID-19 Indonesia API", - Description: "A comprehensive REST API for COVID-19 data in Indonesia, including national cases and province-level statistics with enhanced ODP/PDP grouping and hybrid pagination.", + Title: "Sulawesi Tengah COVID-19 Data API", + 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 and hybrid pagination.", InfoInstanceName: "swagger", SwaggerTemplate: docTemplate, LeftDelim: "{{", diff --git a/docs/swagger.json b/docs/swagger.json index 7e96a1c..97a170d 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -5,8 +5,8 @@ ], "swagger": "2.0", "info": { - "description": "A comprehensive REST API for COVID-19 data in Indonesia, including national cases and province-level statistics with enhanced ODP/PDP grouping and hybrid pagination.", - "title": "COVID-19 Indonesia API", + "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 and hybrid pagination.", + "title": "Sulawesi Tengah COVID-19 Data API", "termsOfService": "http://swagger.io/terms/", "contact": { "name": "API Support", @@ -717,11 +717,11 @@ "name": "health" }, { - "description": "National COVID-19 case operations", + "description": "National COVID-19 case operations (for context)", "name": "national" }, { - "description": "Province information and COVID-19 case operations", + "description": "Province information and COVID-19 case operations (focus on Sulawesi Tengah)", "name": "provinces" }, { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 298dbe3..6d30fc3 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -199,14 +199,14 @@ info: email: support@banuacoder.com name: API Support url: https://github.com/banua-coder/pico-api-go - description: A comprehensive REST API for COVID-19 data in Indonesia, including - national cases and province-level statistics with enhanced ODP/PDP grouping and - hybrid pagination. + 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 and hybrid pagination. license: name: MIT url: https://opensource.org/licenses/MIT termsOfService: http://swagger.io/terms/ - title: COVID-19 Indonesia API + title: Sulawesi Tengah COVID-19 Data API version: 2.0.2 paths: /health: @@ -455,9 +455,10 @@ swagger: "2.0" tags: - description: Health check operations name: health -- description: National COVID-19 case operations +- description: National COVID-19 case operations (for context) name: national -- description: Province information and COVID-19 case operations +- description: Province information and COVID-19 case operations (focus on Sulawesi + Tengah) name: provinces - description: Province-level COVID-19 case data with pagination support name: province-cases From 60b9bc436e86d969a03cb9de4aaed0ec15c55cd9 Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Sun, 7 Sep 2025 23:44:55 +0700 Subject: [PATCH 11/13] feat: add MIT License - Add LICENSE file with MIT license terms - Update README to reference LICENSE file - Include copyright notice for Banua Coder - Ensures proper open source licensing for the project The MIT license allows free use, modification, and distribution while providing liability protection for the authors. --- LICENSE | 21 +++++++++++++++++++++ README.md | 4 +++- 2 files changed, 24 insertions(+), 1 deletion(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a86baf9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Banua Coder + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 891af58..389e63c 100644 --- a/README.md +++ b/README.md @@ -295,4 +295,6 @@ git flow feature finish feature-name ## License -This project is licensed under the MIT License. \ No newline at end of file +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. + +Copyright (c) 2024 Banua Coder From 1f2727ce3b59d20021a5179012e2c1fa248eb44c Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Sun, 7 Sep 2025 23:46:47 +0700 Subject: [PATCH 12/13] docs: remove manual API docs and update project structure - Remove API_DOCUMENTATION.md (replaced by auto-generated Swagger docs) - Update README project structure to reflect current directories - Add descriptions for docs/, pkg/utils/, test/, and other directories - Highlight auto-generated documentation approach The OpenAPI/Swagger documentation provides comprehensive API docs that are always up-to-date with the code, making manual documentation files redundant. --- API_DOCUMENTATION.md | 359 ------------------------------------------- README.md | 26 +++- 2 files changed, 19 insertions(+), 366 deletions(-) delete mode 100644 API_DOCUMENTATION.md diff --git a/API_DOCUMENTATION.md b/API_DOCUMENTATION.md deleted file mode 100644 index 2f5f87c..0000000 --- a/API_DOCUMENTATION.md +++ /dev/null @@ -1,359 +0,0 @@ -# Sulawesi Tengah COVID-19 Data API Documentation - -Version: 2.0.2 -Base URL: `https://pico-api.banuacoder.com/api/v1` - -## Overview - -This API provides COVID-19 data primarily focused on Sulawesi Tengah (Central Sulawesi), with additional national and provincial data for context. The API supports both paginated responses (for efficient data loading) and complete datasets (for analytics and charts). - -## Response Format - -### Success Response -```json -{ - "status": "success", - "data": { ... } -} -``` - -### Paginated Response -```json -{ - "status": "success", - "data": { - "data": [...], - "pagination": { - "limit": 50, - "offset": 0, - "total": 1000, - "total_pages": 20, - "page": 1, - "has_next": true, - "has_prev": false - } - } -} -``` - -### Error Response -```json -{ - "status": "error", - "message": "Error description" -} -``` - -## Pagination Parameters - -All endpoints support hybrid pagination: - -| Parameter | Type | Default | Max | Description | -|-----------|------|---------|-----|-------------| -| `limit` | int | 50 | 1000 | Number of records per page | -| `offset` | int | 0 | - | Number of records to skip | -| `all` | boolean | false | - | Return all data (bypasses pagination) | - -## Enhanced Province Data Structure - -Province case data now includes grouped ODP/PDP fields: - -```json -{ - "day": 100, - "date": "2024-01-15T00:00:00Z", - "daily": { - "positive": 150, - "recovered": 120, - "deceased": 10, - "active": 20, - "odp": { - "active": 5, - "finished": 20 - }, - "pdp": { - "active": 8, - "finished": 25 - } - }, - "cumulative": { - "positive": 5000, - "recovered": 4500, - "deceased": 300, - "active": 200, - "odp": { - "active": 50, - "finished": 750, - "total": 800 - }, - "pdp": { - "active": 20, - "finished": 580, - "total": 600 - } - }, - "statistics": { - "percentages": { - "active": 4.0, - "recovered": 90.0, - "deceased": 6.0 - }, - "reproduction_rate": { - "value": 1.2, - "upper_bound": 1.5, - "lower_bound": 0.9 - } - } -} -``` - -## Endpoints - -### 1. Health Check - -**GET** `/health` - -Check API health and database connectivity. - -**Response:** -```json -{ - "status": "success", - "data": { - "status": "healthy", - "service": "COVID-19 API", - "version": "2.0.2", - "timestamp": "2024-01-15T10:30:00Z", - "database": { - "status": "healthy", - "connections": { - "open": 2, - "idle": 1, - "in_use": 1, - "max_open": 5, - "wait_count": 0 - } - } - } -} -``` - -### 2. National Cases - -**GET** `/national` - -Get national COVID-19 cases data. - -**Query Parameters:** -- `start_date` (string, optional): Start date (YYYY-MM-DD) -- `end_date` (string, optional): End date (YYYY-MM-DD) - -**Examples:** -```bash -# Get all national data -GET /national - -# Get data for specific date range -GET /national?start_date=2024-01-01&end_date=2024-01-31 -``` - -### 3. Latest National Case - -**GET** `/national/latest` - -Get the most recent national case data. - -### 4. Provinces - -**GET** `/provinces` - -Get list of all provinces with their latest COVID-19 case data (default behavior). - -**Query Parameters:** -- `exclude_latest_case` (boolean, optional): Return basic province list without case data - -**Examples:** -```bash -# Provinces with latest case data (default) -GET /provinces - -# Basic province list without case data -GET /provinces?exclude_latest_case=true -``` - -### 5. Province Cases - -**GET** `/provinces/cases` -**GET** `/provinces/{provinceId}/cases` - -Get COVID-19 cases for all provinces or a specific province. - -**Path Parameters:** -- `provinceId` (string, optional): Province ID (e.g., "31" for Jakarta) - -**Query Parameters:** -- `limit` (int, optional): Records per page (default: 50, max: 1000) -- `offset` (int, optional): Records to skip (default: 0) -- `all` (boolean, optional): Return all data without pagination -- `start_date` (string, optional): Start date (YYYY-MM-DD) -- `end_date` (string, optional): End date (YYYY-MM-DD) - -**Examples:** - -```bash -# Paginated province cases (default: 50 records) -GET /provinces/cases - -# Custom pagination -GET /provinces/cases?limit=100&offset=200 - -# All data (for charts/analytics) -GET /provinces/cases?all=true - -# Specific province with pagination -GET /provinces/31/cases?limit=30 - -# Specific province, all data -GET /provinces/31/cases?all=true - -# Date range with pagination -GET /provinces/cases?start_date=2024-01-01&end_date=2024-01-31&limit=100 - -# Date range, all data (for time series charts) -GET /provinces/cases?start_date=2024-01-01&end_date=2024-01-31&all=true -``` - -**Response Structure:** - -*Paginated Response:* -```json -{ - "status": "success", - "data": { - "data": [ - { "day": 1, "date": "2024-01-15", ... }, - { "day": 2, "date": "2024-01-14", ... } - ], - "pagination": { - "limit": 50, - "offset": 0, - "total": 1000, - "total_pages": 20, - "page": 1, - "has_next": true, - "has_prev": false - } - } -} -``` - -*All Data Response:* -```json -{ - "status": "success", - "data": [ - { "day": 1, "date": "2024-01-15", ... }, - { "day": 2, "date": "2024-01-14", ... } - ] -} -``` - -## Usage Patterns - -### 1. Efficient Data Loading (Default) -```javascript -// Load first page with 50 records -const response = await fetch('/api/v1/provinces/cases'); -const { data, pagination } = response.data; - -// Load next page -if (pagination.has_next) { - const nextPage = await fetch(`/api/v1/provinces/cases?offset=${pagination.offset + pagination.limit}`); -} -``` - -### 2. Charts & Analytics -```javascript -// Get complete dataset for time series chart -const response = await fetch('/api/v1/provinces/cases?all=true&start_date=2024-01-01&end_date=2024-12-31'); -const allData = response.data; - -// Perfect for Chart.js, D3.js, etc. -const chartData = allData.map(item => ({ - x: item.date, - y: item.cumulative.positive -})); -``` - -### 3. Province-Specific Analysis -```javascript -// Get all Jakarta data for detailed analysis -const response = await fetch('/api/v1/provinces/31/cases?all=true'); -const jakartaData = response.data; -``` - -## Error Handling - -### Common HTTP Status Codes -- `200` - Success -- `400` - Bad Request (invalid parameters) -- `404` - Not Found -- `500` - Internal Server Error -- `503` - Service Unavailable (database issues) - -### Error Response Examples -```json -{ - "status": "error", - "message": "Invalid date format. Use YYYY-MM-DD" -} -``` - -```json -{ - "status": "error", - "message": "Province not found" -} -``` - -## Rate Limiting - -- No rate limiting currently implemented -- Consider implementing if needed for production use - -## CORS - -CORS is enabled for all origins to support web applications. - -## Data Sources - -- Data is sourced from official Indonesian health authorities -- Updates are typically daily -- Historical data available from the beginning of the pandemic - -## Best Practices - -1. **Use pagination by default** for better performance -2. **Use `all=true` only for analytics/charts** to avoid large payloads -3. **Implement client-side caching** for frequently accessed data -4. **Use date ranges** to limit data scope when possible -5. **Handle errors gracefully** and implement retry logic -6. **Monitor response times** and adjust pagination limits as needed - -## Changelog - -### Version 2.0.2 -- ✅ Enhanced ODP/PDP data grouping structure -- ✅ Implemented hybrid pagination system -- ✅ Added provinces with latest case data endpoint -- ✅ Improved API response structure -- ✅ Added comprehensive pagination metadata - -### Version 2.0.1 -- Fixed database column typos -- Fixed RT display issues - -### Version 2.0.0 -- Major API restructure -- Added province-level data -- Enhanced statistics calculations \ No newline at end of file diff --git a/README.md b/README.md index 389e63c..6b64ece 100644 --- a/README.md +++ b/README.md @@ -268,21 +268,33 @@ git flow feature finish feature-name ### Project Structure ``` ├── cmd/ # Application entry points -│ └── main.go +│ └── main.go # Main application entry point +├── docs/ # Auto-generated API documentation +│ ├── docs.go # Generated Go documentation +│ ├── swagger.json # OpenAPI specification (JSON) +│ ├── swagger.yaml # OpenAPI specification (YAML) +│ └── README.md # Documentation guide ├── internal/ # Private application code │ ├── config/ # Configuration management │ ├── handler/ # HTTP handlers and routes │ ├── middleware/ # HTTP middleware -│ ├── models/ # Data models +│ ├── models/ # Data models and response structures │ ├── repository/ # Data access layer │ └── service/ # Business logic layer ├── pkg/ # Public packages -│ └── database/ # Database connection +│ ├── database/ # Database connection utilities +│ └── utils/ # Query parameter parsing utilities +├── test/ # Test files +│ └── integration/ # Integration tests ├── .env.example # Environment configuration template -├── .gitignore -├── go.mod -├── go.sum -└── README.md +├── .github/ # GitHub Actions workflows +├── CHANGELOG.md # Version history and changes +├── CLAUDE.md # AI assistant configuration +├── LICENSE # MIT License +├── Makefile # Build and test commands +├── go.mod # Go module definition +├── go.sum # Go module checksums +└── README.md # This file ``` ## Contributing From ea4308c050a52f36629a966c1138ae1b33596749 Mon Sep 17 00:00:00 2001 From: Fajrian Aidil Pratama Date: Mon, 8 Sep 2025 00:29:18 +0700 Subject: [PATCH 13/13] Add API index endpoint for endpoint discovery - Add GetAPIIndex handler to return comprehensive endpoint information - Include API metadata, documentation links, and feature descriptions - Support both /api/v1 and /api/v1/ routes for better UX - Add comprehensive test coverage for the new endpoint - Update Swagger documentation with new endpoint specification - Enhance API discoverability with structured endpoint listing --- docs/docs.go | 36 ++++++++++++ docs/swagger.json | 36 ++++++++++++ docs/swagger.yaml | 21 +++++++ internal/handler/covid_handler.go | 79 ++++++++++++++++++++++++++ internal/handler/covid_handler_test.go | 40 +++++++++++++ internal/handler/routes.go | 5 ++ 6 files changed, 217 insertions(+) diff --git a/docs/docs.go b/docs/docs.go index b750ff9..f226601 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -24,6 +24,42 @@ const docTemplate = `{ "host": "{{.Host}}", "basePath": "{{.BasePath}}", "paths": { + "/": { + "get": { + "description": "Get a list of all available API endpoints with descriptions", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "health" + ], + "summary": "API endpoint index", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": true + } + } + } + ] + } + } + } + } + }, "/health": { "get": { "description": "Check API health status and database connectivity", diff --git a/docs/swagger.json b/docs/swagger.json index 97a170d..66fb437 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -22,6 +22,42 @@ "host": "pico-api.banuacoder.com", "basePath": "/api/v1", "paths": { + "/": { + "get": { + "description": "Get a list of all available API endpoints with descriptions", + "consumes": [ + "application/json" + ], + "produces": [ + "application/json" + ], + "tags": [ + "health" + ], + "summary": "API endpoint index", + "responses": { + "200": { + "description": "OK", + "schema": { + "allOf": [ + { + "$ref": "#/definitions/handler.Response" + }, + { + "type": "object", + "properties": { + "data": { + "type": "object", + "additionalProperties": true + } + } + } + ] + } + } + } + } + }, "/health": { "get": { "description": "Check API health status and database connectivity", diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 6d30fc3..db5f118 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -209,6 +209,27 @@ info: title: Sulawesi Tengah COVID-19 Data API version: 2.0.2 paths: + /: + get: + consumes: + - application/json + description: Get a list of all available API endpoints with descriptions + produces: + - application/json + responses: + "200": + description: OK + schema: + allOf: + - $ref: '#/definitions/handler.Response' + - properties: + data: + additionalProperties: true + type: object + type: object + summary: API endpoint index + tags: + - health /health: get: consumes: diff --git a/internal/handler/covid_handler.go b/internal/handler/covid_handler.go index 2d8c003..a994a9c 100644 --- a/internal/handler/covid_handler.go +++ b/internal/handler/covid_handler.go @@ -328,3 +328,82 @@ func (h *CovidHandler) HealthCheck(w http.ResponseWriter, r *http.Request) { Data: health, }) } + +// 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] +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.0.2", + "description": "A comprehensive REST API for COVID-19 data in Sulawesi Tengah (Central Sulawesi)", + }, + "documentation": map[string]interface{}{ + "swagger_ui": "/swagger/index.html", + "openapi": map[string]string{ + "yaml": "/docs/swagger.yaml", + "json": "/docs/swagger.json", + }, + }, + "endpoints": map[string]interface{}{ + "health": map[string]interface{}{ + "url": "/api/v1/health", + "method": "GET", + "description": "Check API health status and database connectivity", + }, + "national": map[string]interface{}{ + "list": map[string]string{ + "url": "/api/v1/national", + "method": "GET", + "description": "Get national COVID-19 cases (with optional date range)", + }, + "latest": map[string]string{ + "url": "/api/v1/national/latest", + "method": "GET", + "description": "Get latest national COVID-19 case data", + }, + }, + "provinces": map[string]interface{}{ + "list": map[string]string{ + "url": "/api/v1/provinces", + "method": "GET", + "description": "Get provinces with latest case data (default)", + }, + "cases": map[string]interface{}{ + "all": map[string]string{ + "url": "/api/v1/provinces/cases", + "method": "GET", + "description": "Get province cases (paginated by default, ?all=true for complete data)", + }, + "specific": map[string]string{ + "url": "/api/v1/provinces/{provinceId}/cases", + "method": "GET", + "description": "Get cases for specific province (e.g., /api/v1/provinces/72/cases for Sulawesi Tengah)", + }, + }, + }, + }, + "features": []string{ + "Hybrid pagination system (paginated by default, ?all=true for complete data)", + "Date range filtering (?start_date=YYYY-MM-DD&end_date=YYYY-MM-DD)", + "Enhanced ODP/PDP data grouping", + "Provinces with latest case data by default", + "Sulawesi Tengah focused with national context data", + }, + "examples": map[string]interface{}{ + "sulawesi_tengah_cases": "/api/v1/provinces/72/cases", + "paginated_data": "/api/v1/provinces/cases?limit=100&offset=50", + "date_range": "/api/v1/national?start_date=2024-01-01&end_date=2024-12-31", + "complete_dataset": "/api/v1/provinces/cases?all=true", + }, + } + + writeSuccessResponse(w, endpoints) +} diff --git a/internal/handler/covid_handler_test.go b/internal/handler/covid_handler_test.go index ab10935..bb44299 100644 --- a/internal/handler/covid_handler_test.go +++ b/internal/handler/covid_handler_test.go @@ -525,6 +525,46 @@ func TestCovidHandler_GetProvinces_ExcludeLatestCase(t *testing.T) { mockService.AssertExpectations(t) } +func TestCovidHandler_GetAPIIndex(t *testing.T) { + mockService := new(MockCovidService) + handler := NewCovidHandler(mockService, nil) + + req, err := http.NewRequest("GET", "/api/v1", nil) + assert.NoError(t, err) + + rr := httptest.NewRecorder() + handler.GetAPIIndex(rr, req) + + assert.Equal(t, http.StatusOK, rr.Code) + + var response Response + err = json.Unmarshal(rr.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "success", response.Status) + + // Verify structure contains expected keys + data, ok := response.Data.(map[string]interface{}) + assert.True(t, ok) + assert.Contains(t, data, "api") + assert.Contains(t, data, "documentation") + assert.Contains(t, data, "endpoints") + assert.Contains(t, data, "features") + assert.Contains(t, data, "examples") + + // Verify API info + 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.0.2", apiInfo["version"]) + + // Verify endpoints structure + endpoints, ok := data["endpoints"].(map[string]interface{}) + assert.True(t, ok) + assert.Contains(t, endpoints, "health") + assert.Contains(t, endpoints, "national") + assert.Contains(t, endpoints, "provinces") +} + func TestCovidHandler_HealthCheck(t *testing.T) { mockService := new(MockCovidService) handler := NewCovidHandler(mockService, nil) diff --git a/internal/handler/routes.go b/internal/handler/routes.go index c4aff5c..5bb52ab 100644 --- a/internal/handler/routes.go +++ b/internal/handler/routes.go @@ -16,6 +16,11 @@ func SetupRoutes(covidService service.CovidService, db *database.DB) *mux.Router api := router.PathPrefix("/api/v1").Subrouter() + // API index endpoint + api.HandleFunc("", covidHandler.GetAPIIndex).Methods("GET", "OPTIONS") + api.HandleFunc("/", covidHandler.GetAPIIndex).Methods("GET", "OPTIONS") + + // Main endpoints api.HandleFunc("/health", covidHandler.HealthCheck).Methods("GET", "OPTIONS") api.HandleFunc("/national", covidHandler.GetNationalCases).Methods("GET", "OPTIONS") api.HandleFunc("/national/latest", covidHandler.GetLatestNationalCase).Methods("GET", "OPTIONS")